terminal_commander_ipc/protocol.rs
1// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
2// Copyright 2026 The Terminal Commander Authors
3
4//! IPC wire protocol (TC37).
5//!
6//! Length-prefixed JSON frames. Every frame begins with a 4-byte
7//! big-endian `u32` payload length, followed by exactly that many
8//! bytes of UTF-8 JSON. Frames larger than [`MAX_FRAME_BYTES`] are
9//! rejected before the payload is decoded.
10//!
11//! Method set at TC37 is deliberately tiny:
12//! - `system_discover` — version + capabilities + tool list.
13//! - `health` — daemon liveness ping.
14//! - `policy_status` — active profile + the daemon-side caps.
15//! - `self_check` — re-run the bounded TC36 self-check report.
16//!
17//! No `command_*`, no `bucket_*`, no `event_context`, no
18//! `file_read_*` — those land in TC38 (process wiring), TC39
19//! (bucket/event daemon API), and TC41 (MCP tool surface). TC37
20//! deliberately ships a minimal, safe method set so the transport
21//! lock-in does not race ahead of policy / wiring goals.
22//!
23//! Source-status: live (TC37).
24
25use std::path::PathBuf;
26use std::time::Duration;
27
28use serde::{Deserialize, Serialize};
29use terminal_commander_core::{
30 ActivationScope, BucketConfig, BucketId, EventId, JobId, RuleDefinition, RuleStatus, SessionId,
31 Severity, SignalEvent, SourceStream,
32};
33
34/// US2 (FR-011): a non-blocking hint that a curated rule pack exists
35/// for the tool being started but is not active. Surfaced on
36/// command-start responses so an agent can self-serve the comb rules.
37///
38/// This is advisory only: it changes no behavior and activates
39/// nothing (constitution VII). The agent acts on it by calling
40/// `registry_import_pack` (named in `action`).
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42pub struct PackAvailableHint {
43 /// Always `"pack_available"`. A discriminant for forward-compat.
44 pub kind: String,
45 /// The recognized pack id (e.g. `"docker"`, `"git"`).
46 pub pack: String,
47 /// Always `"registry_import_pack"`: the tool to call to act on the
48 /// hint.
49 pub action: String,
50}
51
52impl PackAvailableHint {
53 /// Build a `pack_available` hint for the named pack.
54 #[must_use]
55 pub fn for_pack(pack: impl Into<String>) -> Self {
56 Self {
57 kind: "pack_available".to_owned(),
58 pack: pack.into(),
59 action: "registry_import_pack".to_owned(),
60 }
61 }
62}
63
64/// Bounded response shape. Carries identifiers and counters, never
65/// raw stdout/stderr.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct CommandStartResponse {
68 pub job_id: JobId,
69 pub bucket_id: BucketId,
70 pub probe_id: terminal_commander_core::ProbeId,
71 /// Initial bucket cursor: clients pass this to `bucket_events_since`.
72 pub cursor: u64,
73 /// US2 (FR-011): optional hint that a curated pack exists for this
74 /// tool but is not active. Omitted (None) when the tool is
75 /// unrecognized or its pack is already active. Advisory only.
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub hint: Option<PackAvailableHint>,
78}
79
80/// No-silence exit receipt (TCE-ERG-1).
81///
82/// Present ONLY when a finished process command produced ZERO
83/// rule-driven events. This is the one sanctioned exception to "TC
84/// never returns raw output": a bounded, truthful tail so a zero-rule
85/// command does not read as breakage.
86///
87/// PTY and file-watch jobs never produce a receipt: it is built solely in the
88/// combed lifecycle waiter, and the other lanes have no binding in the command
89/// runtime's live map, so this field stays `None` for them. A tail therefore
90/// cannot include secret-prompt input.
91///
92/// An earlier version of this comment claimed `handle_command_status` routed
93/// PTY job ids to `UnknownJob`. It never did -- the job ledger is shared across
94/// lanes -- and that gap is the defect spec 004 fixes by routing a status read
95/// to the runtime that owns the job. The no-secret-leak conclusion above holds
96/// on its own terms, independent of that routing.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct CommandReceipt {
99 pub exit_code: Option<i32>,
100 /// Frames the command produced that no rule matched, i.e. lines
101 /// the agent would otherwise have scrolled. `frames_total` for a
102 /// zero-rule run.
103 pub lines_suppressed: u64,
104 /// Last N frame texts (oldest first), byte-capped.
105 pub tail: Vec<String>,
106 /// True when the ring evicted earlier frames; the tail may omit
107 /// the start of output.
108 pub tail_incomplete: bool,
109}
110
111/// How a reported outcome was established (spec 004 FR-006).
112///
113/// Present on EVERY [`CommandStatusResponse`], never conditionally omitted, so
114/// the normal and degraded shapes cannot drift apart. Constitution VII requires
115/// public diagnostics to use closed typed codes rather than free text, which is
116/// why this is a fixed enum and not a note field.
117///
118/// ## "lost" is deliberately NOT a value here
119///
120/// A job the daemon recorded starting and never recorded finishing has no
121/// outcome to report, so it is not a status payload at all: it is delivered as
122/// a typed [`IpcErrorCode::JobLost`] error. An earlier draft carried a `Lost`
123/// variant and the agent-facing docs promised it as a field value, but nothing
124/// ever constructed it -- an agent branching on `outcome_trust == "lost"` would
125/// have waited forever for a value that could not arrive (found by all three
126/// reviewers of this branch). Every variant below has a live construction site.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
128#[serde(rename_all = "snake_case")]
129pub enum OutcomeTrust {
130 /// The daemon witnessed this outcome live. Every counter is a real
131 /// observation.
132 #[default]
133 Observed,
134 /// Read back from the durable receipt after the in-memory job was gone.
135 /// `state` and `exit_code` are truthful; the counters are the values
136 /// captured when the job finished. Receipts written before the evidence
137 /// migration carry no counters, and that absence is reported honestly.
138 Reconstructed,
139 /// Ended by daemon shutdown or stale replacement rather than reaching its
140 /// own conclusion. Reported with lifecycle state `Cancelled` and no exit
141 /// code -- deliberately NOT a failure, and deliberately not a new
142 /// [`JobState`](terminal_commander_core::JobState) variant (spec D1).
143 Abandoned,
144}
145
146/// Bounded status shape. Counters + final exit state only.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct CommandStatusResponse {
149 pub job_id: JobId,
150 pub bucket_id: BucketId,
151 pub probe_id: terminal_commander_core::ProbeId,
152 pub state: terminal_commander_core::JobState,
153 pub frames_total: u64,
154 pub frames_stdout: u64,
155 pub frames_stderr: u64,
156 pub bytes_total: u64,
157 pub events_emitted: u64,
158 #[serde(default)]
159 pub frames_suppressed: u64,
160 #[serde(default)]
161 pub frames_suppressed_progress: u64,
162 #[serde(default)]
163 pub frames_suppressed_dedupe: u64,
164 pub exit_code: Option<i32>,
165 pub signal: Option<String>,
166 pub duration_ms: Option<u64>,
167 /// No-silence receipt; `Some` only for a finished process command
168 /// with zero rule-driven events. See [`CommandReceipt`].
169 pub receipt: Option<CommandReceipt>,
170 /// TC-B3 (FR-027): `true` when this status was reconstructed from a
171 /// PERSISTED job receipt because the in-memory job was gone (a daemon
172 /// restart happened since the job ran).
173 ///
174 /// Retained as the backward-compatible alias for "this outcome was NOT
175 /// observed live", i.e. `outcome_trust != OutcomeTrust::Observed`. It is
176 /// therefore `true` for both `Reconstructed` and `Abandoned`, both of which
177 /// are read back from the durable receipt. Derive it from `outcome_trust`
178 /// rather than setting the two independently, so they cannot drift.
179 ///
180 /// NOTE: an earlier version of this comment said the live counters are
181 /// "zero because the in-memory probe metrics did not survive". That is no
182 /// longer true -- spec 004 persists the evidence a live observer would have
183 /// had, so a reconstructed status carries real counters. Only receipts
184 /// written before that migration lack them.
185 #[serde(default)]
186 pub restarted: bool,
187 /// How this outcome was established. See [`OutcomeTrust`]. Defaults to
188 /// `Observed` so a payload from an older daemon decodes unchanged.
189 #[serde(default)]
190 pub outcome_trust: OutcomeTrust,
191}
192
193/// Params for `command_stop` (TC-3): force-kill a running combed
194/// command by `job_id`.
195///
196/// Mirrors [`PtyCommandStopParams`] for the non-PTY runtime.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct CommandStopParams {
199 pub job_id: JobId,
200}
201
202/// Bounded response for `command_stop` (TC-3).
203///
204/// Mirrors [`PtyCommandStopResponse`] minus the PTY-only counters
205/// (`stdin_bytes_written`, `secret_prompts_total`), which have no
206/// meaning for a non-interactive combed command.
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct CommandStopResponse {
209 pub job_id: JobId,
210 pub bucket_id: BucketId,
211 pub frames_total: u64,
212 pub events_emitted: u64,
213 pub bytes_total: u64,
214}
215
216/// Hard cap on a complete frame (length prefix + payload). Anything
217/// above this is rejected without payload parse.
218///
219/// 256 KiB is large enough for the JSON shapes ahead (e.g. a full
220/// `bucket_events_since` response capped at 10000 events ~ 25 bytes
221/// per event minimum overhead) and small enough that one malicious
222/// client cannot exhaust daemon memory by streaming a giant frame.
223/// Bucket / event-context responses still carry their own per-call
224/// caps; this is the transport-layer envelope cap.
225pub const MAX_FRAME_BYTES: usize = 256 * 1024;
226
227/// Soft cap on the request side.
228///
229/// Applied before the request is dispatched. Currently identical to
230/// [`MAX_FRAME_BYTES`]; kept as a named constant so future tools can
231/// raise / lower it independently from response sizing.
232pub const MAX_REQUEST_BYTES: usize = MAX_FRAME_BYTES;
233
234/// Soft cap on the response side. Matches the frame cap today.
235pub const MAX_RESPONSE_BYTES: usize = MAX_FRAME_BYTES;
236
237/// Wire correlation id.
238///
239/// The client picks; the server echoes. Used to distinguish responses
240/// on a multiplexed connection. Today the connection is request /
241/// response one-at-a-time; the field future-proofs the protocol.
242pub type CorrelationId = u64;
243
244/// Top-level request envelope.
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct RequestEnvelope {
247 pub correlation_id: CorrelationId,
248 pub request: IpcRequest,
249}
250
251/// Top-level response envelope.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct ResponseEnvelope {
254 pub correlation_id: CorrelationId,
255 pub result: IpcResult,
256}
257
258/// Maximum wait timeout the daemon will accept for `bucket_wait`.
259/// Requests above this are clamped at the dispatcher.
260pub const MAX_BUCKET_WAIT_MS: u64 = 30_000;
261/// Default wait timeout when the client omits one.
262pub const DEFAULT_BUCKET_WAIT_MS: u64 = 5_000;
263/// Hard cap on events returned by `bucket_events_since` /
264/// `bucket_wait`. Mirrors the codebase `MAX_READ_LIMIT`.
265pub const MAX_BUCKET_READ_LIMIT: usize = 10_000;
266/// Default events-per-call when the client omits a limit.
267pub const DEFAULT_BUCKET_READ_LIMIT: usize = 200;
268/// Hard cap on context-window frames returned by `event_context`.
269pub const MAX_CONTEXT_FRAMES: u32 = 1024;
270/// Default `before` count when the client omits one.
271pub const DEFAULT_CONTEXT_BEFORE: u32 = 5;
272/// Default `after` count when the client omits one.
273pub const DEFAULT_CONTEXT_AFTER: u32 = 5;
274/// Hard cap on event_context payload bytes. Mirrors the per-ring
275/// `max_bytes` cap; the dispatcher clamps oversize values.
276pub const MAX_CONTEXT_BYTES: usize = 64 * 1024;
277
278/// Maximum number of subscriptions open at once in the in-memory registry.
279///
280/// Opening beyond this returns
281/// [`IpcErrorCode::SubscriptionLimitExceeded`]; the caller frees a
282/// slot via `subscription_close` and retries. (Subscriptions design,
283/// Phase 1, Task 7.)
284pub const MAX_SUBSCRIPTIONS: usize = 64;
285/// Hard cap on in-scope buckets a single subscription scans per pull.
286///
287/// The `list_bucket_ids()` ∩ side-table scan is bounded by this; over-cap
288/// is flagged `truncated`. (Subscriptions design §1 "Routing scan is
289/// bounded".)
290pub const MAX_BUCKETS_PER_SUBSCRIPTION: usize = 200;
291
292/// Hard cap on events returned by one `subscription_pull`. The caller's
293/// `max` is clamped to this; the combined events+liveness response stays
294/// under [`MAX_FRAME_BYTES`]. (Subscriptions design §3 step 8.)
295pub const MAX_PULL_EVENTS: usize = 50;
296/// Default `subscription_pull` timeout when the caller omits `timeout_ms`.
297pub const DEFAULT_PULL_TIMEOUT_MS: u64 = 5_000;
298/// Hard cap on a `subscription_pull` timeout.
299///
300/// Strictly below the unix `DRAIN_CEILING` (10 s) so a blocked pull returns
301/// its normal empty+liveness at its own timeout before a graceful drain
302/// would abort it. (Subscriptions design §3 "Timeout reconciliation".)
303pub const MAX_PULL_TIMEOUT_MS: u64 = 8_000;
304
305/// Method-typed request union.
306///
307/// Method names are namespaced `<domain>_<verb>` to match the MCP tool
308/// names; the rmcp adapter maps each daemon-backed tool 1:1 to a method.
309/// 50 IPC methods are live, including the `audit_since` read surface and the
310/// supervisor-only `quiesce_for_replace` verb.
311/// The full rmcp catalogue exposes 51 granular tools (see
312/// `docs/mcp/TOOL_CONTROL_SURFACE.md` §2); the compact MCP surface instead
313/// advertises five facade tools, gated by `TC_SURFACE=compact`, that forward
314/// to the same IPC methods.
315#[derive(Debug, Clone, Serialize, Deserialize)]
316#[serde(tag = "method", content = "params", rename_all = "snake_case")]
317pub enum IpcRequest {
318 /// Get daemon version, MCP spec revision, callable method list.
319 SystemDiscover,
320 /// Liveness ping. Returns the daemon uptime in seconds.
321 Health,
322 /// Report active policy profile + the configured per-call caps.
323 PolicyStatus,
324 /// Re-run the TC36 self-check; returns the report as text.
325 SelfCheck,
326 /// Cursor-based bucket read. Bounded by `MAX_BUCKET_READ_LIMIT`.
327 BucketEventsSince(BucketEventsSinceParams),
328 /// Realtime wait. Returns heartbeat on timeout, never raw text.
329 BucketWait(BucketWaitParams),
330 /// Bounded summary (counters + severity histogram).
331 BucketSummary(BucketSummaryParams),
332 /// Bounded context window around the event's source pointer.
333 /// Resolved by `(bucket_id, event_id)`; the daemon walks the
334 /// bucket to find the matching event, then resolves
335 /// `(probe_id, pointer.frame_id)` against the context ring.
336 EventContext(EventContextParams),
337 /// Start a non-PTY argv command. Bounded metadata response only;
338 /// never returns raw stdout/stderr. Shell-bridge guard applies.
339 CommandStartCombed(CommandStartParams),
340 /// Lifecycle + counters lookup for a previously started command.
341 CommandStatus(CommandStatusParams),
342 /// Force-kill a running combed command by `job_id` (TC-3). Bounded
343 /// metadata response; never returns raw output.
344 CommandStop(CommandStopParams),
345 /// Rule-free bounded read of a job's captured output tail (F1).
346 CommandOutputTail(CommandOutputTailParams),
347 /// Start a shell-lane command (TC49): run ONE shell line through the
348 /// comb pipeline behind the `allow_shell` capability. Denied by
349 /// default; the wire carries `shell_line` ONLY, never a cap flag.
350 /// Bounded metadata response (reuses [`CommandStartResponse`]);
351 /// never returns raw stdout/stderr.
352 ShellExec(ShellExecParams),
353 /// FTS-backed search over persisted rule definitions.
354 RegistrySearch(RegistrySearchParams),
355 /// Fetch a specific rule definition by id and optional version.
356 RegistryGet(RegistryGetParams),
357 /// Insert a new (rule_id, version+1) row from a validated
358 /// definition. Existing versions are immutable.
359 RegistryUpsert(RegistryUpsertParams),
360 /// Evaluate a rule against bounded sample texts and return the
361 /// emitted draft shapes. Read-only; never persisted.
362 RegistryTest(RegistryTestParams),
363 /// Mark `(rule_id, version)` as active in the in-memory
364 /// activation registry AND record the persistent activation row.
365 RegistryActivate(RegistryActivateParams),
366 /// Import an embedded rule pack by name; optionally promote its
367 /// rules to Active and activate them in one call.
368 RegistryImportPack(RegistryImportPackParams),
369 /// Remove `(rule_id, version)` from the active set and close
370 /// the persistent activation row.
371 RegistryDeactivate(RegistryDeactivateParams),
372 /// US2 (FR-011): deactivate an entire seed pack or an explicit list
373 /// of rule ids in ONE call, under one explicit scope, reporting
374 /// per-rule outcomes. The single-rule [`RegistryDeactivate`] wire
375 /// contract is untouched.
376 RegistryDeactivateBulk(RegistryDeactivateBulkParams),
377 /// Snapshot of every currently-active `(rule_id, version)`. Bounded
378 /// by [`MAX_LIST_LIMIT`] / the request `limit`.
379 RegistryListActive(ListLimitParams),
380 /// US2 (FR-007): suggest candidate parsing rules from bounded
381 /// output samples using PURE heuristics. Returns DRAFT proposals +
382 /// a confidence label + the explicit next-step loop. NEVER
383 /// activates or persists a rule (FR-008 / constitution VII).
384 /// Read-only: a blind retry recomputes the same deterministic
385 /// proposals, so it is idempotent.
386 RegistrySuggestFromSamples(RegistrySuggestFromSamplesParams),
387 /// Bounded line/byte window read of a regular file. Never
388 /// returns the whole file; the daemon clamps the window.
389 FileReadWindow(FileReadWindowParams),
390 /// Bounded substring/regex search over one file. Returns
391 /// structured match pointers + short snippets only.
392 FileSearch(FileSearchParams),
393 /// Bounded single-level listing of one directory (US3). Returns
394 /// name/kind/size/mtime per entry, dirs-first deterministic order,
395 /// capped with a truthful truncation flag. Same read-path policy gate
396 /// as `file_read_window`. Symlinks/reparse points reported by kind,
397 /// never followed.
398 FileListDir(FileListDirParams),
399 /// Write UTF-8 content to a single regular file (TC22 A3).
400 /// MUTATING + NON-idempotent: a blind retry would double-write.
401 /// The daemon policy-gates the canonical target against
402 /// `paths.write_allow`, audits BEFORE the write, bounds the content
403 /// size, and writes atomically (temp file + rename).
404 FileWrite(FileWriteParams),
405 /// Start a daemon-owned file probe that emits structured signal
406 /// events into a bucket as the file is appended to. Never
407 /// streams raw file content.
408 FileWatchStart(FileWatchStartParams),
409 /// Stop a previously-started file watch by `watch_id`.
410 FileWatchStop(FileWatchStopParams),
411 /// Snapshot of every currently-live file watch.
412 FileWatchList,
413 /// Start an interactive PTY argv command. Bounded metadata
414 /// response only; never returns raw screen buffer.
415 PtyCommandStart(PtyCommandStartParams),
416 /// Write bounded stdin bytes to a running PTY job. Returns
417 /// `SecretInputDenied` while a secret prompt is active.
418 PtyCommandWriteStdin(PtyCommandWriteStdinParams),
419 /// Stop a previously started PTY job by `job_id`.
420 PtyCommandStop(PtyCommandStopParams),
421 /// Snapshot of every currently-live PTY job.
422 PtyCommandList,
423 /// Start a persistent shell session (P1 / TC50): a long-lived
424 /// login-shell PTY behind the `allow_session` capability. Denied by
425 /// default; policy-checked + audited before spawn. Bounded metadata
426 /// response (`session_id`/`bucket_id`/`state`); never raw output.
427 ShellSessionStart(ShellSessionStartParams),
428 /// Send ONE line to a live session shell and read back the combed
429 /// signals it produced. Output is read from the session bucket; never
430 /// a raw stream. A send to a non-live session fails loudly.
431 ShellSessionExec(ShellSessionExecParams),
432 /// Query a session's lifecycle state, current cwd, and bounded env
433 /// snapshot.
434 ShellSessionStatus(ShellSessionStatusParams),
435 /// Stop a session (graceful then forced) and report the terminal
436 /// state.
437 ShellSessionStop(ShellSessionStopParams),
438 /// Snapshot of every currently-live session.
439 ShellSessionList,
440 /// Persist a session's cwd + bounded env as a restorable workspace
441 /// snapshot (SQLite). Read-once of session state + a DB write.
442 WorkspaceSnapshotCreate(WorkspaceSnapshotCreateParams),
443 /// Restore a workspace snapshot's cwd/env into a (live) session.
444 WorkspaceSnapshotApply(WorkspaceSnapshotApplyParams),
445 /// TC45: bounded aggregate snapshot across every daemon runtime
446 /// (command, file watch, PTY) plus active rule scopes and
447 /// bucket counters. Read-only. Each of its three vecs is bounded
448 /// INDEPENDENTLY by [`MAX_LIST_LIMIT`] / the request `limit`.
449 RuntimeState(ListLimitParams),
450 /// TC45: flat list of every live probe across all runtimes.
451 /// Read-only. Bounded by [`MAX_LIST_LIMIT`] / the request `limit`.
452 ProbeList(ListLimitParams),
453 /// TC45: bounded lookup for one probe by id. Returns
454 /// `UnknownProbe` if no runtime knows the id.
455 ProbeStatus(ProbeStatusParams),
456 /// Cursor-based read of the persistent audit log. Read-only;
457 /// bounded by [`MAX_AUDIT_READ_LIMIT`]. Read failure surfaces
458 /// [`IpcErrorCode::Internal`] (the closed error set is not widened
459 /// for this method).
460 AuditSince(AuditSinceParams),
461 /// Open a predicate-routed subscription. Mints a fresh opaque
462 /// `sub_id` with its own independent offsets (consumer isolation).
463 /// Initial offsets for already-in-scope buckets are their current
464 /// tail (from-now for a late open). (Subscriptions §4.)
465 SubscriptionOpen(SubscriptionOpenParams),
466 /// Multiplexed, lossless pull over an open subscription. Returns
467 /// bounded, source-tagged events + per-source liveness. Idle returns
468 /// SUCCESS empty+liveness, never an error; unknown/expired `sub_id`
469 /// returns [`IpcErrorCode::UnknownSubscription`]. Blocks up to the
470 /// (clamped) timeout. (Subscriptions §3.)
471 SubscriptionPull(SubscriptionPullParams),
472 /// Bounded snapshot of every open subscription. (Subscriptions §6.)
473 SubscriptionList(SubscriptionListParams),
474 /// Close a subscription, freeing its registry slot. (Subscriptions §4.)
475 SubscriptionClose(SubscriptionCloseParams),
476 /// Reposition one bucket's offset for a subscription (explicit re-read).
477 /// The requested seq is clamped to the bucket's live range. (Subscriptions
478 /// §3 seek.)
479 SubscriptionSeek(SubscriptionSeekParams),
480 /// Request a graceful shutdown. The daemon ACKs immediately
481 /// (`ShutdownAck`), stops accepting new connections, drains in-flight
482 /// requests, removes its pidfile, and exits 0. New connections during the
483 /// drain receive `ShuttingDown` (retryable).
484 Shutdown,
485 /// spec 004 FR-013: ask a daemon that is about to be REPLACED to durably
486 /// record its in-flight jobs as abandoned before it is killed.
487 ///
488 /// The replacer cannot do this itself: the in-flight set lives in the
489 /// outgoing daemon's memory, not on disk. So the outgoing daemon writes its
490 /// own records, and the replacer waits a bounded beat before hard-killing
491 /// as it does today.
492 ///
493 /// Strictly weaker than [`Self::Shutdown`]: spawns nothing, kills nothing,
494 /// exits nothing. It only writes rows about the daemon's own jobs, so it
495 /// carries the same authorization posture -- protected by the local-only
496 /// endpoint and attested peer identity, not by a capability flag.
497 ///
498 /// Quiescing MUST NEVER be able to block a replacement: a timeout, an
499 /// error, or an older daemon that does not know this verb all fall back to
500 /// current behaviour.
501 QuiesceForReplace,
502}
503
504impl IpcRequest {
505 /// Whether this RPC is safe to blindly re-send after a transport
506 /// failure (the daemon pipe/socket dropped mid-call so the client
507 /// never learned the outcome).
508 ///
509 /// Governing rule: return `false` for any RPC whose retry could
510 /// create or duplicate a server-side resource, mint a fresh id, or
511 /// advance server-held state; return `true` only for pure bounded
512 /// reads and idempotent-effect repositioning. When in doubt, return
513 /// `false` -- a missed retry is an error the caller can re-issue
514 /// deliberately, but a silent double-effect cannot be undone.
515 ///
516 /// The match is EXHAUSTIVE (no wildcard arm) so that adding a new
517 /// `IpcRequest` variant fails to compile until it is deliberately
518 /// classified here.
519 #[must_use]
520 pub const fn is_idempotent(&self) -> bool {
521 match self {
522 // Mutating / unsafe to blind-retry: each one creates or
523 // duplicates server-side state, mints a fresh id, or advances
524 // a server-held offset.
525 Self::CommandStartCombed(_)
526 // Shell-lane start (TC49): spawns a fresh `[shell,"-lc",line]`
527 // child + mints a job/bucket exactly like CommandStartCombed,
528 // so a blind retry double-spawns. Non-idempotent.
529 | Self::ShellExec(_)
530 // Force-kill: fires a one-shot cancel + sets the job terminal.
531 // A blind re-send is a harmless no-op on an already-terminal job,
532 // but it is a server-side state MUTATION, so it is classified
533 // non-idempotent alongside the other Command*/Pty* mutators.
534 | Self::CommandStop(_)
535 | Self::PtyCommandStart(_)
536 | Self::PtyCommandWriteStdin(_)
537 | Self::PtyCommandStop(_)
538 // Session lane (P1 / TC50): start spawns a fresh session
539 // shell + mints ids; exec writes stdin + advances the read
540 // cursor server-side; stop fires a one-shot cancel; the
541 // workspace snapshot create/apply mutate persisted state or
542 // re-inject cwd/env into the session shell. All non-idempotent
543 // alongside the PTY mutators.
544 | Self::ShellSessionStart(_)
545 | Self::ShellSessionExec(_)
546 | Self::ShellSessionStop(_)
547 | Self::WorkspaceSnapshotCreate(_)
548 | Self::WorkspaceSnapshotApply(_)
549 | Self::RegistryUpsert(_)
550 | Self::RegistryActivate(_)
551 | Self::RegistryDeactivate(_)
552 // Bulk deactivate mutates the active set (durable rows +
553 // in-memory authority) exactly like the single-rule form; a
554 // blind retry re-closes already-closed rows (harmless no-op)
555 // but is still a server-side mutation, so it groups with the
556 // other registry mutators.
557 | Self::RegistryDeactivateBulk(_)
558 | Self::RegistryImportPack(_)
559 // File WRITE (TC22 A3): creates or overwrites a file on disk.
560 // A blind retry double-writes (or re-truncates) the target, so
561 // it MUST be non-idempotent -- the BACKLOG P0.1 client self-heal
562 // can never auto-retry a write. Classified MUTATING alongside
563 // command_start / file_watch_start, NOT with the read-only
564 // file_read_window / file_search.
565 | Self::FileWrite(_)
566 | Self::FileWatchStart(_)
567 | Self::FileWatchStop(_)
568 // Mints a fresh sub_id + a registry slot; a blind retry leaks
569 // a slot and can trip SubscriptionLimitExceeded.
570 | Self::SubscriptionOpen(_)
571 // Frees a slot; conservative non-retry.
572 | Self::SubscriptionClose(_)
573 // NOT a safe read: per-consumer offsets are advanced and
574 // committed SERVER-SIDE inside the pull (the drain advances
575 // offsets and `commit` persists them, subscriptions/pull.rs
576 // commit sites at 543/633) BEFORE the response is serialized.
577 // A lost-then-
578 // retried pull restarts from the already-advanced offset, so
579 // the previously-drained events are silently dropped --
580 // converting the documented lossless pull into a lossy one.
581 // Contrast BucketWait, which is client-cursor-driven and fully
582 // replayable.
583 | Self::SubscriptionPull(_)
584 | Self::Shutdown
585 // spec 004 FR-013. The write itself is idempotent (INSERT OR
586 // REPLACE of the same terminal row), but it is still a server-side
587 // mutation and the caller is about to kill this daemon anyway --
588 // a blind retry buys nothing. Grouped conservatively with Shutdown.
589 | Self::QuiesceForReplace => false,
590
591 // Pure bounded reads + idempotent-effect repositioning: a
592 // retry observes state without changing it (or re-applies the
593 // same absolute reposition).
594 Self::Health
595 | Self::SystemDiscover
596 | Self::PolicyStatus
597 | Self::SelfCheck
598 | Self::CommandStatus(_)
599 | Self::CommandOutputTail(_)
600 | Self::BucketWait(_)
601 | Self::BucketEventsSince(_)
602 | Self::BucketSummary(_)
603 | Self::EventContext(_)
604 | Self::RuntimeState(_)
605 | Self::ProbeList(_)
606 | Self::ProbeStatus(_)
607 | Self::PtyCommandList
608 // Session read-only lookups: pure bounded reads, replayable.
609 | Self::ShellSessionStatus(_)
610 | Self::ShellSessionList
611 | Self::FileReadWindow(_)
612 | Self::FileSearch(_)
613 // Directory listing (US3): a pure bounded read, replayable and
614 // side-effect free, so it groups with the file read/search reads.
615 | Self::FileListDir(_)
616 | Self::FileWatchList
617 | Self::RegistrySearch(_)
618 | Self::RegistryGet(_)
619 | Self::RegistryTest(_)
620 | Self::RegistryListActive(_)
621 // Suggestion is a pure deterministic heuristic over the
622 // supplied samples: a retry recomputes the identical
623 // proposal set and never activates/persists anything.
624 | Self::RegistrySuggestFromSamples(_)
625 | Self::SubscriptionList(_)
626 // Set-position (absolute clamped offset), not advance-position,
627 // so a re-send re-applies the same reposition. Caveat: the
628 // clamp target is live state (head_seq/tail_seq), so a retry
629 // after bucket eviction may clamp to a later position than the
630 // first attempt -- identical to the hazard of any deliberate
631 // re-seek, hence still idempotent in shape.
632 | Self::SubscriptionSeek(_)
633 | Self::AuditSince(_) => true,
634 }
635 }
636}
637
638/// Success / error union. Success carries a typed payload per method;
639/// error carries a structured code + message.
640///
641/// Boxing the `Ok` payload would change neither the JSON wire form
642/// nor the public Rust API (serde renders `Box<T>` as `T`), so the
643/// large-variant lint is suppressed in favor of keeping the
644/// pattern-matched shape that every dispatcher and test uses.
645#[derive(Debug, Clone, Serialize, Deserialize)]
646#[serde(rename_all = "snake_case", tag = "kind")]
647#[allow(clippy::large_enum_variant)]
648pub enum IpcResult {
649 Ok { response: IpcResponse },
650 Err { error: IpcError },
651}
652
653/// Method-typed success union. Each variant matches one
654/// [`IpcRequest`] variant.
655#[derive(Debug, Clone, Serialize, Deserialize)]
656#[serde(rename_all = "snake_case", tag = "method")]
657pub enum IpcResponse {
658 SystemDiscover(DiscoverResponse),
659 Health {
660 uptime_secs: u64,
661 /// Seconds since the last real IPC request. Optional for
662 /// backward compat: a legacy daemon omits it; clients treat
663 /// absence as unknown.
664 #[serde(default)]
665 idle_secs: Option<u64>,
666 /// The responding daemon's own compile-time crate version
667 /// (`env!("CARGO_PKG_VERSION")`). Lets a client assert WHICH
668 /// build is live. `#[serde(default)]` keeps back-compat: a
669 /// legacy daemon omits it, so an empty string means "unknown".
670 #[serde(default)]
671 version: String,
672 },
673 PolicyStatus(PolicyStatusResponse),
674 SelfCheck(SelfCheckResponse),
675 BucketEventsSince(BucketEventsSinceResponse),
676 BucketWait(BucketWaitResponse),
677 BucketSummary(BucketSummaryResponse),
678 EventContext(EventContextResponse),
679 CommandStartCombed(CommandStartResponse),
680 CommandStatus(CommandStatusResponse),
681 CommandStop(CommandStopResponse),
682 CommandOutputTail(CommandOutputTailResponse),
683 RegistrySearch(RegistrySearchResponse),
684 RegistryGet(RegistryGetResponse),
685 RegistryUpsert(RegistryUpsertResponse),
686 RegistryTest(RegistryTestResponse),
687 RegistryActivate(RegistryActivateResponse),
688 RegistryImportPack(RegistryImportPackResponse),
689 RegistryDeactivate(RegistryDeactivateResponse),
690 RegistryDeactivateBulk(RegistryDeactivateBulkResponse),
691 RegistryListActive(RegistryListActiveResponse),
692 RegistrySuggestFromSamples(RegistrySuggestFromSamplesResponse),
693 FileReadWindow(FileReadWindowResponse),
694 FileSearch(FileSearchResponse),
695 FileListDir(FileListDirResponse),
696 FileWrite(FileWriteResponse),
697 FileWatchStart(FileWatchStartResponse),
698 FileWatchStop(FileWatchStopResponse),
699 FileWatchList(FileWatchListResponse),
700 PtyCommandStart(PtyCommandStartResponse),
701 PtyCommandWriteStdin(PtyCommandWriteStdinResponse),
702 PtyCommandStop(PtyCommandStopResponse),
703 PtyCommandList(PtyCommandListResponse),
704 ShellSessionStart(ShellSessionStartResponse),
705 ShellSessionExec(ShellSessionExecResponse),
706 ShellSessionStatus(ShellSessionStatusResponse),
707 ShellSessionStop(ShellSessionStopResponse),
708 ShellSessionList(ShellSessionListResponse),
709 WorkspaceSnapshotCreate(WorkspaceSnapshotCreateResponse),
710 WorkspaceSnapshotApply(WorkspaceSnapshotApplyResponse),
711 RuntimeState(RuntimeStateResponse),
712 ProbeList(ProbeListResponse),
713 ProbeStatus(ProbeStatusResponse),
714 AuditSince(AuditSinceResponse),
715 SubscriptionOpen(SubscriptionOpenResponse),
716 SubscriptionPull(SubscriptionPullResponse),
717 SubscriptionList(SubscriptionListResponse),
718 SubscriptionClose(SubscriptionCloseResponse),
719 SubscriptionSeek(SubscriptionSeekResponse),
720 /// Ack for `Shutdown`. `draining=true` once the daemon has stopped accepting
721 /// new connections and begun draining.
722 ShutdownAck {
723 draining: bool,
724 },
725 /// ACK for [`IpcRequest::QuiesceForReplace`]: how many in-flight jobs were
726 /// recorded as abandoned. Zero is a normal answer (an idle daemon).
727 QuiesceAck {
728 recorded: u32,
729 },
730}
731
732/// Evidence-backed host program probe used by `system_discover`.
733#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
734pub struct ProgramProbe {
735 pub name: String,
736 pub available: bool,
737 #[serde(default, skip_serializing_if = "Option::is_none")]
738 pub path: Option<String>,
739 #[serde(default, skip_serializing_if = "Option::is_none")]
740 pub version: Option<String>,
741 pub evidence: String,
742 pub version_status: String,
743 #[serde(default)]
744 pub execution_status: String,
745}
746
747/// Terminal/session markers inherited by the daemon process.
748#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
749pub struct TerminalProbe {
750 pub kind: String,
751 pub evidence: String,
752 #[serde(default, skip_serializing_if = "Option::is_none")]
753 pub name: Option<String>,
754 #[serde(default, skip_serializing_if = "Option::is_none")]
755 pub version: Option<String>,
756 #[serde(default, skip_serializing_if = "Option::is_none")]
757 pub interactive: Option<bool>,
758 pub ci: bool,
759}
760
761/// Bounded WSL availability details on Windows; unavailable elsewhere.
762#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
763pub struct WslProbe {
764 pub available: bool,
765 pub status: String,
766 #[serde(default, skip_serializing_if = "Option::is_none")]
767 pub version: Option<String>,
768 #[serde(default, skip_serializing_if = "Vec::is_empty")]
769 pub distributions: Vec<String>,
770 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub default_shell: Option<String>,
772 #[serde(default)]
773 pub execution_status: String,
774}
775
776/// A verified way to cross from the daemon host into a command interpreter.
777#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
778pub struct AccessRoute {
779 pub route_id: String,
780 pub rank: u16,
781 pub kind: String,
782 pub family: String,
783 pub executable: String,
784 pub argv_template: Vec<String>,
785 #[serde(default, skip_serializing_if = "Option::is_none")]
786 pub version: Option<String>,
787 pub evidence: String,
788}
789
790/// Bounded, evidence-backed execution environment snapshot.
791#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
792pub struct HostEnvironment {
793 pub os: String,
794 pub arch: String,
795 pub terminal: TerminalProbe,
796 pub shells: Vec<ProgramProbe>,
797 pub tools: Vec<ProgramProbe>,
798 pub wsl: WslProbe,
799 #[serde(default, skip_serializing_if = "Vec::is_empty")]
800 pub access_routes: Vec<AccessRoute>,
801 #[serde(default, skip_serializing_if = "Option::is_none")]
802 pub beachhead: Option<AccessRoute>,
803 #[serde(default, skip_serializing_if = "Option::is_none")]
804 pub preferred_shell: Option<String>,
805 pub discovery_ms: u64,
806}
807
808/// `system_discover` payload. Mirrors the contract laid out in
809/// `docs/mcp/TOOL_CONTROL_SURFACE.md`. The advertised method list is
810/// tied to the dispatcher's actual handler set.
811#[derive(Debug, Clone, Serialize, Deserialize)]
812pub struct DiscoverResponse {
813 pub version: String,
814 pub mcp_spec: String,
815 pub policy_profile: String,
816 pub methods: Vec<String>,
817 /// Host evidence is additive so newer adapters can still decode older
818 /// daemon responses as an empty/unknown snapshot.
819 #[serde(default)]
820 pub environment: Box<HostEnvironment>,
821}
822
823/// The four resolved per-call capabilities (POLICY.md section 4.1).
824///
825/// Surfaced on [`PolicyStatusResponse`] so an operator can see the ACTIVE caps
826/// -- including those preset ON by `full_access` -- without reading TOML.
827/// These are the values the policy engine actually evaluates against (base
828/// profile `||` `full_access` preset), never the raw config.
829// 4 independent opt-in capability flags; a bitfield/enum would hurt the wire/serde surface
830#[allow(clippy::struct_excessive_bools)]
831#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
832pub struct PolicyCapsView {
833 /// Gates the `shell_exec` lane (TC49).
834 pub allow_shell: bool,
835 /// Gates the `shell_session_*` lane (TC50; not yet live).
836 pub allow_session: bool,
837 /// Gates the Wave-4 privileged helper (not yet live).
838 pub allow_privileged: bool,
839 /// Gates remote federation / `target_id` (Wave 5; not yet live).
840 pub allow_remote: bool,
841}
842
843/// `policy_status` payload.
844#[derive(Debug, Clone, Serialize, Deserialize)]
845pub struct PolicyStatusResponse {
846 pub profile: String,
847 pub commands_deny_count: usize,
848 pub default_deny_path_suffix_count: usize,
849 /// Per-call file_read_window cap (from `LimitsSection`, clamped
850 /// at config load to the codebase hard cap).
851 pub file_window_bytes: usize,
852 /// Per-call bucket-read cap.
853 pub bucket_read_limit: usize,
854 /// Resolved per-call capabilities (POLICY.md section 4.1). Exposes the
855 /// caps the engine evaluates against -- so `full_access` (all preset ON)
856 /// and a base profile + `[policy.caps] allow_shell = true` both show the
857 /// active set, with no opaque "full_access magic".
858 pub caps: PolicyCapsView,
859}
860
861/// `self_check` payload.
862#[derive(Debug, Clone, Serialize, Deserialize)]
863pub struct SelfCheckResponse {
864 pub report: String,
865 pub failures: u32,
866}
867
868/// Structured error code. Closed set. Adding a variant requires a
869/// goal-file amendment.
870#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
871#[serde(rename_all = "snake_case")]
872pub enum IpcErrorCode {
873 /// Frame exceeded [`MAX_FRAME_BYTES`].
874 FrameTooLarge,
875 /// Payload was not valid UTF-8 JSON.
876 MalformedJson,
877 /// Payload decoded but didn't match the wire schema.
878 SchemaMismatch,
879 /// Method not recognized.
880 UnknownMethod,
881 /// Policy engine denied the request.
882 PolicyDenied,
883 /// Daemon-internal error while handling the request.
884 Internal,
885 /// Peer credential check failed; connection refused.
886 PeerCredentialFailure,
887 /// Platform does not support UDS (Windows native).
888 UnsupportedPlatform,
889 /// The requested bucket does not exist.
890 BucketNotFound,
891 /// The requested event id was not found in the bucket.
892 EventNotFound,
893 /// The cursor is invalid (e.g. far above the current tail).
894 InvalidCursor,
895 /// `argv[0]` basename matches the shell-bridge deny list.
896 /// `command_start_combed` is not a shell entry point.
897 ShellInterpreterDenied,
898 /// F7: the program named in `argv[0]` does not exist (the OS spawn
899 /// returned `ErrorKind::NotFound`). A CALLER-fixable command attempt
900 /// (typo / wrong PATH / missing binary), NOT a daemon or transport
901 /// fault. Surfaced as a structured `program_not_found` receipt at the
902 /// MCP boundary (`invalid_params`, carrying `error_kind` + `argv0`)
903 /// instead of an opaque `Internal` error so the agent corrects its
904 /// argv and keeps routing through Terminal Commander. Distinct from
905 /// every other spawn failure, which stays `Internal`.
906 ProgramNotFound,
907 /// argv shape is invalid (empty, too long, or item too large).
908 ArgvInvalid,
909 /// `command_status` was called with a job id the daemon does not
910 /// know.
911 UnknownJob,
912 /// `command_status` was called with a job id the daemon durably recorded
913 /// STARTING but never recorded finishing -- the daemon died before the
914 /// terminal transition. Distinct from [`Self::UnknownJob`], which means no
915 /// durable record exists at all. Never accompanied by a terminal outcome.
916 ///
917 /// Deliberately an error code rather than a lifecycle state: "the daemon
918 /// lost the thread" is not a job lifecycle state, and an unknown error code
919 /// fails closed for older clients.
920 JobLost,
921 /// `registry_get` / `registry_test` / `registry_activate` /
922 /// `registry_deactivate` referenced a `(rule_id, version?)` the
923 /// daemon does not know.
924 RuleNotFound,
925 /// `registry_upsert` or `registry_test` payload failed rule
926 /// validation (empty id, bad regex, kind/keywords mismatch,
927 /// etc.).
928 RuleInvalid,
929 /// `registry_activate` / `registry_deactivate` was issued with
930 /// a scope value the daemon cannot resolve to a live entity
931 /// (unknown bucket / job / probe id) or with a malformed scope
932 /// payload. The activation is NOT silently widened to Global.
933 ScopeInvalid,
934 /// `file_*` request referenced a path the policy engine rejected
935 /// (default-deny suffix or future per-profile path policy).
936 PathDenied,
937 /// `file_*` request referenced a path that does not exist on
938 /// disk OR is not a regular file (directories rejected here so
939 /// TC43 does not balloon into directory probe expansion).
940 FileNotFound,
941 /// `file_read_window` / `file_search` detected non-UTF-8 bytes
942 /// in the requested window. Binary content is rejected with a
943 /// typed code instead of streaming bytes to the LLM.
944 FileBinary,
945 /// Request exceeds a bounded cap (line count, byte count, glob
946 /// breadth, search result count). The dispatcher clamps where
947 /// safe; payloads that cannot be clamped surface this code.
948 OversizedRequest,
949 /// `file_watch_stop` referenced a watch id the daemon does not
950 /// know.
951 UnknownWatch,
952 /// `pty_command_write_stdin` was issued while the target PTY job
953 /// has an active secret prompt. The LLM input MUST NOT be
954 /// written. TC44 contract: no automatic password entry, no
955 /// LLM-supplied password forwarding.
956 SecretInputDenied,
957 /// `probe_status` referenced a probe id the daemon does not
958 /// know across any of its runtimes.
959 UnknownProbe,
960 /// `registry_activate` referenced a rule whose status is not
961 /// runtime-eligible (Draft / Deprecated / Tombstoned). Activating
962 /// a non-Active rule would silently bind a definition the sifter
963 /// runtime then rejects at command-start time with
964 /// `SifterError::NotActive`, blocking every newly-started command
965 /// in scope. The activation is refused up front with the remedy
966 /// in the message (promote the rule to status=Active and re-upsert)
967 /// rather than poisoning the scope. See the agent-ergonomics chain.
968 RuleNotActive,
969 /// `subscription_pull`/`subscription_close` referenced a `sub_id` the
970 /// daemon does not know (unknown or reset by a daemon restart). Caller
971 /// re-opens. Approved goal-file amendment 2026-06-02.
972 UnknownSubscription,
973 /// `subscription_open` exceeded the max-subscriptions cap. Caller frees
974 /// a slot (subscription_close) and retries. Approved 2026-06-02.
975 SubscriptionLimitExceeded,
976 /// `shell_session_*` referenced a `session_id` the daemon does not
977 /// know (never started, already reaped, or reset by a daemon
978 /// restart). P1 / TC50 (omni spec 001).
979 UnknownSession,
980 /// `shell_session_exec` (or a snapshot apply) targeted a session that
981 /// is not in the `Live` state. The terminal-state guard refuses the
982 /// send loudly instead of hanging on a dead shell. P1 / TC50.
983 SessionNotLive,
984 /// `shell_session_start` was refused because the configured
985 /// `max_sessions` cap is already reached. Caller stops a session and
986 /// retries. P1 / TC50.
987 SessionLimitExceeded,
988 /// Returned to a new request that arrives while the daemon is draining for
989 /// shutdown. Retryable: the client should cold-spawn a fresh daemon.
990 ShuttingDown,
991}
992
993/// Structured error payload.
994#[derive(Debug, Clone, Serialize, Deserialize)]
995pub struct IpcError {
996 pub code: IpcErrorCode,
997 pub message: String,
998 /// F7: the offending `argv[0]` for an [`IpcErrorCode::ProgramNotFound`]
999 /// error, carried as a TYPED field rather than recovered by parsing the
1000 /// human-readable `message`. The message still names the program for logs
1001 /// and humans, but this field is the authoritative source the MCP boundary
1002 /// reads to populate the `argv0` data key -- so the receipt survives any
1003 /// wording change to `message` (including apostrophes in the program name,
1004 /// which the old prose quote-count parse could not handle). Additive and
1005 /// `serde(default)`: omitted from the wire when `None`, so every other
1006 /// error (and any pre-F7 client payload) round-trips unchanged.
1007 #[serde(default, skip_serializing_if = "Option::is_none")]
1008 pub argv0: Option<String>,
1009 /// F14: the MCP tool name for an [`IpcErrorCode::UnsupportedPlatform`]
1010 /// error, carried as a TYPED field so the MCP boundary can name the
1011 /// caller-routable tool (e.g. `shell_session_start`) in its structured
1012 /// `unsupported_platform` receipt without parsing the human-readable
1013 /// `message`. An unsupported platform is a caller-ROUTABLE fact (route to
1014 /// WSL / a different tool), not a server fault, so the receipt must say
1015 /// WHICH tool is unavailable here. Additive and `serde(default)`: omitted
1016 /// from the wire when `None`, so every other error round-trips unchanged.
1017 #[serde(default, skip_serializing_if = "Option::is_none")]
1018 pub tool: Option<String>,
1019}
1020
1021impl IpcError {
1022 /// Marker lead-in for CLIENT-SIDE transport failures (connect, write,
1023 /// read, request timeout, correlation mismatch). These never carry a
1024 /// daemon-authored payload -- they fail before, or instead of, a decoded
1025 /// response -- so the marker lets a caller distinguish "could not reach
1026 /// the daemon" (recoverable: re-ensure + retry, then surface a clean
1027 /// `daemon_unavailable` envelope) from a daemon-RETURNED [`IpcErrorCode`]
1028 /// (a real, caller-actionable fault). The marker is a human-readable
1029 /// prefix on an otherwise-`Internal` error so the rendered message stays
1030 /// meaningful while [`IpcError::is_transport`] can detect it without
1031 /// fragile substring scanning of OS error text. The daemon NEVER
1032 /// constructs transport errors, so its `Internal` errors are never
1033 /// misclassified.
1034 pub const TRANSPORT_PREFIX: &'static str = "transport: ";
1035
1036 /// Constructor.
1037 #[must_use]
1038 pub fn new(code: IpcErrorCode, message: impl Into<String>) -> Self {
1039 Self {
1040 code,
1041 message: message.into(),
1042 argv0: None,
1043 tool: None,
1044 }
1045 }
1046
1047 /// F7: construct a [`IpcErrorCode::ProgramNotFound`] error that carries the
1048 /// offending `argv[0]` as a TYPED field. The `message` is still
1049 /// human/log-facing (and may name the program however it likes), but the
1050 /// `argv0` field -- not the prose -- is the authoritative value the MCP
1051 /// boundary reads into the structured `program_not_found` receipt.
1052 #[must_use]
1053 pub fn program_not_found(argv0: impl Into<String>, message: impl Into<String>) -> Self {
1054 Self {
1055 code: IpcErrorCode::ProgramNotFound,
1056 message: message.into(),
1057 argv0: Some(argv0.into()),
1058 tool: None,
1059 }
1060 }
1061
1062 /// F14: construct an [`IpcErrorCode::UnsupportedPlatform`] error that
1063 /// carries the offending MCP `tool` name as a TYPED field. The `message`
1064 /// stays human/log-facing, but the `tool` field -- not the prose -- is the
1065 /// authoritative value the MCP boundary reads into the structured
1066 /// `unsupported_platform` receipt. An unsupported platform is a
1067 /// caller-ROUTABLE fact (route to WSL / a different tool), so naming the
1068 /// unavailable tool keeps the agent reasoning instead of abandoning TC.
1069 #[must_use]
1070 pub fn unsupported_platform(tool: &str, message: impl Into<String>) -> Self {
1071 Self {
1072 code: IpcErrorCode::UnsupportedPlatform,
1073 message: message.into(),
1074 argv0: None,
1075 tool: Some(tool.to_owned()),
1076 }
1077 }
1078
1079 /// Construct a client-side TRANSPORT failure: an [`IpcErrorCode::Internal`]
1080 /// error tagged with [`Self::TRANSPORT_PREFIX`] so [`Self::is_transport`]
1081 /// recognizes it. Use this only for failures to REACH or COMMUNICATE with
1082 /// the daemon (connect / write / read / timeout / correlation mismatch),
1083 /// never for a daemon-returned error.
1084 #[must_use]
1085 pub fn transport(message: impl AsRef<str>) -> Self {
1086 Self {
1087 code: IpcErrorCode::Internal,
1088 message: format!("{}{}", Self::TRANSPORT_PREFIX, message.as_ref()),
1089 argv0: None,
1090 tool: None,
1091 }
1092 }
1093
1094 /// True when this is a client-side transport failure (see
1095 /// [`Self::transport`]). Distinguishes "could not reach the daemon" from a
1096 /// daemon-returned error so the MCP adapter can self-heal + retry and then
1097 /// surface a clean `daemon_unavailable` envelope instead of a raw
1098 /// `internal_error` (-32603) that trains agents to abandon the tool.
1099 #[must_use]
1100 pub fn is_transport(&self) -> bool {
1101 self.code == IpcErrorCode::Internal && self.message.starts_with(Self::TRANSPORT_PREFIX)
1102 }
1103}
1104
1105/// Parameters for `bucket_events_since`.
1106#[derive(Debug, Clone, Serialize, Deserialize)]
1107pub struct BucketEventsSinceParams {
1108 pub bucket_id: BucketId,
1109 pub cursor: u64,
1110 /// Optional minimum severity. Omitted = `trace` (no filter).
1111 #[serde(default, skip_serializing_if = "Option::is_none")]
1112 pub severity_min: Option<terminal_commander_core::Severity>,
1113 /// Optional exact-match kind filter.
1114 #[serde(default, skip_serializing_if = "Option::is_none")]
1115 pub kind_filter: Option<String>,
1116 /// Result count cap. Clamped to `MAX_BUCKET_READ_LIMIT` at the
1117 /// dispatcher. Omitted = `DEFAULT_BUCKET_READ_LIMIT`.
1118 #[serde(default, skip_serializing_if = "Option::is_none")]
1119 pub limit: Option<usize>,
1120}
1121
1122/// Response shape for `bucket_events_since` / `bucket_wait`.
1123#[derive(Debug, Clone, Serialize, Deserialize)]
1124pub struct BucketEventsSinceResponse {
1125 pub bucket_id: BucketId,
1126 pub cursor_in: u64,
1127 pub next_cursor: u64,
1128 pub has_more: bool,
1129 pub dropped_count: u64,
1130 pub events: Vec<SignalEvent>,
1131}
1132
1133/// Parameters for `bucket_wait`.
1134#[derive(Debug, Clone, Serialize, Deserialize)]
1135pub struct BucketWaitParams {
1136 pub bucket_id: BucketId,
1137 pub cursor: u64,
1138 #[serde(default, skip_serializing_if = "Option::is_none")]
1139 pub severity_min: Option<terminal_commander_core::Severity>,
1140 #[serde(default, skip_serializing_if = "Option::is_none")]
1141 pub kind_filter: Option<String>,
1142 #[serde(default, skip_serializing_if = "Option::is_none")]
1143 pub limit: Option<usize>,
1144 /// Maximum wait in milliseconds. Clamped to `MAX_BUCKET_WAIT_MS`.
1145 /// Omitted = `DEFAULT_BUCKET_WAIT_MS`.
1146 #[serde(default, skip_serializing_if = "Option::is_none")]
1147 pub timeout_ms: Option<u64>,
1148}
1149
1150impl BucketWaitParams {
1151 /// Resolve the effective `Duration`, clamping to the hard cap.
1152 #[must_use]
1153 pub fn timeout(&self) -> Duration {
1154 let raw = self.timeout_ms.unwrap_or(DEFAULT_BUCKET_WAIT_MS);
1155 Duration::from_millis(raw.min(MAX_BUCKET_WAIT_MS))
1156 }
1157}
1158
1159/// Response shape for `bucket_wait`. Identical to
1160/// `BucketEventsSinceResponse` plus a `heartbeat` flag.
1161#[derive(Debug, Clone, Serialize, Deserialize)]
1162pub struct BucketWaitResponse {
1163 pub bucket_id: BucketId,
1164 pub cursor_in: u64,
1165 pub next_cursor: u64,
1166 /// `true` when the wait timed out and no matching events
1167 /// arrived. The `events` array MUST be empty in that case.
1168 pub heartbeat: bool,
1169 pub dropped_count: u64,
1170 pub events: Vec<SignalEvent>,
1171}
1172
1173/// Parameters for `bucket_summary`.
1174#[derive(Debug, Clone, Serialize, Deserialize)]
1175pub struct BucketSummaryParams {
1176 pub bucket_id: BucketId,
1177}
1178
1179/// Response shape for `bucket_summary`. Counters only; never raw
1180/// stream content.
1181#[derive(Debug, Clone, Serialize, Deserialize)]
1182pub struct BucketSummaryResponse {
1183 pub bucket_id: BucketId,
1184 pub head_seq: u64,
1185 pub tail_seq: u64,
1186 pub event_count: u64,
1187 pub dropped_count: u64,
1188 /// Per-severity histogram (trace / debug / info / low / medium /
1189 /// high / critical), in wire-stable order.
1190 pub by_severity: SeverityHistogram,
1191}
1192
1193/// Wire-stable severity histogram. Independent of the in-memory
1194/// `BucketSummary` so the protocol locks the field order.
1195#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1196pub struct SeverityHistogram {
1197 pub trace: u64,
1198 pub debug: u64,
1199 pub info: u64,
1200 pub low: u64,
1201 pub medium: u64,
1202 pub high: u64,
1203 pub critical: u64,
1204}
1205
1206/// Parameters for `event_context`. Resolves the event's source
1207/// pointer and returns bounded context around that frame.
1208#[derive(Debug, Clone, Serialize, Deserialize)]
1209pub struct EventContextParams {
1210 /// NOW OPTIONAL (US5 / FR-040). Supplied: exactly today's
1211 /// single-bucket resolution (event absent from that bucket =
1212 /// `EventNotFound`, so a contradicting `bucket_id` is an error, never
1213 /// silently ignored). Absent: the daemon resolves the owning bucket
1214 /// by scanning in-scope buckets for the globally-unique `event_id`.
1215 #[serde(default, skip_serializing_if = "Option::is_none")]
1216 pub bucket_id: Option<BucketId>,
1217 pub event_id: EventId,
1218 /// Frames to include BEFORE the anchor. Clamped to
1219 /// `MAX_CONTEXT_FRAMES`. Omitted = `DEFAULT_CONTEXT_BEFORE`.
1220 #[serde(default, skip_serializing_if = "Option::is_none")]
1221 pub before: Option<u32>,
1222 /// Frames to include AFTER the anchor. Clamped to
1223 /// `MAX_CONTEXT_FRAMES`. Omitted = `DEFAULT_CONTEXT_AFTER`.
1224 #[serde(default, skip_serializing_if = "Option::is_none")]
1225 pub after: Option<u32>,
1226 /// Hard byte cap on the response. Clamped to
1227 /// `MAX_CONTEXT_BYTES`. Omitted = `MAX_CONTEXT_BYTES`.
1228 #[serde(default, skip_serializing_if = "Option::is_none")]
1229 pub max_bytes: Option<usize>,
1230}
1231
1232/// One context frame on the wire. Same shape as `core::ContextLine`
1233/// but kept inside the IPC module so the protocol owns its serde
1234/// surface.
1235#[derive(Debug, Clone, Serialize, Deserialize)]
1236pub struct IpcContextFrame {
1237 pub probe_id: terminal_commander_core::ProbeId,
1238 pub frame_id: terminal_commander_core::FrameId,
1239 pub stream: terminal_commander_core::SourceStream,
1240 pub line: Option<u64>,
1241 pub text: String,
1242}
1243
1244/// Reasons a context window may be empty or partial.
1245#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1246#[serde(rename_all = "snake_case")]
1247pub enum ContextUnavailableReason {
1248 /// Severity below Medium: event carries no pointer by design.
1249 NoPointer,
1250 /// Event carried a `pointer_unavailable_reason` instead of a
1251 /// pointer (synthetic lifecycle events).
1252 SyntheticEvent,
1253 /// Anchor frame already evicted from the ring.
1254 AnchorEvicted,
1255 /// Probe id not found in the context-ring manager.
1256 UnknownProbe,
1257}
1258
1259/// Response shape for `event_context`.
1260#[derive(Debug, Clone, Serialize, Deserialize)]
1261pub struct EventContextResponse {
1262 pub bucket_id: BucketId,
1263 pub event_id: EventId,
1264 /// `true` when the anchor frame was no longer in the ring at
1265 /// resolution time.
1266 pub anchor_missing: bool,
1267 /// Set when the daemon could not produce a window for a
1268 /// non-error reason (severity below threshold, synthetic event,
1269 /// etc.). When set, `frames` is empty.
1270 #[serde(default, skip_serializing_if = "Option::is_none")]
1271 pub unavailable_reason: Option<ContextUnavailableReason>,
1272 /// Echo of the event's `pointer_unavailable_reason` if the event
1273 /// carried one. Never raw stream content.
1274 #[serde(default, skip_serializing_if = "Option::is_none")]
1275 pub pointer_unavailable_reason: Option<String>,
1276 /// Bounded window. Empty when `anchor_missing` or
1277 /// `unavailable_reason` is set.
1278 pub frames: Vec<IpcContextFrame>,
1279 /// Reported by the underlying ring; helps clients reason about
1280 /// truncation.
1281 pub total_bytes: usize,
1282 pub truncated: bool,
1283}
1284
1285/// Maximum number of explicit env entries on `command_start_combed`.
1286/// Symmetric with `MAX_ARGV_ITEMS`; protects the wire path from
1287/// accidental fan-out via env.
1288pub const MAX_COMMAND_ENV_ITEMS: usize = 256;
1289/// Maximum number of inline rules accepted on `command_start_combed`.
1290/// Hot rule binding is TC42 territory; TC41 only accepts the empty
1291/// default unless the operator passes a small per-call list.
1292pub const MAX_COMMAND_INLINE_RULES: usize = 64;
1293/// Maximum grace window before forced terminate. Clamped at the
1294/// dispatcher.
1295pub const MAX_COMMAND_GRACE_MS: u64 = 60_000;
1296
1297/// Serde default for `bool` fields that default to `true` (e.g.
1298/// `CommandStartParams::strip_ansi`). A bare `#[serde(default)]` would
1299/// yield `false`, inverting the intended TC-B1 default; this helper keeps
1300/// an omitted field meaning "strip on".
1301const fn default_true() -> bool {
1302 true
1303}
1304
1305/// Wire shape for `command_start_combed`. Mirrors the daemon's
1306/// `CommandStartRequest` but uses millis instead of `Duration` so the
1307/// JSON form stays human-readable.
1308#[derive(Debug, Clone, Serialize, Deserialize)]
1309pub struct CommandStartParams {
1310 /// Target environment (default local parent).
1311 #[serde(default, skip_serializing_if = "Option::is_none")]
1312 pub environment: Option<terminal_commander_core::EnvironmentSpec>,
1313 /// argv. `argv[0]` is the program; rest are passed verbatim.
1314 /// Shell-string passthrough is forbidden; `argv[0]` matching the
1315 /// shell-bridge deny list is rejected before the policy gate.
1316 pub argv: Vec<String>,
1317 /// Working directory. Optional; resolves against the daemon's
1318 /// own cwd when None.
1319 #[serde(default, skip_serializing_if = "Option::is_none")]
1320 pub cwd: Option<PathBuf>,
1321 /// Explicit environment for the child. Empty means inherit.
1322 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1323 pub env: Vec<(String, String)>,
1324 /// Bucket configuration override (max_events / TTL). Defaults
1325 /// applied if None.
1326 #[serde(default, skip_serializing_if = "Option::is_none")]
1327 pub bucket_config: Option<BucketConfig>,
1328 /// Optional inline rule set. Empty means use the daemon's empty
1329 /// sifter (no events emitted). Hot rule binding lives in TC42.
1330 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1331 pub rules: Vec<RuleDefinition>,
1332 /// Grace window between graceful and forced terminate, in
1333 /// milliseconds. Clamped to `MAX_COMMAND_GRACE_MS`.
1334 #[serde(default, skip_serializing_if = "Option::is_none")]
1335 pub grace_ms: Option<u64>,
1336 /// Optional per-bucket tag for subscription routing (Phase 3).
1337 #[serde(default, skip_serializing_if = "Option::is_none")]
1338 pub tag: Option<String>,
1339 /// Strip ANSI/CSI/OSC escapes before sifter matching and in emitted
1340 /// summaries (TC-B1, FR-026). RAW bytes are always preserved in the
1341 /// frame store; this affects ONLY what the sifter sees and echoes.
1342 /// Defaults to `true`. Additive and non-breaking: old clients omit it
1343 /// and decode via the `default_true` serde default.
1344 #[serde(default = "default_true")]
1345 pub strip_ansi: bool,
1346 /// Optional in-flight dedup hint (TC-2). A client that MAY re-send
1347 /// the same logical start (e.g. a transport retry of a mutating
1348 /// `command_start_combed`) SHOULD send the SAME nonce on every
1349 /// re-send; the daemon collapses an in-flight duplicate to the SAME
1350 /// `(job_id, bucket_id)` instead of spawning twice. Two DISTINCT
1351 /// logical starts MUST use distinct nonces (or none). Additive and
1352 /// non-breaking: old clients omit it and decode via serde default;
1353 /// the daemon then falls back to a very short peer-scoped
1354 /// signature window. This is NOT a server-honored idempotency-key
1355 /// protocol (no TTL store, no envelope change) -- just an in-flight
1356 /// collapse hint.
1357 #[serde(default, skip_serializing_if = "Option::is_none")]
1358 pub dedup_nonce: Option<String>,
1359}
1360
1361impl CommandStartParams {
1362 /// Resolve the effective grace `Duration`, clamping to the cap.
1363 #[must_use]
1364 pub fn grace(&self) -> Option<Duration> {
1365 self.grace_ms
1366 .map(|ms| Duration::from_millis(ms.min(MAX_COMMAND_GRACE_MS)))
1367 }
1368}
1369
1370/// Wire shape for `shell_exec` (TC49).
1371///
1372/// The shell lane runs ONE shell
1373/// line (pipelines / compounds / redirects) through the comb pipeline
1374/// behind the `allow_shell` capability. Mirrors the daemon's
1375/// `ShellExecRequest`; carries the dedicated `shell_line` ONLY — there
1376/// is NO capability flag on the wire (caps are config/TOML, never
1377/// MCP-flippable). Denied by default.
1378///
1379/// `wait_ms` is deliberately ABSENT here: like `command_start_combed`,
1380/// the bounded-wait control is an MCP-layer concern (`McpShellExecParams`
1381/// strips it before building the IPC start), never forwarded into the
1382/// IPC start params.
1383#[derive(Debug, Clone, Serialize, Deserialize)]
1384pub struct ShellExecParams {
1385 /// The shell line to run. Becomes `argv[2]` of `[shell, "-lc",
1386 /// shell_line]`; bounded by the daemon's `MAX_SHELL_LINE_BYTES`.
1387 pub shell_line: String,
1388 /// Interpreter override. `None` -> the daemon's `default_shell`.
1389 #[serde(default, skip_serializing_if = "Option::is_none")]
1390 pub shell: Option<String>,
1391 /// Working directory for the spawned child. `None` inherits the
1392 /// daemon's cwd (the policy gate may still reject paths outside the
1393 /// project root on containment profiles).
1394 #[serde(default, skip_serializing_if = "Option::is_none")]
1395 pub cwd: Option<PathBuf>,
1396 /// Explicit environment for the child. Empty means inherit.
1397 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1398 pub env: Vec<(String, String)>,
1399 /// Optional inline rule set to comb this job's output. Empty means
1400 /// the daemon's empty sifter (no events emitted).
1401 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1402 pub rules: Vec<RuleDefinition>,
1403 /// Bucket configuration override (max_events / TTL). Defaults
1404 /// applied if None.
1405 #[serde(default, skip_serializing_if = "Option::is_none")]
1406 pub bucket_config: Option<BucketConfig>,
1407 /// Optional per-bucket tag for subscription routing.
1408 #[serde(default, skip_serializing_if = "Option::is_none")]
1409 pub tag: Option<String>,
1410}
1411
1412/// Wire shape for `command_status`. Carries just the job id.
1413#[derive(Debug, Clone, Serialize, Deserialize)]
1414pub struct CommandStatusParams {
1415 pub job_id: JobId,
1416}
1417
1418/// Wire shape for `command_output_tail` (F1). Rule-free bounded read
1419/// of a job's captured output. Caps enforced server-side.
1420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1421pub struct CommandOutputTailParams {
1422 pub job_id: JobId,
1423 #[serde(default = "default_tail_lines")]
1424 pub max_lines: u32,
1425 #[serde(default = "default_tail_bytes")]
1426 pub max_bytes: u32,
1427 /// Strip ANSI escape sequences from the returned rendering. The frame
1428 /// store remains raw. Omitted/false preserves the historical wire shape.
1429 #[serde(default)]
1430 pub strip_ansi: bool,
1431}
1432
1433const fn default_tail_lines() -> u32 {
1434 50
1435}
1436const fn default_tail_bytes() -> u32 {
1437 65_536
1438}
1439
1440/// Response for `command_output_tail`.
1441///
1442/// Bounded; never returns the full raw stream. `truncated_lines` is
1443/// true when the ring held more frames than `max_lines` (after
1444/// server-side clamping). `truncated_bytes` is true when the byte cap
1445/// was hit before the line cap.
1446#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1447pub struct CommandOutputTailResponse {
1448 pub job_id: JobId,
1449 pub lines: Vec<String>,
1450 pub returned_lines: u32,
1451 pub truncated_lines: bool,
1452 pub truncated_bytes: bool,
1453 pub evicted_frames: u64,
1454}
1455
1456/// Maximum number of lines returned by `command_output_tail`.
1457pub const MAX_TAIL_LINES: usize = 200;
1458/// Maximum bytes returned by `command_output_tail`.
1459pub const MAX_TAIL_BYTES: usize = 65_536;
1460
1461/// Maximum hits returned by `registry_search` in a single call.
1462pub const MAX_REGISTRY_SEARCH_LIMIT: usize = 200;
1463/// Default hits returned when the caller omits a limit.
1464pub const DEFAULT_REGISTRY_SEARCH_LIMIT: usize = 50;
1465/// Maximum number of samples accepted by `registry_test`.
1466pub const MAX_REGISTRY_TEST_SAMPLES: usize = 32;
1467/// Maximum size of a single sample text. Mirrors the sifter's
1468/// per-frame cap; bytes above this are truncated before evaluation.
1469pub const MAX_REGISTRY_TEST_SAMPLE_BYTES: usize = 8192;
1470
1471/// Maximum sample lines accepted by `registry_suggest_from_samples`.
1472///
1473/// US2 / FR-007. Lines beyond this are ignored before the heuristics
1474/// run so a huge sample set cannot blow the bounded-output budget.
1475pub const MAX_SUGGEST_SAMPLES: usize = 200;
1476/// Maximum bytes inspected per suggestion sample line.
1477pub const MAX_SUGGEST_SAMPLE_BYTES: usize = 4096;
1478/// Hard cap on the number of proposed rules returned by
1479/// `registry_suggest_from_samples`, regardless of the caller's
1480/// `max_rules`.
1481pub const MAX_SUGGEST_PROPOSED_RULES: usize = 8;
1482
1483/// `registry_search` parameters.
1484#[derive(Debug, Clone, Serialize, Deserialize)]
1485pub struct RegistrySearchParams {
1486 /// FTS5 query string. Operator-supplied; the daemon performs no
1487 /// rewriting beyond what SQLite's FTS5 layer enforces.
1488 pub query: String,
1489 /// Result cap. Clamped to `MAX_REGISTRY_SEARCH_LIMIT`.
1490 #[serde(default, skip_serializing_if = "Option::is_none")]
1491 pub limit: Option<usize>,
1492}
1493
1494/// One hit returned by `registry_search`.
1495#[derive(Debug, Clone, Serialize, Deserialize)]
1496pub struct RegistrySearchHit {
1497 pub rule_id: String,
1498 pub version: u32,
1499 pub event_kind: String,
1500 pub summary_template: String,
1501 pub tags: Vec<String>,
1502 pub severity: Severity,
1503 pub status: RuleStatus,
1504}
1505
1506#[derive(Debug, Clone, Serialize, Deserialize)]
1507pub struct RegistrySearchResponse {
1508 pub hits: Vec<RegistrySearchHit>,
1509}
1510
1511/// `registry_get` parameters. If `version` is `None`, the daemon
1512/// returns the latest stored version.
1513#[derive(Debug, Clone, Serialize, Deserialize)]
1514pub struct RegistryGetParams {
1515 pub rule_id: String,
1516 #[serde(default, skip_serializing_if = "Option::is_none")]
1517 pub version: Option<u32>,
1518}
1519
1520#[derive(Debug, Clone, Serialize, Deserialize)]
1521pub struct RegistryGetResponse {
1522 pub definition: RuleDefinition,
1523}
1524
1525/// `registry_upsert` parameters. The daemon validates the definition,
1526/// assigns the next version, and persists an immutable row.
1527#[derive(Debug, Clone, Serialize, Deserialize)]
1528pub struct RegistryUpsertParams {
1529 pub definition: RuleDefinition,
1530}
1531
1532#[derive(Debug, Clone, Serialize, Deserialize)]
1533pub struct RegistryUpsertResponse {
1534 pub rule_id: String,
1535 pub version: u32,
1536}
1537
1538/// A single bounded sample for `registry_test`.
1539#[derive(Debug, Clone, Serialize, Deserialize)]
1540pub struct RegistryTestSample {
1541 /// Sample text. Bytes above `MAX_REGISTRY_TEST_SAMPLE_BYTES` are
1542 /// truncated by the daemon before evaluation; the dropped byte
1543 /// count surfaces in the response.
1544 pub text: String,
1545 /// Stream tag used to drive `rule.stream` filtering. Defaults to
1546 /// `stdout` so an operator does not have to set it for simple
1547 /// keyword tests.
1548 #[serde(default, skip_serializing_if = "Option::is_none")]
1549 pub stream: Option<SourceStream>,
1550}
1551
1552#[derive(Debug, Clone, Serialize, Deserialize)]
1553pub struct RegistryTestParams {
1554 pub rule_id: String,
1555 #[serde(default, skip_serializing_if = "Option::is_none")]
1556 pub version: Option<u32>,
1557 pub samples: Vec<RegistryTestSample>,
1558}
1559
1560/// One match produced by `registry_test`. Bounded by design:
1561/// captures are projected to a flat `BTreeMap<String, String>` so
1562/// the response never carries arbitrary deeply-nested JSON.
1563#[derive(Debug, Clone, Serialize, Deserialize)]
1564pub struct RegistryTestMatch {
1565 pub sample_index: usize,
1566 pub severity: Severity,
1567 pub kind: String,
1568 pub summary: String,
1569 pub captures: std::collections::BTreeMap<String, String>,
1570}
1571
1572#[derive(Debug, Clone, Serialize, Deserialize)]
1573pub struct RegistryTestResponse {
1574 pub matches: Vec<RegistryTestMatch>,
1575 /// Bytes dropped by per-sample truncation. Helps the operator
1576 /// reason about why a tail-anchored regex did not fire.
1577 pub truncated_bytes: u32,
1578 /// F8b (trust): `sample_index` values whose text the rule's regex
1579 /// WOULD match, but whose stream the rule's `stream` filter
1580 /// excludes -- so the rule produced no match for a reason invisible
1581 /// in the sample text alone. Empty when no sample is a stream
1582 /// mismatch. Additive: omitted from the wire when empty so older
1583 /// clients keep the historical shape.
1584 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1585 pub stream_mismatches: Vec<usize>,
1586}
1587
1588/// `registry_suggest_from_samples` parameters (US2 / FR-007).
1589///
1590/// PURE heuristic suggestion: the daemon runs deterministic line-shape
1591/// detectors over `samples` and returns DRAFT rule proposals. It NEVER
1592/// activates or persists anything (FR-008 / constitution VII).
1593#[derive(Debug, Clone, Serialize, Deserialize)]
1594pub struct RegistrySuggestFromSamplesParams {
1595 /// Raw output sample lines to analyze. Capped at
1596 /// [`MAX_SUGGEST_SAMPLES`]; per-line bytes capped at
1597 /// [`MAX_SUGGEST_SAMPLE_BYTES`].
1598 pub samples: Vec<String>,
1599 /// Optional free-text hint describing the tool/intent. Advisory
1600 /// only; the heuristics are deterministic and ignore it for
1601 /// matching (it is echoed back for the caller's context).
1602 #[serde(default, skip_serializing_if = "Option::is_none")]
1603 pub intent: Option<String>,
1604 /// Optional cap on proposals. Clamped to
1605 /// [`MAX_SUGGEST_PROPOSED_RULES`].
1606 #[serde(default, skip_serializing_if = "Option::is_none")]
1607 pub max_rules: Option<u32>,
1608}
1609
1610/// `registry_suggest_from_samples` response (US2 / FR-007).
1611///
1612/// `proposed_rules` are DRAFT [`RuleDefinition`]s. They are NOT active
1613/// and NOT persisted: the caller must run the explicit
1614/// `registry_test` -> `registry_upsert` -> `registry_activate` loop
1615/// named in `next_steps`.
1616#[derive(Debug, Clone, Serialize, Deserialize)]
1617pub struct RegistrySuggestFromSamplesResponse {
1618 /// Candidate DRAFT rules. May be empty for low-signal input.
1619 pub proposed_rules: Vec<RuleDefinition>,
1620 /// Always `"heuristic"`. The suggestions are deterministic
1621 /// line-shape heuristics, never an ML score.
1622 pub confidence: String,
1623 /// The explicit, ordered activation loop the caller MUST follow to
1624 /// make any proposal live. Constant by design.
1625 pub next_steps: Vec<String>,
1626 /// Human-readable explanation of what was (or was not) detected.
1627 /// For empty/low-signal input this explains why no rule was
1628 /// proposed instead of fabricating one.
1629 pub explanation: String,
1630}
1631
1632/// `registry_activate` parameters.
1633///
1634/// The optional `scope` field (TC42c) selects which live stream(s) the
1635/// activation reaches. Omitted scope deserializes to
1636/// [`ActivationScope::Global`], preserving TC42/TC42b wire compatibility.
1637#[derive(Debug, Clone, Serialize, Deserialize)]
1638pub struct RegistryActivateParams {
1639 pub rule_id: String,
1640 #[serde(default, skip_serializing_if = "Option::is_none")]
1641 pub version: Option<u32>,
1642 #[serde(default, skip_serializing_if = "Option::is_none")]
1643 pub scope: Option<ActivationScope>,
1644}
1645
1646#[derive(Debug, Clone, Serialize, Deserialize)]
1647pub struct RegistryActivateResponse {
1648 pub rule_id: String,
1649 pub version: u32,
1650 /// `true` when the rule was already active under this scope
1651 /// before this call. The activation row is still persisted in
1652 /// either case so the audit trail records the operator intent.
1653 pub was_already_active: bool,
1654 /// Echo of the scope that was applied. Always populated so
1655 /// pre-TC42c clients can ignore the field and post-TC42c clients
1656 /// can verify their request.
1657 pub scope: ActivationScope,
1658 /// Number of live jobs whose sifter was rebound by this call.
1659 /// Zero is valid (e.g. no commands running, or no live job
1660 /// matched the scope).
1661 pub jobs_rebound: u32,
1662 /// Other versions of the SAME rule id that were active under this
1663 /// scope and were closed by this activation (S5 activate-supersedes:
1664 /// version stacking within one scope fires duplicate events per
1665 /// frame, so activating vN deactivates the rest). Empty when nothing
1666 /// was superseded. `serde(default)` keeps pre-S5 payloads decodable.
1667 #[serde(default)]
1668 pub superseded_versions: Vec<u32>,
1669}
1670
1671/// Import a named, embedded rule pack into the registry.
1672///
1673/// When `activate` is true, `scope` is REQUIRED and every imported
1674/// rule is promoted to Active and activated in that scope -- one call
1675/// for "give me expert signals for X". When false, rules import at
1676/// their on-disk status (the vetting path) and nothing is activated.
1677#[derive(Debug, Clone, Serialize, Deserialize)]
1678pub struct RegistryImportPackParams {
1679 pub pack: String,
1680 #[serde(default)]
1681 pub activate: bool,
1682 #[serde(default, skip_serializing_if = "Option::is_none")]
1683 pub scope: Option<ActivationScope>,
1684}
1685
1686#[derive(Debug, Clone, Serialize, Deserialize)]
1687pub struct RegistryImportPackResponse {
1688 pub pack: String,
1689 pub imported: Vec<String>,
1690 pub skipped: Vec<String>,
1691 /// Rules that imported AND activated successfully. Only the rules
1692 /// whose activation completed appear here; a rule listed in
1693 /// `imported` but missing from both `activated` and `failed` means
1694 /// `activate` was false (nothing was activated).
1695 pub activated: Vec<String>,
1696 /// Partial-success channel (M7): rules that imported and were
1697 /// promoted to Active but whose *activation* failed mid-loop. Each
1698 /// entry carries the rule id + a human-readable reason. This is an
1699 /// ADDITIVE field: it serializes only when non-empty, so the
1700 /// all-success wire shape is unchanged and older clients that do
1701 /// not know the field still deserialize. A non-empty `failed` is
1702 /// still a SUCCESSFUL response (no IPC error code) -- the caller
1703 /// inspects `failed` to learn which rules need a retry rather than
1704 /// receiving a bare error that hides the rules that did activate.
1705 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1706 pub failed: Vec<RegistryImportFailure>,
1707}
1708
1709/// One rule whose activation failed during a partial-success import.
1710///
1711/// The rule WAS imported (and promoted to Active in the store) by
1712/// `registry_import_pack`; only the in-memory/durable activation step
1713/// failed. `reason` is the typed IPC error message surfaced to the
1714/// caller so it can decide whether to retry that single rule.
1715#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1716pub struct RegistryImportFailure {
1717 pub rule_id: String,
1718 pub reason: String,
1719}
1720
1721/// `registry_deactivate` parameters. Scope follows the same default
1722/// rule as `registry_activate`.
1723#[derive(Debug, Clone, Serialize, Deserialize)]
1724pub struct RegistryDeactivateParams {
1725 pub rule_id: String,
1726 pub version: u32,
1727 #[serde(default, skip_serializing_if = "Option::is_none")]
1728 pub scope: Option<ActivationScope>,
1729}
1730
1731#[derive(Debug, Clone, Serialize, Deserialize)]
1732pub struct RegistryDeactivateResponse {
1733 pub rule_id: String,
1734 pub version: u32,
1735 /// `false` when the rule was not in the in-memory active set
1736 /// for this scope (e.g. operator deactivated something already
1737 /// inactive). The daemon still attempts to close the persistent
1738 /// row.
1739 pub was_deactivated: bool,
1740 /// Echo of the scope that was applied.
1741 pub scope: ActivationScope,
1742 /// Number of live jobs whose sifter was rebound by this call.
1743 pub jobs_rebound: u32,
1744}
1745
1746/// `registry_deactivate_bulk` parameters (US2 / FR-011).
1747///
1748/// Deactivate an entire seed pack OR an explicit list of rule ids in ONE
1749/// call, under exactly ONE scope. Exactly one of `pack` / `rule_ids`
1750/// must be present; the daemon rejects zero or both with a teaching
1751/// error. The single-rule [`RegistryDeactivateParams`] wire contract is
1752/// untouched.
1753#[derive(Debug, Clone, Serialize, Deserialize)]
1754pub struct RegistryDeactivateBulkParams {
1755 /// Selector 1: deactivate every member of this seed pack.
1756 #[serde(default, skip_serializing_if = "Option::is_none")]
1757 pub pack: Option<String>,
1758 /// Selector 2: deactivate these rule ids.
1759 #[serde(default, skip_serializing_if = "Option::is_none")]
1760 pub rule_ids: Option<Vec<String>>,
1761 /// Required. ONE scope per call.
1762 pub scope: ActivationScope,
1763}
1764
1765/// The disposition of one rule in a bulk deactivate (US2 / FR-011).
1766///
1767/// Partial success is the NORMAL shape: `not_active` and `unknown_rule`
1768/// are reported per-rule, never as a call-level error.
1769#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1770#[serde(rename_all = "snake_case")]
1771pub enum BulkOutcomeKind {
1772 /// At least one active version under the scope was closed.
1773 Deactivated,
1774 /// Known rule, but nothing was open under this scope.
1775 NotActive,
1776 /// Rule id not in the registry (or not a member of the named pack).
1777 UnknownRule,
1778}
1779
1780/// One per-rule outcome entry in a bulk deactivate response.
1781#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1782pub struct BulkDeactivateOutcome {
1783 pub rule_id: String,
1784 /// The version acted on; `None` for `unknown_rule`.
1785 #[serde(default, skip_serializing_if = "Option::is_none")]
1786 pub version: Option<u32>,
1787 pub outcome: BulkOutcomeKind,
1788}
1789
1790#[derive(Debug, Clone, Serialize, Deserialize)]
1791pub struct RegistryDeactivateBulkResponse {
1792 /// One entry per requested rule, ALWAYS, in request order (pack
1793 /// order = pack-file order).
1794 pub outcomes: Vec<BulkDeactivateOutcome>,
1795 /// Live jobs rebound ONCE after the whole loop.
1796 pub jobs_rebound: u64,
1797}
1798
1799/// One entry in `registry_list_active`. Carries the scope the rule
1800/// is bound to so a rule active under several scopes appears once
1801/// per scope.
1802#[derive(Debug, Clone, Serialize, Deserialize)]
1803pub struct RegistryActiveEntry {
1804 pub rule_id: String,
1805 pub version: u32,
1806 pub severity: Severity,
1807 pub event_kind: String,
1808 pub tags: Vec<String>,
1809 pub scope: ActivationScope,
1810}
1811
1812#[derive(Debug, Clone, Serialize, Deserialize)]
1813pub struct RegistryListActiveResponse {
1814 pub entries: Vec<RegistryActiveEntry>,
1815 /// More active entries existed than `entries` carries (bounded by
1816 /// [`MAX_LIST_LIMIT`] / the request `limit`).
1817 #[serde(default)]
1818 pub truncated: bool,
1819}
1820
1821// =====================================================================
1822// TC43: file probe surface.
1823//
1824// Three families:
1825// - `file_read_window` — bounded line/byte window read of one file.
1826// - `file_search` — bounded substring/regex match over one file.
1827// - `file_watch_*` — daemon-owned follow-mode FileProbe attached
1828// to a bucket so scoped rules emit signal.
1829// =====================================================================
1830
1831/// Hard cap on lines returned by `file_read_window` in a single call.
1832pub const MAX_FILE_READ_LINES: u32 = 2_000;
1833/// Default lines when the caller omits a limit.
1834pub const DEFAULT_FILE_READ_LINES: u32 = 200;
1835/// Hard cap on bytes returned by `file_read_window`. Same envelope cap
1836/// shape as the bucket / event-context payloads.
1837pub const MAX_FILE_READ_BYTES: usize = 64 * 1024;
1838/// Default `file_read_window` byte cap.
1839pub const DEFAULT_FILE_READ_BYTES: usize = MAX_FILE_READ_BYTES;
1840/// Hard cap on matches returned by `file_search` in a single call.
1841pub const MAX_FILE_SEARCH_MATCHES: u32 = 500;
1842/// Default `file_search` match cap.
1843pub const DEFAULT_FILE_SEARCH_MATCHES: u32 = 100;
1844/// Hard cap on snippet bytes returned per `file_search` match.
1845pub const MAX_FILE_SEARCH_SNIPPET_BYTES: usize = 512;
1846/// Default snippet bytes per match.
1847pub const DEFAULT_FILE_SEARCH_SNIPPET_BYTES: usize = 240;
1848/// Hard cap on bytes scanned by a single `file_search` call. Protects
1849/// the daemon from a request that asks to search a gigabyte file.
1850pub const MAX_FILE_SEARCH_SCAN_BYTES: u64 = 16 * 1024 * 1024;
1851/// Hard cap on directory entries visited by one recursive `file_search`.
1852/// This bounds trees containing many empty directories or tiny files even
1853/// when the byte and match caps would not stop the walk.
1854pub const MAX_FILE_SEARCH_ENTRIES: u64 = 20_000;
1855/// Hard cap on entries returned by `file_list_dir` in a single call.
1856///
1857/// (US3 FR-020.) Bounds a single-level directory listing the same way the
1858/// read-window / search lanes bound their output: a directory with more than
1859/// this many entries is returned truncation-flagged with the true total, never
1860/// silently partial (Constitution III).
1861pub const MAX_FILE_LIST_ENTRIES: usize = 500;
1862/// Default `file_list_dir` entry cap when the caller omits `max_entries`.
1863pub const DEFAULT_FILE_LIST_ENTRIES: usize = 200;
1864/// Hard cap on the content size accepted by a single `file_write` call.
1865///
1866/// (TC22 A3.) Bounds the request the same way the read window / search
1867/// scan budgets bound their lanes: a write larger than this is rejected
1868/// with [`IpcErrorCode::OversizedRequest`] before any filesystem touch,
1869/// so a single tool call can never be coerced into writing an unbounded
1870/// blob.
1871///
1872/// MUST stay comfortably below [`MAX_FRAME_BYTES`] (256 KiB): the content
1873/// is carried inline in the request frame as escaped JSON, so the cap is
1874/// 192 KiB to leave ~64 KiB headroom for the path, field names, and worst-
1875/// case JSON string escaping. This guarantees the dedicated
1876/// `OversizedRequest` verdict fires at the handler, rather than a generic
1877/// transport `FrameTooLarge`, giving the caller an actionable, lane-specific
1878/// error. (Larger files are written as multiple bounded calls.)
1879pub const MAX_FILE_WRITE_BYTES: usize = 192 * 1024;
1880
1881/// `file_read_window` parameters.
1882///
1883/// Either `start_line` (1-based) drives a line-window read or
1884/// `start_byte` drives a byte-window read. If both are omitted the
1885/// daemon reads from line 1.
1886#[derive(Debug, Clone, Serialize, Deserialize)]
1887pub struct FileReadWindowParams {
1888 pub path: std::path::PathBuf,
1889 #[serde(default, skip_serializing_if = "Option::is_none")]
1890 pub start_line: Option<u64>,
1891 #[serde(default, skip_serializing_if = "Option::is_none")]
1892 pub max_lines: Option<u32>,
1893 #[serde(default, skip_serializing_if = "Option::is_none")]
1894 pub max_bytes: Option<usize>,
1895}
1896
1897/// One line returned by `file_read_window`.
1898#[derive(Debug, Clone, Serialize, Deserialize)]
1899pub struct FileLine {
1900 /// 1-based line number within the file.
1901 pub line: u64,
1902 /// Byte offset where this line begins. Useful for follow-up
1903 /// reads / context windows.
1904 pub byte_offset: u64,
1905 pub text: String,
1906}
1907
1908#[derive(Debug, Clone, Serialize, Deserialize)]
1909pub struct FileReadWindowResponse {
1910 pub path: std::path::PathBuf,
1911 pub lines: Vec<FileLine>,
1912 /// File size in bytes at read time.
1913 pub file_bytes: u64,
1914 /// `true` when the response was clamped by line / byte cap.
1915 pub truncated: bool,
1916 /// First byte offset past the last line returned. Lets the
1917 /// caller compute a follow-up window without rereading.
1918 pub next_byte_offset: u64,
1919}
1920
1921/// `file_search` parameters.
1922#[derive(Debug, Clone, Serialize, Deserialize)]
1923pub struct FileSearchParams {
1924 pub path: std::path::PathBuf,
1925 /// Substring to find. Required.
1926 pub query: String,
1927 /// Case-insensitive match. Defaults to false.
1928 #[serde(default, skip_serializing_if = "Option::is_none")]
1929 pub case_insensitive: Option<bool>,
1930 /// Hard cap on returned matches. Clamped to
1931 /// [`MAX_FILE_SEARCH_MATCHES`]. Omitted = [`DEFAULT_FILE_SEARCH_MATCHES`].
1932 #[serde(default, skip_serializing_if = "Option::is_none")]
1933 pub max_matches: Option<u32>,
1934 /// Hard cap on snippet bytes per match. Clamped to
1935 /// [`MAX_FILE_SEARCH_SNIPPET_BYTES`]. Omitted =
1936 /// [`DEFAULT_FILE_SEARCH_SNIPPET_BYTES`].
1937 #[serde(default, skip_serializing_if = "Option::is_none")]
1938 pub max_snippet_bytes: Option<usize>,
1939}
1940
1941/// One `file_search` match. Bounded shape: never the whole line, never
1942/// arbitrary bytes — `snippet` is capped at `max_snippet_bytes`.
1943#[derive(Debug, Clone, Serialize, Deserialize)]
1944pub struct FileSearchMatch {
1945 /// Canonical owning file for a directory search. Omitted for the legacy
1946 /// single-file shape, whose response-level `path` already identifies it.
1947 #[serde(default, skip_serializing_if = "Option::is_none")]
1948 pub path: Option<std::path::PathBuf>,
1949 /// 1-based line number.
1950 pub line: u64,
1951 /// Byte offset of the matching position within the file.
1952 pub byte_offset: u64,
1953 /// Bounded text snippet around the match. Replaced with the
1954 /// owning line, truncated to `max_snippet_bytes`. Never raw
1955 /// stream bytes; always UTF-8.
1956 pub snippet: String,
1957}
1958
1959#[derive(Debug, Clone, Serialize, Deserialize)]
1960pub struct FileSearchResponse {
1961 pub path: std::path::PathBuf,
1962 pub matches: Vec<FileSearchMatch>,
1963 /// `true` when the search hit the per-call cap or the scan-bytes
1964 /// budget before completing.
1965 pub truncated: bool,
1966 /// Bytes actually scanned (may be lower than file size when the
1967 /// scan-bytes budget tripped first).
1968 pub bytes_scanned: u64,
1969 /// Directory-search diagnostic: regular files successfully scanned.
1970 /// Omitted for the legacy single-file response shape.
1971 #[serde(default, skip_serializing_if = "Option::is_none")]
1972 pub files_scanned: Option<u64>,
1973 /// Directory-search diagnostic: entries skipped because they were binary,
1974 /// unreadable, policy-denied, symlinks/reparse points, special files, or
1975 /// version-control metadata directories.
1976 /// Omitted for the legacy single-file response shape.
1977 #[serde(default, skip_serializing_if = "Option::is_none")]
1978 pub entries_skipped: Option<u64>,
1979}
1980
1981/// Kind of a single directory entry, from `symlink_metadata` (never
1982/// followed): a symlink is reported as `symlink` regardless of its target.
1983#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1984#[serde(rename_all = "snake_case")]
1985pub enum DirEntryKind {
1986 File,
1987 Dir,
1988 Symlink,
1989}
1990
1991/// One entry returned by `file_list_dir` (US3). The discovery unit of the
1992/// files facade: a single-level entry, never recursed into and, for a
1993/// symlink/reparse point, never followed.
1994#[derive(Debug, Clone, Serialize, Deserialize)]
1995pub struct DirEntry {
1996 /// Entry file name only (no path component).
1997 pub name: String,
1998 /// `file` / `dir` / `symlink`, taken from `symlink_metadata`.
1999 pub kind: DirEntryKind,
2000 /// Size in bytes for regular files only; omitted for dirs/symlinks and
2001 /// when the entry vanished between enumeration and stat.
2002 #[serde(default, skip_serializing_if = "Option::is_none")]
2003 pub size_bytes: Option<u64>,
2004 /// Modification time in milliseconds since the Unix epoch; omitted when
2005 /// unavailable (stat race or platform without an mtime).
2006 #[serde(default, skip_serializing_if = "Option::is_none")]
2007 pub mtime_ms: Option<i64>,
2008}
2009
2010/// `file_list_dir` parameters (US3 FR-020).
2011///
2012/// Single-level listing of one directory. Absolute path required (the daemon
2013/// has no workspace root); gated by the SAME read-path policy as
2014/// `file_read_window` (FR-021).
2015#[derive(Debug, Clone, Serialize, Deserialize)]
2016pub struct FileListDirParams {
2017 /// Absolute path of the directory to list.
2018 pub path: String,
2019 /// Cap on returned entries; clamped to
2020 /// `[1, MAX_FILE_LIST_ENTRIES]`, default `DEFAULT_FILE_LIST_ENTRIES`.
2021 #[serde(default, skip_serializing_if = "Option::is_none")]
2022 pub max_entries: Option<u32>,
2023}
2024
2025#[derive(Debug, Clone, Serialize, Deserialize)]
2026pub struct FileListDirResponse {
2027 /// Canonicalized directory that was listed.
2028 pub path: String,
2029 /// Sorted: dirs first, then files/symlinks together; each group
2030 /// lexicographic by `name`.
2031 pub entries: Vec<DirEntry>,
2032 /// Total entries present in the directory (`>= entries.len()`).
2033 pub total_entries: u64,
2034 /// `true` iff `total_entries > entries.len()` (the cap clamped the list).
2035 pub truncated: bool,
2036}
2037
2038/// `file_write` parameters (TC22 A3).
2039///
2040/// Writes `content` to `path` as a single UTF-8 regular file. The daemon
2041/// canonicalizes the PARENT directory (the target file need not exist
2042/// yet), policy-gates the canonical target against `paths.write_allow`,
2043/// audits BEFORE the write, bounds `content` to [`MAX_FILE_WRITE_BYTES`],
2044/// and writes ATOMICALLY (temp file in the same dir + rename) so a partial
2045/// or torn write can never be observed.
2046#[derive(Debug, Clone, Serialize, Deserialize)]
2047pub struct FileWriteParams {
2048 /// Absolute path to the target file. Absolute is required: the daemon
2049 /// has no workspace root, so a relative path is rejected rather than
2050 /// resolved against the daemon's working directory.
2051 pub path: std::path::PathBuf,
2052 /// UTF-8 content to write. Bounded by [`MAX_FILE_WRITE_BYTES`]; an
2053 /// oversize payload is rejected before any filesystem touch.
2054 pub content: String,
2055 /// Create missing parent directories WITHIN an allowed path. The
2056 /// parent must still pass policy: `create_dirs` never widens the
2057 /// allow-list, it only saves a separate mkdir for a path the policy
2058 /// already permits. Defaults to false.
2059 #[serde(default)]
2060 pub create_dirs: bool,
2061 /// Append `content` to the target instead of replacing it. Same policy
2062 /// gate (`FileWrite`), same [`MAX_FILE_WRITE_BYTES`] cap per call, same
2063 /// missing-file creation semantics. Defaults to false (full replace).
2064 ///
2065 /// Integrity (honest): the original content is never modified and racing
2066 /// appends never interleave (OS append-mode offset atomicity). All-or-
2067 /// nothing is NOT promised: a mid-write I/O failure can leave a partial
2068 /// append and surfaces as an error.
2069 #[serde(default)]
2070 pub append: bool,
2071}
2072
2073#[derive(Debug, Clone, Serialize, Deserialize)]
2074pub struct FileWriteResponse {
2075 /// Canonical path that was written.
2076 pub path: std::path::PathBuf,
2077 /// Number of content bytes written.
2078 pub bytes_written: u64,
2079}
2080
2081/// `file_watch_start` parameters.
2082#[derive(Debug, Clone, Serialize, Deserialize)]
2083pub struct FileWatchStartParams {
2084 pub path: std::path::PathBuf,
2085 /// Optional bucket config (max_events / TTL). Defaults applied
2086 /// if None.
2087 #[serde(default, skip_serializing_if = "Option::is_none")]
2088 pub bucket_config: Option<BucketConfig>,
2089 /// Optional inline rule set bound to this watch only. Empty
2090 /// means the per-job set is whatever scoped activations the
2091 /// registry resolves for the watch's `(bucket_id, watch_id,
2092 /// probe_id)` triple.
2093 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2094 pub rules: Vec<RuleDefinition>,
2095 /// Follow from end (skip existing content) or from beginning.
2096 /// Defaults to follow-end (typical "tail -F" semantics).
2097 #[serde(default, skip_serializing_if = "Option::is_none")]
2098 pub follow_from_beginning: Option<bool>,
2099 /// Optional per-bucket tag for subscription routing (Phase 3).
2100 #[serde(default, skip_serializing_if = "Option::is_none")]
2101 pub tag: Option<String>,
2102}
2103
2104#[derive(Debug, Clone, Serialize, Deserialize)]
2105pub struct FileWatchStartResponse {
2106 /// Opaque watch identifier. The LLM uses this with
2107 /// `file_watch_stop`. Wire form is a JobId so scoped activation
2108 /// can target a single watch via `ActivationScope::Job { job_id }`.
2109 pub watch_id: JobId,
2110 pub bucket_id: BucketId,
2111 pub probe_id: terminal_commander_core::ProbeId,
2112 pub cursor: u64,
2113}
2114
2115#[derive(Debug, Clone, Serialize, Deserialize)]
2116pub struct FileWatchStopParams {
2117 pub watch_id: JobId,
2118}
2119
2120#[derive(Debug, Clone, Serialize, Deserialize)]
2121pub struct FileWatchStopResponse {
2122 pub watch_id: JobId,
2123 pub bucket_id: BucketId,
2124 pub frames_total: u64,
2125 pub events_emitted: u64,
2126 pub bytes_total: u64,
2127}
2128
2129#[derive(Debug, Clone, Serialize, Deserialize)]
2130pub struct FileWatchListEntry {
2131 pub watch_id: JobId,
2132 pub bucket_id: BucketId,
2133 pub probe_id: terminal_commander_core::ProbeId,
2134 pub path: std::path::PathBuf,
2135 pub frames_total: u64,
2136 pub events_emitted: u64,
2137 pub bytes_total: u64,
2138}
2139
2140#[derive(Debug, Clone, Serialize, Deserialize)]
2141pub struct FileWatchListResponse {
2142 pub entries: Vec<FileWatchListEntry>,
2143}
2144
2145// =====================================================================
2146// TC44: PTY command surface.
2147// =====================================================================
2148
2149/// Hard cap on argv items for a PTY command. Matches `MAX_ARGV_ITEMS`
2150/// from `command.rs` so the two surfaces stay symmetric.
2151pub const MAX_PTY_ARGV_ITEMS: usize = 256;
2152/// Hard cap on bytes accepted in one `pty_command_write_stdin` call.
2153/// Mirrors `MAX_PTY_STDIN_BYTES` from `crates/probes::pty`.
2154pub const MAX_PTY_STDIN_BYTES: usize = 4096;
2155
2156#[derive(Debug, Clone, Serialize, Deserialize)]
2157pub struct PtyCommandStartParams {
2158 #[serde(default, skip_serializing_if = "Option::is_none")]
2159 pub environment: Option<terminal_commander_core::EnvironmentSpec>,
2160 pub argv: Vec<String>,
2161 #[serde(default, skip_serializing_if = "Option::is_none")]
2162 pub cwd: Option<std::path::PathBuf>,
2163 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2164 pub env: Vec<(String, String)>,
2165 #[serde(default, skip_serializing_if = "Option::is_none")]
2166 pub bucket_config: Option<BucketConfig>,
2167 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2168 pub rules: Vec<RuleDefinition>,
2169 #[serde(default, skip_serializing_if = "Option::is_none")]
2170 pub rows: Option<u16>,
2171 #[serde(default, skip_serializing_if = "Option::is_none")]
2172 pub cols: Option<u16>,
2173 /// Optional per-bucket tag for subscription routing (Phase 3).
2174 #[serde(default, skip_serializing_if = "Option::is_none")]
2175 pub tag: Option<String>,
2176}
2177
2178#[derive(Debug, Clone, Serialize, Deserialize)]
2179pub struct PtyCommandStartResponse {
2180 pub job_id: JobId,
2181 pub bucket_id: BucketId,
2182 pub probe_id: terminal_commander_core::ProbeId,
2183 pub cursor: u64,
2184}
2185
2186#[derive(Debug, Clone, Serialize, Deserialize)]
2187pub struct PtyCommandWriteStdinParams {
2188 pub job_id: JobId,
2189 /// Bytes to write. Capped at `MAX_PTY_STDIN_BYTES`. Sent as a
2190 /// JSON string; non-UTF-8 input must be base64-pre-encoded by the
2191 /// caller (TC44 surface accepts UTF-8 only).
2192 pub bytes: String,
2193 /// NEW (US5 / FR-041): bucket cursor to read the settle window from
2194 /// (default `0` = the PTY job bucket head). Only meaningful with
2195 /// `wait_ms`.
2196 #[serde(default, skip_serializing_if = "Option::is_none")]
2197 pub cursor: Option<u64>,
2198 /// NEW (US5 / FR-041): bounded settle window (ms) to wait for combed
2199 /// signals AFTER the write, clamped server-side like the
2200 /// `shell_session_exec` settle window. Absent = immediate return
2201 /// (today's byte-identical behavior).
2202 #[serde(default, skip_serializing_if = "Option::is_none")]
2203 pub wait_ms: Option<u64>,
2204}
2205
2206#[derive(Debug, Clone, Serialize, Deserialize)]
2207pub struct PtyCommandWriteStdinResponse {
2208 pub job_id: JobId,
2209 pub bytes_written: u64,
2210 /// Echoes the post-write secret-prompt-active flag so the LLM
2211 /// can avoid a follow-up write that would also be rejected.
2212 pub secret_prompt_active: bool,
2213 /// NEW (US5 / FR-041): the following combed-batch fields are present
2214 /// ONLY when `wait_ms` was supplied on the request. A no-wait
2215 /// response omits every one of them, serializing byte-identically to
2216 /// the pre-US5 shape.
2217 #[serde(default, skip_serializing_if = "Option::is_none")]
2218 pub cursor_in: Option<u64>,
2219 #[serde(default, skip_serializing_if = "Option::is_none")]
2220 pub next_cursor: Option<u64>,
2221 #[serde(default, skip_serializing_if = "Option::is_none")]
2222 pub has_more: Option<bool>,
2223 #[serde(default, skip_serializing_if = "Option::is_none")]
2224 pub dropped_count: Option<u64>,
2225 #[serde(default, skip_serializing_if = "Option::is_none")]
2226 pub events: Option<Vec<SignalEvent>>,
2227}
2228
2229#[derive(Debug, Clone, Serialize, Deserialize)]
2230pub struct PtyCommandStopParams {
2231 pub job_id: JobId,
2232}
2233
2234#[derive(Debug, Clone, Serialize, Deserialize)]
2235pub struct PtyCommandStopResponse {
2236 pub job_id: JobId,
2237 pub bucket_id: BucketId,
2238 pub frames_total: u64,
2239 pub events_emitted: u64,
2240 pub bytes_total: u64,
2241 pub stdin_bytes_written: u64,
2242 pub secret_prompts_total: u64,
2243}
2244
2245#[derive(Debug, Clone, Serialize, Deserialize)]
2246pub struct PtyCommandListEntry {
2247 pub job_id: JobId,
2248 pub bucket_id: BucketId,
2249 pub probe_id: terminal_commander_core::ProbeId,
2250 pub argv: Vec<String>,
2251 pub frames_total: u64,
2252 pub events_emitted: u64,
2253 pub bytes_total: u64,
2254 pub stdin_bytes_written: u64,
2255 pub secret_prompts_total: u64,
2256 pub secret_prompt_active: bool,
2257}
2258
2259#[derive(Debug, Clone, Serialize, Deserialize)]
2260pub struct PtyCommandListResponse {
2261 pub entries: Vec<PtyCommandListEntry>,
2262}
2263
2264// =====================================================================
2265// P1 (TC50): persistent shell sessions + workspace snapshots.
2266//
2267// A session is a long-lived login-shell PTY job: sticky cwd/env come
2268// for free from the persistent shell process. The wire mirrors the
2269// PTY surface in style. Session output is ALWAYS combed (read from the
2270// session bucket via cursor); the wire never carries a raw stream.
2271// Session start is gated by `PolicyAction::SessionStart` behind the
2272// `allow_session` capability (default deny) and audited before spawn.
2273// =====================================================================
2274
2275/// Maximum byte length of a single `shell_session_exec` line.
2276///
2277/// A session line is written to the shell PTY as `line + "\n"`. Bounded
2278/// well under the PTY stdin cap (`MAX_PTY_STDIN_BYTES`) so the appended
2279/// newline can never push a max-length line over the probe's write cap.
2280pub const MAX_SESSION_LINE_BYTES: usize = 4000;
2281
2282/// Maximum number of `(key, value)` pairs returned in a session/snapshot
2283/// bounded env snapshot. Keeps the status/list/snapshot responses small.
2284pub const MAX_SESSION_ENV_ITEMS: usize = 256;
2285
2286/// Lifecycle state of a shell session.
2287///
2288/// Mirrors the data-model `Starting | Live | Exited | Failed` set. `Live`
2289/// is the only state in which `shell_session_exec` is accepted; a send to
2290/// any other state fails loudly (terminal-state guard), never hangs.
2291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2292#[serde(rename_all = "snake_case")]
2293pub enum SessionState {
2294 Starting,
2295 Live,
2296 Exited,
2297 Failed,
2298}
2299
2300/// `shell_session_start` request.
2301///
2302/// `shell` is the interpreter override (`None` -> the daemon's default
2303/// login shell); argv[0] is NOT a user-chosen interpreter on this lane (it
2304/// is assembled by the daemon), so the argv shell-interpreter guard is
2305/// intentionally skipped here and the `SessionStart` cap gates instead.
2306#[derive(Debug, Clone, Serialize, Deserialize)]
2307pub struct ShellSessionStartParams {
2308 /// Interpreter override. `None` -> the daemon's default login shell.
2309 #[serde(default, skip_serializing_if = "Option::is_none")]
2310 pub shell: Option<String>,
2311 /// Initial working directory for the session shell. `None` inherits
2312 /// the daemon's cwd (the policy gate still applies on containment
2313 /// profiles).
2314 #[serde(default, skip_serializing_if = "Option::is_none")]
2315 pub cwd: Option<PathBuf>,
2316 /// Environment overlay applied to the session shell. Bounded by
2317 /// [`MAX_SESSION_ENV_ITEMS`]. Empty = inherit unchanged.
2318 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2319 pub env: Vec<(String, String)>,
2320 /// Optional inline rules bound to the session bucket so the session's
2321 /// combed output emits structured signals. Empty = the daemon's empty
2322 /// sifter (only lifecycle events appear).
2323 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2324 pub rules: Vec<RuleDefinition>,
2325 /// Bucket configuration override (max_events / TTL). Defaults applied
2326 /// if `None`.
2327 #[serde(default, skip_serializing_if = "Option::is_none")]
2328 pub bucket_config: Option<BucketConfig>,
2329 /// Optional per-bucket tag for subscription routing.
2330 #[serde(default, skip_serializing_if = "Option::is_none")]
2331 pub tag: Option<String>,
2332}
2333
2334/// `shell_session_start` response: the stable session id, its signal
2335/// bucket, and the initial lifecycle state.
2336#[derive(Debug, Clone, Serialize, Deserialize)]
2337pub struct ShellSessionStartResponse {
2338 pub session_id: SessionId,
2339 pub bucket_id: BucketId,
2340 pub state: SessionState,
2341}
2342
2343/// `shell_session_exec` request: send ONE line to a live session shell.
2344///
2345/// The line is bounded by [`MAX_SESSION_LINE_BYTES`]; a trailing newline
2346/// is appended by the daemon (do NOT include it). Output is read back as
2347/// combed signals from the session bucket via the cursor.
2348#[derive(Debug, Clone, Serialize, Deserialize)]
2349pub struct ShellSessionExecParams {
2350 pub session_id: SessionId,
2351 /// The shell line to run (no trailing newline). Bounded by
2352 /// [`MAX_SESSION_LINE_BYTES`].
2353 pub line: String,
2354 /// Cursor into the session bucket to read combed signals from. Omit /
2355 /// `0` to read from the bucket head. The response returns the next
2356 /// cursor for the following exec.
2357 #[serde(default)]
2358 pub cursor: u64,
2359 /// Bounded wait (ms) for combed signals to appear after the line is
2360 /// written, clamped server-side to [`MAX_BUCKET_WAIT_MS`]. Omit for
2361 /// the default settle window.
2362 #[serde(default, skip_serializing_if = "Option::is_none")]
2363 pub wait_ms: Option<u64>,
2364}
2365
2366/// `shell_session_exec` response: combed signals appended to the session
2367/// bucket after the line ran, plus the next cursor. Never a raw stream.
2368#[derive(Debug, Clone, Serialize, Deserialize)]
2369pub struct ShellSessionExecResponse {
2370 pub session_id: SessionId,
2371 pub bucket_id: BucketId,
2372 pub bytes_written: u64,
2373 pub cursor_in: u64,
2374 pub next_cursor: u64,
2375 pub has_more: bool,
2376 pub dropped_count: u64,
2377 pub events: Vec<SignalEvent>,
2378}
2379
2380/// `shell_session_status` request.
2381#[derive(Debug, Clone, Serialize, Deserialize)]
2382pub struct ShellSessionStatusParams {
2383 pub session_id: SessionId,
2384}
2385
2386/// `shell_session_status` response: lifecycle state, current cwd, a
2387/// bounded env snapshot, and the last-active timestamp (epoch seconds).
2388#[derive(Debug, Clone, Serialize, Deserialize)]
2389pub struct ShellSessionStatusResponse {
2390 pub session_id: SessionId,
2391 pub bucket_id: BucketId,
2392 pub state: SessionState,
2393 /// Best-known current working directory. Tracked from the requested
2394 /// start cwd and from `cd`-shaped session lines (see SHELL_SESSION
2395 /// runtime docs); a value the daemon has not observed reports the
2396 /// start cwd. Bounded.
2397 pub cwd: Option<String>,
2398 /// Bounded env snapshot captured at start (overlay entries), capped at
2399 /// [`MAX_SESSION_ENV_ITEMS`].
2400 pub env_snapshot: Vec<(String, String)>,
2401 /// Seconds since the unix epoch of the last exec/status touch.
2402 pub last_active_at: u64,
2403}
2404
2405/// `shell_session_stop` request.
2406#[derive(Debug, Clone, Serialize, Deserialize)]
2407pub struct ShellSessionStopParams {
2408 pub session_id: SessionId,
2409}
2410
2411/// `shell_session_stop` response. The session shell is terminated
2412/// (graceful then forced) and the state moves to [`SessionState::Exited`].
2413#[derive(Debug, Clone, Serialize, Deserialize)]
2414pub struct ShellSessionStopResponse {
2415 pub session_id: SessionId,
2416 pub state: SessionState,
2417 /// Short bounded reason for the terminal transition (e.g. "stopped"
2418 /// or "already terminal").
2419 pub terminal_reason: String,
2420}
2421
2422/// One entry in `shell_session_list`.
2423#[derive(Debug, Clone, Serialize, Deserialize)]
2424pub struct ShellSessionListEntry {
2425 pub session_id: SessionId,
2426 pub bucket_id: BucketId,
2427 pub state: SessionState,
2428 pub cwd: Option<String>,
2429 pub last_active_at: u64,
2430}
2431
2432/// `shell_session_list` response: a bounded snapshot of live sessions.
2433#[derive(Debug, Clone, Serialize, Deserialize)]
2434pub struct ShellSessionListResponse {
2435 pub sessions: Vec<ShellSessionListEntry>,
2436}
2437
2438/// `workspace_snapshot_create` request: persist the current cwd + bounded
2439/// env of a session as a restorable workspace snapshot.
2440#[derive(Debug, Clone, Serialize, Deserialize)]
2441pub struct WorkspaceSnapshotCreateParams {
2442 pub session_id: SessionId,
2443 /// Optional human-friendly label stored alongside the snapshot.
2444 #[serde(default, skip_serializing_if = "Option::is_none")]
2445 pub name: Option<String>,
2446}
2447
2448/// `workspace_snapshot_create` response: the new snapshot id.
2449#[derive(Debug, Clone, Serialize, Deserialize)]
2450pub struct WorkspaceSnapshotCreateResponse {
2451 pub snapshot_id: String,
2452}
2453
2454/// `workspace_snapshot_apply` request: restore a snapshot's cwd/env into
2455/// the given (live) session.
2456#[derive(Debug, Clone, Serialize, Deserialize)]
2457pub struct WorkspaceSnapshotApplyParams {
2458 pub snapshot_id: String,
2459 pub session_id: SessionId,
2460}
2461
2462/// `workspace_snapshot_apply` response: the restored cwd echoed back.
2463#[derive(Debug, Clone, Serialize, Deserialize)]
2464pub struct WorkspaceSnapshotApplyResponse {
2465 pub applied: bool,
2466 pub session_id: SessionId,
2467 pub cwd: Option<String>,
2468}
2469
2470// =====================================================================
2471// TC45: aggregate runtime view (read-only).
2472//
2473// `runtime_state`, `probe_list`, and `probe_status` surface the union
2474// of `CommandRuntime::live_jobs`, `WatchRuntime::list`, and
2475// `PtyRuntime::list` (when cfg(unix)) plus bucket counters and the
2476// scoped activation snapshot. No new spawn / cancel / mutation
2477// capability. No raw stream content.
2478// =====================================================================
2479
2480/// Default + hard cap on each list/snapshot vec (subscriptions §6).
2481///
2482/// `runtime_state` bounds its THREE vecs (probes, buckets, active_rules)
2483/// INDEPENDENTLY by this; `probe_list` / `registry_list_active` bound their
2484/// single vec by it. Over-cap sets the per-vec `truncated` flag.
2485pub const MAX_LIST_LIMIT: usize = 500;
2486
2487/// Closed set of probe kinds surfaced by `probe_list` / `probe_status`.
2488#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2489#[serde(rename_all = "snake_case")]
2490pub enum ProbeKind {
2491 Command,
2492 FileWatch,
2493 Pty,
2494}
2495
2496/// Single authoritative liveness union for a probe / subscription source.
2497///
2498/// Derived from the job ledger (NOT from live-map presence: command
2499/// bindings linger in the live map after exit, so presence is not
2500/// "running"). Reused by `ProbeListEntry.liveness` and the subscription
2501/// `SourceLiveness`. See subscriptions spec MUST-ADD #3.
2502///
2503/// `JobState -> Liveness` mapping (command kind):
2504/// `Starting -> Starting`, `Running -> Running`,
2505/// `Exited -> Exited{code}`, `Failed -> Failed{code,signal}`,
2506/// `Cancelled -> Cancelled` (cancel sets signal `"CANCELLED"` and MUST
2507/// NOT be folded into `Failed`). File-watch / PTY report `Running`
2508/// while present (no exit-code concept on the live-map path).
2509/// `Dropped{count}` carries a bucket's `dropped_count` when surfacing
2510/// bucket-level lag (not used by per-probe derivation today).
2511#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2512#[serde(rename_all = "snake_case", tag = "state")]
2513pub enum Liveness {
2514 /// Probe registered, process not yet observed running.
2515 Starting,
2516 /// Probe is live (process running, or watch/PTY present).
2517 Running,
2518 /// Process exited cleanly (code 0, no signal).
2519 Exited { code: i32 },
2520 /// Process exited non-zero or was killed by a signal.
2521 Failed {
2522 code: Option<i32>,
2523 signal: Option<String>,
2524 },
2525 /// Operator-initiated kill before exit (JobState::Cancelled).
2526 Cancelled,
2527 /// Probe stopped without an exit code (watch/PTY removal).
2528 Stopped,
2529 /// Bucket-level lag: `count` events were dropped (eviction).
2530 Dropped { count: u64 },
2531}
2532
2533impl Default for Liveness {
2534 /// Backstop for `#[serde(default)]` on `ProbeListEntry.liveness`
2535 /// when decoding an older payload that predates the field. A
2536 /// present-but-unannotated probe is treated as `Running`.
2537 fn default() -> Self {
2538 Self::Running
2539 }
2540}
2541
2542/// One row in `probe_list` / `runtime_state.probes`.
2543///
2544/// Bounded; never carries raw stream content. `argv` is the
2545/// bounded argv passed at spawn time (for FileWatch this is
2546/// `["file_watch:<path>"]`, matching how `WatchRuntime` registers
2547/// the JobConfig today; for PTY this is the original argv).
2548#[derive(Debug, Clone, Serialize, Deserialize)]
2549pub struct ProbeListEntry {
2550 pub kind: ProbeKind,
2551 pub job_id: JobId,
2552 pub bucket_id: BucketId,
2553 pub probe_id: terminal_commander_core::ProbeId,
2554 pub frames_total: u64,
2555 pub events_emitted: u64,
2556 #[serde(default)]
2557 pub frames_suppressed: u64,
2558 #[serde(default)]
2559 pub frames_suppressed_progress: u64,
2560 #[serde(default)]
2561 pub frames_suppressed_dedupe: u64,
2562 /// PTY only — surfaces `PtyProbeMetrics::secret_prompts_total`.
2563 /// Other kinds return 0.
2564 pub secret_prompts_total: u64,
2565 /// PTY only — current secret-prompt flag from the probe.
2566 /// Other kinds return `false`.
2567 pub secret_prompt_active: bool,
2568 /// File-watch only — surfaces the watched path. Other kinds
2569 /// return None.
2570 pub path: Option<std::path::PathBuf>,
2571 /// Per-source liveness. Command probes derive this from the job
2572 /// ledger (`command.status(job).state`) — NOT from live-map
2573 /// presence, which lingers after exit. File-watch and PTY probes
2574 /// report `Running` while present. `#[serde(default)]` so older
2575 /// payloads (pre-liveness) decode as `Running`.
2576 #[serde(default)]
2577 pub liveness: Liveness,
2578 /// Optional per-bucket tag lifted from the bucket source. `#[serde(default,
2579 /// skip_serializing_if)]` keeps the wire additive: old payloads decode as None,
2580 /// old daemons omit it, new clients see None.
2581 #[serde(default, skip_serializing_if = "Option::is_none")]
2582 pub tag: Option<String>,
2583 /// Bounded, REDACTED argv head (program + up to 2 tokens), with secret spans
2584 /// masked to `<redacted>` and each item truncated to 128 bytes. None when the
2585 /// source kind carries no argv. Additive: `#[serde(default, skip_serializing_if)]`.
2586 #[serde(default, skip_serializing_if = "Option::is_none")]
2587 pub argv_head: Option<Vec<String>>,
2588}
2589
2590/// Bucket-level counters surfaced by `runtime_state.buckets`.
2591#[derive(Debug, Clone, Serialize, Deserialize)]
2592pub struct RuntimeBucketSummary {
2593 pub bucket_id: BucketId,
2594 pub head_seq: u64,
2595 pub tail_seq: u64,
2596 pub event_count: u64,
2597 /// Backpressure indicator: events dropped by the bucket's
2598 /// retention policy. Already tracked by `BucketSummary`
2599 /// (TC07); surfaced here in the aggregate view.
2600 pub dropped_count: u64,
2601}
2602
2603/// One active scoped registry binding surfaced by
2604/// `runtime_state.active_rules`.
2605#[derive(Debug, Clone, Serialize, Deserialize)]
2606pub struct RuntimeActiveRule {
2607 pub rule_id: String,
2608 pub version: u32,
2609 pub event_kind: String,
2610 pub scope: terminal_commander_core::ActivationScope,
2611}
2612
2613/// Optional per-call `limit` for the single-vec list snapshots
2614/// (`probe_list`, `registry_list_active`) and `runtime_state` (applied
2615/// independently to each of its three vecs). Clamped to [`MAX_LIST_LIMIT`].
2616#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2617pub struct ListLimitParams {
2618 /// Max rows per vec. Clamped to [`MAX_LIST_LIMIT`]. Omitted =
2619 /// [`MAX_LIST_LIMIT`].
2620 #[serde(default, skip_serializing_if = "Option::is_none")]
2621 pub limit: Option<usize>,
2622}
2623
2624#[derive(Debug, Clone, Serialize, Deserialize)]
2625pub struct RuntimeStateResponse {
2626 pub command_jobs: u32,
2627 pub pty_jobs: u32,
2628 pub file_watches: u32,
2629 pub bucket_count: u32,
2630 pub active_rules_count: u32,
2631 pub probes: Vec<ProbeListEntry>,
2632 pub buckets: Vec<RuntimeBucketSummary>,
2633 pub active_rules: Vec<RuntimeActiveRule>,
2634 /// More probes existed than `probes` carries (bounded independently
2635 /// by [`MAX_LIST_LIMIT`] / the request `limit`).
2636 #[serde(default)]
2637 pub probes_truncated: bool,
2638 /// More buckets existed than `buckets` carries.
2639 #[serde(default)]
2640 pub buckets_truncated: bool,
2641 /// More active rules existed than `active_rules` carries.
2642 #[serde(default)]
2643 pub active_rules_truncated: bool,
2644}
2645
2646#[derive(Debug, Clone, Serialize, Deserialize)]
2647pub struct ProbeListResponse {
2648 pub probes: Vec<ProbeListEntry>,
2649 /// More probes existed than `probes` carries (bounded by
2650 /// [`MAX_LIST_LIMIT`] / the request `limit`).
2651 #[serde(default)]
2652 pub truncated: bool,
2653}
2654
2655#[derive(Debug, Clone, Serialize, Deserialize)]
2656pub struct ProbeStatusParams {
2657 pub probe_id: terminal_commander_core::ProbeId,
2658}
2659
2660#[derive(Debug, Clone, Serialize, Deserialize)]
2661pub struct ProbeStatusResponse {
2662 pub probe: ProbeListEntry,
2663}
2664
2665// =====================================================================
2666// P4: audit-log read surface (read-only).
2667//
2668// `audit_since` exposes a cursor-paged, bounded view of the persistent
2669// audit log. The protocol owns its serde surface: [`AuditRowWire`]
2670// MIRRORS `terminal_commander_store::AuditRow` the same way
2671// [`SeverityHistogram`] mirrors its in-memory type. The wire form does
2672// NOT couple to store internals — `timestamp` is carried as an RFC3339
2673// string so the protocol crate needs no `time` dependency.
2674// =====================================================================
2675
2676/// Hard cap on rows returned by `audit_since` in a single call.
2677/// Mirrors `terminal_commander_store::MAX_AUDIT_READ_LIMIT`. The daemon
2678/// dispatcher clamps oversize / omitted limits to this value.
2679pub const MAX_AUDIT_READ_LIMIT: usize = 10_000;
2680/// Default rows returned when the caller omits a limit. Mirrors
2681/// `terminal_commander_store::DEFAULT_AUDIT_READ_LIMIT`.
2682pub const DEFAULT_AUDIT_READ_LIMIT: usize = 200;
2683
2684/// Parameters for `audit_since`. Reads rows strictly after `cursor`
2685/// (i.e. `audit_id > cursor`), ordered ascending by `audit_id`.
2686#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2687pub struct AuditSinceParams {
2688 /// Return rows with `audit_id > cursor`. `0` reads from the start.
2689 pub cursor: u64,
2690 /// Optional exact-match action filter (e.g. `"registry_activate"`).
2691 #[serde(default, skip_serializing_if = "Option::is_none")]
2692 pub action_filter: Option<String>,
2693 /// Optional exact-match decision filter (e.g. `"info"`, `"error"`).
2694 #[serde(default, skip_serializing_if = "Option::is_none")]
2695 pub decision_filter: Option<String>,
2696 /// Result count cap. Clamped to [`MAX_AUDIT_READ_LIMIT`] at the
2697 /// dispatcher. Omitted = [`DEFAULT_AUDIT_READ_LIMIT`].
2698 #[serde(default, skip_serializing_if = "Option::is_none")]
2699 pub limit: Option<usize>,
2700}
2701
2702/// One audit row on the wire.
2703///
2704/// MIRRORS `terminal_commander_store::AuditRow` field-for-field; the
2705/// daemon maps the in-memory row to this shape (a unit test guards the
2706/// mapping against drift). `timestamp` is the row's RFC3339 string —
2707/// the same encoding the store persists — so the protocol owns its
2708/// serde surface without a `time` dependency.
2709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2710pub struct AuditRowWire {
2711 pub audit_id: u64,
2712 /// RFC3339 timestamp string.
2713 pub timestamp: String,
2714 pub action: String,
2715 pub subject: String,
2716 pub decision: String,
2717 #[serde(default, skip_serializing_if = "Option::is_none")]
2718 pub profile: Option<String>,
2719 #[serde(default, skip_serializing_if = "Option::is_none")]
2720 pub reason: Option<String>,
2721 #[serde(default, skip_serializing_if = "Option::is_none")]
2722 pub actor: Option<String>,
2723 #[serde(default, skip_serializing_if = "Option::is_none")]
2724 pub metadata_json: Option<String>,
2725}
2726
2727/// Response shape for `audit_since`. Bounded by
2728/// [`MAX_AUDIT_READ_LIMIT`]; carries the next cursor so a client can
2729/// page forward without re-reading.
2730#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2731pub struct AuditSinceResponse {
2732 /// Echo of the requested cursor.
2733 pub cursor_in: u64,
2734 /// `audit_id` of the last returned row, or `cursor_in` when empty.
2735 /// Pass as the next call's `cursor` to page forward.
2736 pub next_cursor: u64,
2737 pub rows: Vec<AuditRowWire>,
2738}
2739
2740// =====================================================================
2741// Subscriptions (Phase 1): predicate-routed, multiplexed event consumer.
2742//
2743// Wire types for `subscription_open/pull/list/close`. The daemon holds
2744// the opaque per-open `sub_id` + server-advanced offsets; the wire form
2745// carries the predicate, the source-tagged events, and per-source
2746// liveness. See `docs/superpowers/specs/2026-06-02-subscriptions-design.md`
2747// §4, §6.
2748// =====================================================================
2749
2750/// Per-bucket routing selector on the wire. Mirrors the daemon-internal
2751/// `SourceSel`. `all` auto-includes future matching buckets; the fixed
2752/// variants are a closed set of ids.
2753#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2754#[serde(rename_all = "snake_case", tag = "kind")]
2755pub enum SubscriptionSourceSel {
2756 /// Every bucket; future buckets auto-join on the next routing rebuild.
2757 All,
2758 /// A fixed set of owning jobs.
2759 Jobs { jobs: Vec<JobId> },
2760 /// A fixed set of bucket ids.
2761 Buckets { buckets: Vec<BucketId> },
2762 /// A fixed set of owning probes.
2763 Probes {
2764 probes: Vec<terminal_commander_core::ProbeId>,
2765 },
2766}
2767
2768/// The wire predicate. All fields optional; AND semantics. `severity_min`
2769/// and `kind` are per-EVENT filters; `sources` is per-BUCKET routing.
2770#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2771pub struct SubscriptionPredicate {
2772 /// Minimum severity (per-EVENT). Omitted = `trace` (no filter).
2773 #[serde(default, skip_serializing_if = "Option::is_none")]
2774 pub severity_min: Option<Severity>,
2775 /// Event-kind allowlist (per-EVENT). Omitted = any kind.
2776 #[serde(default, skip_serializing_if = "Option::is_none")]
2777 pub kind: Option<Vec<String>>,
2778 /// Per-BUCKET routing selector. Omitted = `all`.
2779 #[serde(default = "default_source_sel")]
2780 pub sources: SubscriptionSourceSel,
2781 /// Per-BUCKET tag AND-filter. Omitted = ignore the tag dimension.
2782 #[serde(default, skip_serializing_if = "Option::is_none")]
2783 pub tag: Option<String>,
2784}
2785
2786/// Default routing selector (`all`) when `sources` is omitted on the wire.
2787const fn default_source_sel() -> SubscriptionSourceSel {
2788 SubscriptionSourceSel::All
2789}
2790
2791/// One source-tagged event delivered by `subscription_pull`.
2792///
2793/// Reuses the existing [`SignalEvent`] wire shape (the same type
2794/// `bucket_events_since` returns), plus its provenance so a multiplexed
2795/// consumer can attribute it without juggling N cursors.
2796#[derive(Debug, Clone, Serialize, Deserialize)]
2797pub struct SubscriptionEvent {
2798 /// Bucket the event was read from.
2799 pub bucket_id: BucketId,
2800 /// Owning job, if the bucket's source recorded one.
2801 #[serde(default, skip_serializing_if = "Option::is_none")]
2802 pub job_id: Option<JobId>,
2803 /// The event's per-bucket sequence number (provenance echo).
2804 pub seq: u64,
2805 /// The matched signal event.
2806 pub event: SignalEvent,
2807}
2808
2809/// Per-source liveness entry returned with every pull (including idle).
2810#[derive(Debug, Clone, Serialize, Deserialize)]
2811pub struct SourceLiveness {
2812 /// The in-scope bucket this entry describes.
2813 pub bucket_id: BucketId,
2814 /// Owning job, if recorded.
2815 #[serde(default, skip_serializing_if = "Option::is_none")]
2816 pub job_id: Option<JobId>,
2817 /// Owning probe, if recorded.
2818 #[serde(default, skip_serializing_if = "Option::is_none")]
2819 pub probe_id: Option<terminal_commander_core::ProbeId>,
2820 /// Process / probe liveness (the single authoritative union).
2821 pub liveness: Liveness,
2822}
2823
2824/// One row in `subscription_list`. Bounded; the predicate hash lets an
2825/// agent recognize an equivalent predicate without coupling cursors.
2826#[derive(Debug, Clone, Serialize, Deserialize)]
2827pub struct SubscriptionSummary {
2828 /// Opaque per-open handle.
2829 pub sub_id: String,
2830 /// Stable hash of the normalized predicate (decimal string).
2831 pub predicate_hash: String,
2832 /// Number of buckets this subscription currently tracks an offset for.
2833 pub source_count: u32,
2834 /// Milliseconds since the Unix epoch at open.
2835 pub created_at_ms: u64,
2836 /// Milliseconds since the Unix epoch of the last pull, if any.
2837 #[serde(default, skip_serializing_if = "Option::is_none")]
2838 pub last_pull_at_ms: Option<u64>,
2839}
2840
2841/// `subscription_open` parameters.
2842#[derive(Debug, Clone, Serialize, Deserialize)]
2843pub struct SubscriptionOpenParams {
2844 pub predicate: SubscriptionPredicate,
2845}
2846
2847/// `subscription_open` response. `boot_id` lets a looping agent detect a
2848/// restart (registry + buckets + offsets reset together).
2849#[derive(Debug, Clone, Serialize, Deserialize)]
2850pub struct SubscriptionOpenResponse {
2851 /// Opaque per-open handle (uuid string).
2852 pub sub_id: String,
2853 /// This daemon's per-boot id (uuid string).
2854 pub boot_id: String,
2855 /// Stable hash of the normalized predicate (decimal string).
2856 pub predicate_hash: String,
2857 /// Milliseconds since the Unix epoch at open.
2858 pub created_at_ms: u64,
2859 /// Number of already-in-scope sources matched at open.
2860 pub matched_sources: u32,
2861}
2862
2863/// `subscription_pull` parameters.
2864#[derive(Debug, Clone, Serialize, Deserialize)]
2865pub struct SubscriptionPullParams {
2866 pub sub_id: String,
2867 /// Max events to return. Clamped to [`MAX_PULL_EVENTS`]. Omitted =
2868 /// [`MAX_PULL_EVENTS`].
2869 #[serde(default, skip_serializing_if = "Option::is_none")]
2870 pub max: Option<usize>,
2871 /// Blocking timeout. Clamped to `[1, MAX_PULL_TIMEOUT_MS]`. Omitted =
2872 /// [`DEFAULT_PULL_TIMEOUT_MS`].
2873 #[serde(default, skip_serializing_if = "Option::is_none")]
2874 pub timeout_ms: Option<u64>,
2875 /// When true, `liveness` in the response carries only entries whose state
2876 /// changed since the last pull (full snapshot on a subscription's first
2877 /// pull and after any seek). Default false = today's full-array behavior,
2878 /// byte-identical for existing callers.
2879 #[serde(default)]
2880 pub liveness_delta: bool,
2881}
2882
2883/// `subscription_pull` response. Idle = empty `events` + liveness (never
2884/// an error). No `next_state` in Phase 1.
2885#[derive(Debug, Clone, Serialize, Deserialize)]
2886pub struct SubscriptionPullResponse {
2887 pub events: Vec<SubscriptionEvent>,
2888 pub liveness: Vec<SourceLiveness>,
2889 /// Any in-scope bucket dropped events under us (eviction lag).
2890 pub lagged: bool,
2891 /// The routing scan hit [`MAX_BUCKETS_PER_SUBSCRIPTION`].
2892 pub truncated: bool,
2893}
2894
2895/// `subscription_list` parameters. Bounded by an optional `limit`.
2896#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2897pub struct SubscriptionListParams {
2898 /// Max rows to return. Clamped to [`MAX_SUBSCRIPTIONS`]. Omitted =
2899 /// [`MAX_SUBSCRIPTIONS`].
2900 #[serde(default, skip_serializing_if = "Option::is_none")]
2901 pub limit: Option<usize>,
2902}
2903
2904/// `subscription_list` response. Bounded; `truncated` set if more
2905/// subscriptions exist than were returned.
2906#[derive(Debug, Clone, Serialize, Deserialize)]
2907pub struct SubscriptionListResponse {
2908 pub subscriptions: Vec<SubscriptionSummary>,
2909 pub truncated: bool,
2910}
2911
2912/// `subscription_close` parameters.
2913#[derive(Debug, Clone, Serialize, Deserialize)]
2914pub struct SubscriptionCloseParams {
2915 pub sub_id: String,
2916}
2917
2918/// `subscription_close` response. `closed=false` when the `sub_id` was
2919/// already unknown (idempotent close).
2920#[derive(Debug, Clone, Serialize, Deserialize)]
2921pub struct SubscriptionCloseResponse {
2922 pub closed: bool,
2923}
2924
2925/// `subscription_seek` parameters.
2926///
2927/// Reposition ONE bucket's offset for an existing subscription (explicit
2928/// re-read). The requested `seq` is clamped to the bucket's live range; it is
2929/// never an error. (Subscriptions §3 seek.)
2930#[derive(Debug, Clone, Serialize, Deserialize)]
2931pub struct SubscriptionSeekParams {
2932 /// Opaque handle from `subscription_open`.
2933 pub sub_id: String,
2934 /// Bucket to reposition within.
2935 pub bucket_id: BucketId,
2936 /// Requested re-read position. Clamped to
2937 /// `[head_seq.saturating_sub(1), tail_seq]`; never an error.
2938 pub seq: u64,
2939}
2940
2941/// `subscription_seek` response.
2942#[derive(Debug, Clone, Serialize, Deserialize)]
2943pub struct SubscriptionSeekResponse {
2944 /// The offset actually stored after clamping.
2945 pub clamped_seq: u64,
2946 /// True when the requested seq was below `head_seq.saturating_sub(1)`
2947 /// (the requested events were already evicted).
2948 pub lagged: bool,
2949}
2950
2951/// Serialize an envelope to a length-prefixed wire frame. Returns
2952/// the bytes ready to write to the socket. Rejects payloads larger
2953/// than [`MAX_FRAME_BYTES`].
2954pub fn encode_frame<T: Serialize>(value: &T) -> Result<Vec<u8>, IpcError> {
2955 let json = serde_json::to_vec(value)
2956 .map_err(|e| IpcError::new(IpcErrorCode::Internal, format!("serialize: {e}")))?;
2957 if json.len() > MAX_FRAME_BYTES {
2958 return Err(IpcError::new(
2959 IpcErrorCode::FrameTooLarge,
2960 format!(
2961 "payload {} bytes > MAX_FRAME_BYTES {MAX_FRAME_BYTES}",
2962 json.len()
2963 ),
2964 ));
2965 }
2966 let len_u32 = u32::try_from(json.len())
2967 .map_err(|_| IpcError::new(IpcErrorCode::Internal, "len overflow"))?;
2968 let mut out = Vec::with_capacity(4 + json.len());
2969 out.extend_from_slice(&len_u32.to_be_bytes());
2970 out.extend_from_slice(&json);
2971 Ok(out)
2972}
2973
2974/// Deserialize the JSON portion of a frame (length-prefix stripped
2975/// by the caller). The caller has already validated that
2976/// `payload.len() <= MAX_FRAME_BYTES`.
2977pub fn decode_payload<T: for<'de> Deserialize<'de>>(payload: &[u8]) -> Result<T, IpcError> {
2978 serde_json::from_slice::<T>(payload)
2979 .map_err(|e| IpcError::new(IpcErrorCode::MalformedJson, format!("decode: {e}")))
2980}
2981
2982#[cfg(test)]
2983mod tests {
2984 use super::*;
2985
2986 #[test]
2987 fn transport_error_is_classified_and_daemon_internal_is_not() {
2988 // A client-side transport failure carries the marker and is recognized.
2989 let t = IpcError::transport("connect: os error 2");
2990 assert_eq!(t.code, IpcErrorCode::Internal);
2991 assert!(
2992 t.is_transport(),
2993 "a transport() error must classify as transport"
2994 );
2995 assert!(
2996 t.message.starts_with(IpcError::TRANSPORT_PREFIX),
2997 "transport message keeps a human-readable marker prefix"
2998 );
2999 assert!(
3000 t.message.contains("connect: os error 2"),
3001 "the underlying cause is preserved in the message"
3002 );
3003
3004 // A daemon-RETURNED Internal error (no marker) must NOT classify as
3005 // transport, so it keeps the real internal_error mapping at the MCP edge.
3006 let daemon_internal = IpcError::new(IpcErrorCode::Internal, "open: permission denied");
3007 assert!(
3008 !daemon_internal.is_transport(),
3009 "a daemon-returned Internal error must NOT be misread as transport"
3010 );
3011
3012 // A caller-fixable code is never transport regardless of message text.
3013 let fixable = IpcError::new(IpcErrorCode::PathDenied, "transport: not really");
3014 assert!(
3015 !fixable.is_transport(),
3016 "only Internal-coded marker-prefixed errors are transport"
3017 );
3018 }
3019
3020 #[test]
3021 fn transport_error_survives_wire_round_trip() {
3022 // The marker rides in the message, so a transport error serialized and
3023 // decoded over the wire is still recognized (defensive: the daemon never
3024 // sends one, but the classifier must be robust if it ever did).
3025 let t = IpcError::transport("pipe connect: os error 2");
3026 let json = serde_json::to_string(&t).unwrap();
3027 let back: IpcError = serde_json::from_str(&json).unwrap();
3028 assert!(back.is_transport());
3029 }
3030
3031 #[test]
3032 fn legacy_discover_without_environment_decodes_as_unknown() {
3033 let json = r#"{
3034 "version":"0.1.0",
3035 "mcp_spec":"2025-11-25",
3036 "policy_profile":"default",
3037 "methods":["system_discover"]
3038 }"#;
3039 let decoded: DiscoverResponse = serde_json::from_str(json).unwrap();
3040 assert_eq!(*decoded.environment, HostEnvironment::default());
3041 }
3042
3043 /// F7: the typed `argv0` field is the authoritative carrier for a
3044 /// `ProgramNotFound` error and must survive a serde round-trip exactly,
3045 /// independent of the human-readable `message`. The MCP boundary reads
3046 /// THIS field (not the prose), so the round-trip is the contract.
3047 #[test]
3048 fn program_not_found_argv0_survives_wire_round_trip() {
3049 // A program name with an embedded apostrophe -- the exact case the old
3050 // prose quote-count parse could not recover. The typed field carries it
3051 // verbatim regardless of how the message is worded.
3052 let e = IpcError::program_not_found("my'prog", "program not found: 'my'prog'.");
3053 assert_eq!(e.code, IpcErrorCode::ProgramNotFound);
3054 assert_eq!(e.argv0.as_deref(), Some("my'prog"));
3055
3056 let json = serde_json::to_string(&e).unwrap();
3057 let back: IpcError = serde_json::from_str(&json).unwrap();
3058 assert_eq!(
3059 back.argv0.as_deref(),
3060 Some("my'prog"),
3061 "the typed argv0 must survive serialization verbatim"
3062 );
3063 assert_eq!(back.code, IpcErrorCode::ProgramNotFound);
3064 assert_eq!(back.message, e.message);
3065 }
3066
3067 /// F7: `argv0` is additive and `skip_serializing_if = "Option::is_none"`,
3068 /// so a non-ProgramNotFound error (the common case) must OMIT the `argv0`
3069 /// key from the wire entirely -- no key, not `null`. This pins the
3070 /// skip_serializing_if contract so every other error round-trips unchanged
3071 /// and pre-F7 clients see no new field.
3072 #[test]
3073 fn argv0_key_is_omitted_from_json_when_none() {
3074 let e = IpcError::new(IpcErrorCode::PathDenied, "nope");
3075 assert!(e.argv0.is_none());
3076 let value: serde_json::Value = serde_json::to_value(&e).unwrap();
3077 assert!(
3078 value.get("argv0").is_none(),
3079 "the argv0 key must be absent (not null) when None; got: {value}"
3080 );
3081 // And the typical constructors default it to None.
3082 assert!(IpcError::transport("x").argv0.is_none());
3083 }
3084
3085 // Source-status: test-only. TC-2: the dedup_nonce field is additive and
3086 // serde(default), so an OLD client payload that omits it must still
3087 // decode, and a payload carrying Some(nonce) must round-trip unchanged.
3088 #[test]
3089 fn command_start_params_dedup_nonce_is_optional_and_round_trips() {
3090 // Old-client payload: no dedup_nonce key at all. Must decode via the
3091 // serde default to None (additive, non-breaking).
3092 let old_payload = r#"{"argv":["true"]}"#;
3093 let decoded: CommandStartParams =
3094 serde_json::from_str(old_payload).expect("old payload without dedup_nonce must decode");
3095 assert_eq!(
3096 decoded.dedup_nonce, None,
3097 "absent dedup_nonce must default to None"
3098 );
3099
3100 // Present nonce: serialize -> decode preserves it exactly.
3101 let with_nonce = CommandStartParams {
3102 environment: None,
3103 argv: vec!["true".to_owned()],
3104 cwd: None,
3105 env: vec![],
3106 bucket_config: None,
3107 rules: vec![],
3108 grace_ms: None,
3109 tag: None,
3110 dedup_nonce: Some("mcp-1234-7".to_owned()),
3111 strip_ansi: true,
3112 };
3113 let json = serde_json::to_string(&with_nonce).expect("serialize ok");
3114 assert!(
3115 json.contains("dedup_nonce"),
3116 "a Some(nonce) must serialize the field; json: {json}"
3117 );
3118 let back: CommandStartParams = serde_json::from_str(&json).expect("decode ok");
3119 assert_eq!(back.dedup_nonce, Some("mcp-1234-7".to_owned()));
3120
3121 // skip_serializing_if: a None nonce must NOT appear on the wire, so an
3122 // old daemon never sees an unexpected key.
3123 let without = CommandStartParams {
3124 dedup_nonce: None,
3125 strip_ansi: true,
3126 ..with_nonce
3127 };
3128 let json_none = serde_json::to_string(&without).expect("serialize ok");
3129 assert!(
3130 !json_none.contains("dedup_nonce"),
3131 "a None nonce must be omitted from the wire; json: {json_none}"
3132 );
3133 }
3134
3135 // TC-4 Phase 4a: the ProbeListEntry `tag` and `argv_head` fields are
3136 // additive and `#[serde(default, skip_serializing_if)]`. An OLD payload
3137 // that omits both must decode (fields default to None), and a daemon that
3138 // has neither set must omit both keys from the wire (skip_serializing_if).
3139 #[test]
3140 fn probe_list_entry_tag_and_argv_head_are_optional_and_additive() {
3141 use terminal_commander_core::{BucketId, JobId, ProbeId};
3142
3143 // Build a fully-populated entry, then serialize it and strip the new
3144 // keys to reconstruct an OLD-daemon payload deterministically (the typed
3145 // ids serialize as `<prefix>_<hex>`, not raw UUIDs, so we cannot hand-
3146 // write the wire form). This also exercises the live wire prefixes.
3147 let populated = ProbeListEntry {
3148 kind: ProbeKind::Command,
3149 job_id: JobId::new(),
3150 bucket_id: BucketId::new(),
3151 probe_id: ProbeId::new(),
3152 frames_total: 0,
3153 events_emitted: 0,
3154 frames_suppressed: 0,
3155 frames_suppressed_progress: 0,
3156 frames_suppressed_dedupe: 0,
3157 secret_prompts_total: 0,
3158 secret_prompt_active: false,
3159 path: None,
3160 liveness: Liveness::default(),
3161 tag: Some("prod".to_owned()),
3162 argv_head: Some(vec!["curl".to_owned(), "<redacted>".to_owned()]),
3163 };
3164
3165 // Present values round-trip exactly.
3166 let json_full = serde_json::to_string(&populated).expect("encode populated");
3167 let back: ProbeListEntry =
3168 serde_json::from_str(&json_full).expect("populated payload round-trips");
3169 assert_eq!(back.tag.as_deref(), Some("prod"));
3170 assert_eq!(
3171 back.argv_head,
3172 Some(vec!["curl".to_owned(), "<redacted>".to_owned()])
3173 );
3174
3175 // skip_serializing_if: a None tag/argv_head must NOT appear on the wire,
3176 // so an old daemon and a new client agree on the encoded shape.
3177 let none_entry = ProbeListEntry {
3178 tag: None,
3179 argv_head: None,
3180 ..populated
3181 };
3182 let json_none = serde_json::to_string(&none_entry).expect("encode none");
3183 assert!(
3184 !json_none.contains("\"tag\""),
3185 "None tag must be omitted from the wire (skip_serializing_if)"
3186 );
3187 assert!(
3188 !json_none.contains("\"argv_head\""),
3189 "None argv_head must be omitted from the wire (skip_serializing_if)"
3190 );
3191
3192 // Old-daemon payload shape: the encoded none_entry already omits both
3193 // keys, so decoding it proves an absent tag/argv_head defaults to None.
3194 let decoded: ProbeListEntry = serde_json::from_str(&json_none)
3195 .expect("old payload without tag/argv_head must decode");
3196 assert_eq!(decoded.tag, None, "absent tag must default to None");
3197 assert_eq!(
3198 decoded.argv_head, None,
3199 "absent argv_head must default to None"
3200 );
3201 }
3202
3203 // Source-status: test-only. Verifies the retry-safety classification on
3204 // `IpcRequest::is_idempotent`, which gates the MCP daemon-client retry so a
3205 // transport-failed MUTATING RPC (e.g. a >5s-spawning CommandStartCombed) is
3206 // never blindly re-sent. The list below is exhaustive over the enum; the
3207 // `is_idempotent` match itself is wildcard-free so a new variant forces a
3208 // deliberate classification before this test can even be updated.
3209 #[test]
3210 #[allow(clippy::too_many_lines)] // one table row per IpcRequest variant
3211 fn is_idempotent_classifies_every_request_variant() {
3212 use terminal_commander_core::{JobId, RuleDefinition};
3213
3214 // A minimal valid RuleDefinition for the two rule-carrying registry
3215 // variants. Deserialized rather than hand-built so this test stays
3216 // decoupled from the full struct shape (it only needs *a* value).
3217 fn sample_rule() -> RuleDefinition {
3218 serde_json::from_str(
3219 r#"{
3220 "id": "r",
3221 "version": 1,
3222 "kind": "keyword",
3223 "severity": "low",
3224 "event_kind": "k",
3225 "keywords": ["x"],
3226 "summary_template": "s"
3227 }"#,
3228 )
3229 .expect("sample rule deserializes")
3230 }
3231
3232 let predicate = SubscriptionPredicate {
3233 severity_min: None,
3234 kind: None,
3235 sources: SubscriptionSourceSel::All,
3236 tag: None,
3237 };
3238
3239 // (variant, expected is_idempotent). MUTATING = false (unsafe to blind
3240 // re-send), READ / idempotent-reposition = true.
3241 let cases: Vec<(IpcRequest, bool)> = vec![
3242 // ---- mutating: must be false ----
3243 (
3244 IpcRequest::CommandStartCombed(CommandStartParams {
3245 environment: None,
3246 argv: vec!["x".to_owned()],
3247 cwd: None,
3248 env: vec![],
3249 bucket_config: None,
3250 rules: vec![],
3251 grace_ms: None,
3252 tag: None,
3253 dedup_nonce: None,
3254 strip_ansi: true,
3255 }),
3256 false,
3257 ),
3258 (
3259 IpcRequest::ShellExec(ShellExecParams {
3260 shell_line: "echo a | wc -c".to_owned(),
3261 shell: None,
3262 cwd: None,
3263 env: vec![],
3264 rules: vec![],
3265 bucket_config: None,
3266 tag: None,
3267 }),
3268 false,
3269 ),
3270 (
3271 IpcRequest::PtyCommandStart(PtyCommandStartParams {
3272 environment: None,
3273 argv: vec!["x".to_owned()],
3274 cwd: None,
3275 env: vec![],
3276 bucket_config: None,
3277 rules: vec![],
3278 rows: None,
3279 cols: None,
3280 tag: None,
3281 }),
3282 false,
3283 ),
3284 (
3285 IpcRequest::PtyCommandWriteStdin(PtyCommandWriteStdinParams {
3286 job_id: JobId::new(),
3287 bytes: "x".to_owned(),
3288 cursor: None,
3289 wait_ms: None,
3290 }),
3291 false,
3292 ),
3293 (
3294 IpcRequest::PtyCommandStop(PtyCommandStopParams {
3295 job_id: JobId::new(),
3296 }),
3297 false,
3298 ),
3299 (
3300 IpcRequest::CommandStop(CommandStopParams {
3301 job_id: JobId::new(),
3302 }),
3303 false,
3304 ),
3305 (
3306 IpcRequest::RegistryUpsert(RegistryUpsertParams {
3307 definition: sample_rule(),
3308 }),
3309 false,
3310 ),
3311 (
3312 IpcRequest::RegistryActivate(RegistryActivateParams {
3313 rule_id: "r".to_owned(),
3314 version: None,
3315 scope: None,
3316 }),
3317 false,
3318 ),
3319 (
3320 IpcRequest::RegistryDeactivate(RegistryDeactivateParams {
3321 rule_id: "r".to_owned(),
3322 version: 1,
3323 scope: None,
3324 }),
3325 false,
3326 ),
3327 (
3328 IpcRequest::RegistryImportPack(RegistryImportPackParams {
3329 pack: "p".to_owned(),
3330 activate: false,
3331 scope: None,
3332 }),
3333 false,
3334 ),
3335 (
3336 // File WRITE (TC22 A3): MUTATING -- a blind retry double-writes,
3337 // so is_idempotent MUST be false. Classified with the other
3338 // file-state mutators, NOT with FileReadWindow / FileSearch.
3339 IpcRequest::FileWrite(FileWriteParams {
3340 path: "x".into(),
3341 content: "data".to_owned(),
3342 create_dirs: false,
3343 append: false,
3344 }),
3345 false,
3346 ),
3347 (
3348 IpcRequest::FileWatchStart(FileWatchStartParams {
3349 path: "x".into(),
3350 bucket_config: None,
3351 rules: vec![],
3352 follow_from_beginning: None,
3353 tag: None,
3354 }),
3355 false,
3356 ),
3357 (
3358 IpcRequest::FileWatchStop(FileWatchStopParams {
3359 watch_id: JobId::new(),
3360 }),
3361 false,
3362 ),
3363 (
3364 IpcRequest::SubscriptionOpen(SubscriptionOpenParams {
3365 predicate: predicate.clone(),
3366 }),
3367 false,
3368 ),
3369 (
3370 IpcRequest::SubscriptionClose(SubscriptionCloseParams {
3371 sub_id: "s".to_owned(),
3372 }),
3373 false,
3374 ),
3375 (
3376 IpcRequest::SubscriptionPull(SubscriptionPullParams {
3377 sub_id: "s".to_owned(),
3378 max: None,
3379 timeout_ms: None,
3380 liveness_delta: false,
3381 }),
3382 false,
3383 ),
3384 (IpcRequest::Shutdown, false),
3385 // ---- reads / idempotent repositioning: must be true ----
3386 (IpcRequest::Health, true),
3387 (IpcRequest::SystemDiscover, true),
3388 (IpcRequest::PolicyStatus, true),
3389 (IpcRequest::SelfCheck, true),
3390 (
3391 IpcRequest::CommandStatus(CommandStatusParams {
3392 job_id: JobId::new(),
3393 }),
3394 true,
3395 ),
3396 (
3397 IpcRequest::CommandOutputTail(CommandOutputTailParams {
3398 job_id: JobId::new(),
3399 max_lines: 10,
3400 max_bytes: 1024,
3401 strip_ansi: false,
3402 }),
3403 true,
3404 ),
3405 (
3406 IpcRequest::BucketWait(BucketWaitParams {
3407 bucket_id: BucketId::new(),
3408 cursor: 0,
3409 severity_min: None,
3410 kind_filter: None,
3411 limit: None,
3412 timeout_ms: None,
3413 }),
3414 true,
3415 ),
3416 (
3417 IpcRequest::BucketEventsSince(BucketEventsSinceParams {
3418 bucket_id: BucketId::new(),
3419 cursor: 0,
3420 severity_min: None,
3421 kind_filter: None,
3422 limit: None,
3423 }),
3424 true,
3425 ),
3426 (
3427 IpcRequest::BucketSummary(BucketSummaryParams {
3428 bucket_id: BucketId::new(),
3429 }),
3430 true,
3431 ),
3432 (
3433 IpcRequest::EventContext(EventContextParams {
3434 bucket_id: Some(BucketId::new()),
3435 event_id: EventId::new(),
3436 before: None,
3437 after: None,
3438 max_bytes: None,
3439 }),
3440 true,
3441 ),
3442 (
3443 IpcRequest::RuntimeState(ListLimitParams { limit: None }),
3444 true,
3445 ),
3446 (IpcRequest::ProbeList(ListLimitParams { limit: None }), true),
3447 (
3448 IpcRequest::ProbeStatus(ProbeStatusParams {
3449 probe_id: terminal_commander_core::ProbeId::new(),
3450 }),
3451 true,
3452 ),
3453 (IpcRequest::PtyCommandList, true),
3454 (
3455 IpcRequest::FileReadWindow(FileReadWindowParams {
3456 path: "x".into(),
3457 start_line: None,
3458 max_lines: None,
3459 max_bytes: None,
3460 }),
3461 true,
3462 ),
3463 (
3464 IpcRequest::FileSearch(FileSearchParams {
3465 path: "x".into(),
3466 query: "q".to_owned(),
3467 case_insensitive: None,
3468 max_matches: None,
3469 max_snippet_bytes: None,
3470 }),
3471 true,
3472 ),
3473 (
3474 // Directory listing (US3): a pure bounded read, replayable.
3475 IpcRequest::FileListDir(FileListDirParams {
3476 path: "/x".to_owned(),
3477 max_entries: None,
3478 }),
3479 true,
3480 ),
3481 (IpcRequest::FileWatchList, true),
3482 (
3483 IpcRequest::RegistrySearch(RegistrySearchParams {
3484 query: "q".to_owned(),
3485 limit: None,
3486 }),
3487 true,
3488 ),
3489 (
3490 IpcRequest::RegistryGet(RegistryGetParams {
3491 rule_id: "r".to_owned(),
3492 version: None,
3493 }),
3494 true,
3495 ),
3496 (
3497 IpcRequest::RegistryTest(RegistryTestParams {
3498 rule_id: "r".to_owned(),
3499 version: None,
3500 samples: vec![],
3501 }),
3502 true,
3503 ),
3504 (
3505 IpcRequest::RegistryListActive(ListLimitParams { limit: None }),
3506 true,
3507 ),
3508 (
3509 IpcRequest::SubscriptionList(SubscriptionListParams { limit: None }),
3510 true,
3511 ),
3512 (
3513 IpcRequest::SubscriptionSeek(SubscriptionSeekParams {
3514 sub_id: "s".to_owned(),
3515 bucket_id: BucketId::new(),
3516 seq: 0,
3517 }),
3518 true,
3519 ),
3520 (
3521 IpcRequest::AuditSince(AuditSinceParams {
3522 cursor: 0,
3523 action_filter: None,
3524 decision_filter: None,
3525 limit: None,
3526 }),
3527 true,
3528 ),
3529 ];
3530
3531 for (req, expected) in &cases {
3532 assert_eq!(
3533 req.is_idempotent(),
3534 *expected,
3535 "is_idempotent classification wrong for {req:?}"
3536 );
3537 }
3538
3539 // Spot-check the two highest-harm classifications explicitly so a
3540 // regression names the exact defect.
3541 assert!(
3542 !IpcRequest::CommandStartCombed(CommandStartParams {
3543 environment: None,
3544 argv: vec!["sleep".to_owned(), "10".to_owned()],
3545 cwd: None,
3546 env: vec![],
3547 bucket_config: None,
3548 rules: vec![],
3549 grace_ms: None,
3550 tag: None,
3551 dedup_nonce: None,
3552 strip_ansi: true,
3553 })
3554 .is_idempotent(),
3555 "CommandStartCombed must be non-idempotent: a blind retry double-spawns"
3556 );
3557 assert!(
3558 !IpcRequest::SubscriptionPull(SubscriptionPullParams {
3559 sub_id: "s".to_owned(),
3560 max: None,
3561 timeout_ms: None,
3562 liveness_delta: false,
3563 })
3564 .is_idempotent(),
3565 "SubscriptionPull must be non-idempotent: per-consumer offsets are \
3566 committed server-side inside the pull (subscriptions/pull.rs 543/633) \
3567 before the response, so a lost-then-retried pull drops already-drained \
3568 events -- it is NOT a replayable read like BucketWait"
3569 );
3570 assert!(
3571 !IpcRequest::SubscriptionOpen(SubscriptionOpenParams { predicate }).is_idempotent(),
3572 "SubscriptionOpen must be non-idempotent: a blind retry mints a second \
3573 sub_id + registry slot, leaking a slot and risking \
3574 SubscriptionLimitExceeded"
3575 );
3576 }
3577
3578 /// US3 (W1): the `file_list_dir` response wire shape is the pinned one --
3579 /// `DirEntryKind` renders snake_case (`file`/`dir`/`symlink`), and the
3580 /// per-entry `size_bytes`/`mtime_ms` optionals are omitted when `None`
3581 /// (`skip_serializing_if`). Round-trips back into the same variant.
3582 #[test]
3583 fn file_list_dir_response_wire_shape_is_pinned() {
3584 let resp = IpcResponse::FileListDir(FileListDirResponse {
3585 path: "/abs/dir".to_owned(),
3586 entries: vec![
3587 DirEntry {
3588 name: "sub".to_owned(),
3589 kind: DirEntryKind::Dir,
3590 size_bytes: None,
3591 mtime_ms: Some(1),
3592 },
3593 DirEntry {
3594 name: "f.txt".to_owned(),
3595 kind: DirEntryKind::File,
3596 size_bytes: Some(3),
3597 mtime_ms: None,
3598 },
3599 DirEntry {
3600 name: "link".to_owned(),
3601 kind: DirEntryKind::Symlink,
3602 size_bytes: None,
3603 mtime_ms: None,
3604 },
3605 ],
3606 total_entries: 3,
3607 truncated: false,
3608 });
3609 let json = serde_json::to_value(&resp).expect("serialize");
3610 assert_eq!(json["method"], "file_list_dir");
3611 // snake_case kinds.
3612 assert_eq!(json["entries"][0]["kind"], "dir");
3613 assert_eq!(json["entries"][1]["kind"], "file");
3614 assert_eq!(json["entries"][2]["kind"], "symlink");
3615 // Absent optionals are omitted, not rendered as null.
3616 assert!(
3617 json["entries"][0].get("size_bytes").is_none(),
3618 "dir omits size_bytes"
3619 );
3620 assert!(
3621 json["entries"][1].get("mtime_ms").is_none(),
3622 "None mtime_ms omitted"
3623 );
3624 assert_eq!(json["entries"][1]["size_bytes"], 3);
3625 assert_eq!(json["total_entries"], 3);
3626 assert_eq!(json["truncated"], false);
3627 // Round-trip.
3628 let back: IpcResponse = serde_json::from_value(json).expect("deserialize");
3629 assert!(matches!(back, IpcResponse::FileListDir(_)));
3630 }
3631
3632 #[test]
3633 fn encode_decode_envelope_round_trip() {
3634 let req = RequestEnvelope {
3635 correlation_id: 42,
3636 request: IpcRequest::SystemDiscover,
3637 };
3638 let frame = encode_frame(&req).unwrap();
3639 // 4-byte length + JSON
3640 assert!(frame.len() > 4);
3641 let len = u32::from_be_bytes([frame[0], frame[1], frame[2], frame[3]]) as usize;
3642 assert_eq!(len, frame.len() - 4);
3643 let back: RequestEnvelope = decode_payload(&frame[4..]).unwrap();
3644 assert_eq!(back.correlation_id, 42);
3645 assert!(matches!(back.request, IpcRequest::SystemDiscover));
3646 }
3647
3648 #[test]
3649 fn malformed_json_rejected_with_typed_code() {
3650 let bad = b"{not valid json";
3651 let err: IpcError = decode_payload::<RequestEnvelope>(bad).unwrap_err();
3652 assert_eq!(err.code, IpcErrorCode::MalformedJson);
3653 }
3654
3655 #[test]
3656 fn schema_mismatch_is_malformed_json_today() {
3657 // serde_json reports as a parse error; we surface that as
3658 // MalformedJson because the variant set is closed-set.
3659 let s = br#"{"correlation_id": 1, "request": {"method": "totally_bogus"}}"#;
3660 let err: IpcError = decode_payload::<RequestEnvelope>(s).unwrap_err();
3661 // Either MalformedJson or SchemaMismatch is acceptable; both
3662 // keep the bad payload out of the dispatcher.
3663 assert!(matches!(
3664 err.code,
3665 IpcErrorCode::MalformedJson | IpcErrorCode::SchemaMismatch
3666 ));
3667 }
3668
3669 #[test]
3670 fn frame_too_large_rejected_before_serialize_attempt() {
3671 // Construct an envelope that, once serialized, would exceed
3672 // MAX_FRAME_BYTES. Easiest way: a SelfCheck response with a
3673 // huge report string.
3674 let huge = "x".repeat(MAX_FRAME_BYTES + 1024);
3675 let env = ResponseEnvelope {
3676 correlation_id: 1,
3677 result: IpcResult::Ok {
3678 response: IpcResponse::SelfCheck(SelfCheckResponse {
3679 report: huge,
3680 failures: 0,
3681 }),
3682 },
3683 };
3684 let err = encode_frame(&env).unwrap_err();
3685 assert_eq!(err.code, IpcErrorCode::FrameTooLarge);
3686 }
3687
3688 #[test]
3689 fn subscription_error_codes_roundtrip_snake_case() {
3690 for (code, wire) in [
3691 (
3692 IpcErrorCode::UnknownSubscription,
3693 "\"unknown_subscription\"",
3694 ),
3695 (
3696 IpcErrorCode::SubscriptionLimitExceeded,
3697 "\"subscription_limit_exceeded\"",
3698 ),
3699 ] {
3700 let s = serde_json::to_string(&code).unwrap();
3701 assert_eq!(s, wire);
3702 let back: IpcErrorCode = serde_json::from_str(&s).unwrap();
3703 assert_eq!(back, code);
3704 }
3705 }
3706
3707 #[test]
3708 fn subscription_open_pair_round_trips_through_request_and_response() {
3709 let params = SubscriptionOpenParams {
3710 predicate: SubscriptionPredicate {
3711 severity_min: Some(Severity::High),
3712 kind: Some(vec!["error".to_owned(), "panic".to_owned()]),
3713 sources: SubscriptionSourceSel::Jobs {
3714 jobs: vec![JobId::new()],
3715 },
3716 tag: None,
3717 },
3718 };
3719 let req = IpcRequest::SubscriptionOpen(params);
3720 let back: IpcRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
3721 assert!(matches!(back, IpcRequest::SubscriptionOpen(_)));
3722
3723 let resp = IpcResponse::SubscriptionOpen(SubscriptionOpenResponse {
3724 sub_id: "sub-1".to_owned(),
3725 boot_id: "boot-1".to_owned(),
3726 predicate_hash: "12345".to_owned(),
3727 created_at_ms: 1_700_000_000_000,
3728 matched_sources: 3,
3729 });
3730 let back: IpcResponse =
3731 serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
3732 match back {
3733 IpcResponse::SubscriptionOpen(r) => {
3734 assert_eq!(r.sub_id, "sub-1");
3735 assert_eq!(r.matched_sources, 3);
3736 }
3737 other => panic!("unexpected: {other:?}"),
3738 }
3739 }
3740
3741 #[test]
3742 fn subscription_predicate_defaults_sources_to_all_when_omitted() {
3743 let json = r#"{"severity_min":"high"}"#;
3744 let p: SubscriptionPredicate = serde_json::from_str(json).unwrap();
3745 assert_eq!(p.sources, SubscriptionSourceSel::All);
3746 assert_eq!(p.severity_min, Some(Severity::High));
3747 assert!(p.kind.is_none());
3748 }
3749
3750 #[test]
3751 fn subscription_pull_pair_round_trips_through_request_and_response() {
3752 let params = SubscriptionPullParams {
3753 sub_id: "sub-1".to_owned(),
3754 max: Some(25),
3755 timeout_ms: Some(3_000),
3756 liveness_delta: true,
3757 };
3758 let req = IpcRequest::SubscriptionPull(params);
3759 let back: IpcRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
3760 match back {
3761 IpcRequest::SubscriptionPull(p) => {
3762 assert_eq!(p.sub_id, "sub-1");
3763 assert_eq!(p.max, Some(25));
3764 assert_eq!(p.timeout_ms, Some(3_000));
3765 assert!(p.liveness_delta, "liveness_delta round-trips on the wire");
3766 }
3767 other => panic!("unexpected: {other:?}"),
3768 }
3769
3770 let resp = IpcResponse::SubscriptionPull(SubscriptionPullResponse {
3771 events: Vec::new(),
3772 liveness: vec![SourceLiveness {
3773 bucket_id: BucketId::new(),
3774 job_id: Some(JobId::new()),
3775 probe_id: None,
3776 liveness: Liveness::Exited { code: 0 },
3777 }],
3778 lagged: false,
3779 truncated: false,
3780 });
3781 let back: IpcResponse =
3782 serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
3783 match back {
3784 IpcResponse::SubscriptionPull(r) => {
3785 assert!(r.events.is_empty());
3786 assert_eq!(r.liveness.len(), 1);
3787 assert_eq!(r.liveness[0].liveness, Liveness::Exited { code: 0 });
3788 }
3789 other => panic!("unexpected: {other:?}"),
3790 }
3791 }
3792
3793 #[test]
3794 fn subscription_list_pair_round_trips_through_request_and_response() {
3795 let req = IpcRequest::SubscriptionList(SubscriptionListParams { limit: Some(10) });
3796 let back: IpcRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
3797 assert!(matches!(back, IpcRequest::SubscriptionList(_)));
3798
3799 let resp = IpcResponse::SubscriptionList(SubscriptionListResponse {
3800 subscriptions: vec![SubscriptionSummary {
3801 sub_id: "sub-1".to_owned(),
3802 predicate_hash: "9".to_owned(),
3803 source_count: 2,
3804 created_at_ms: 1,
3805 last_pull_at_ms: Some(2),
3806 }],
3807 truncated: true,
3808 });
3809 let back: IpcResponse =
3810 serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
3811 match back {
3812 IpcResponse::SubscriptionList(r) => {
3813 assert!(r.truncated);
3814 assert_eq!(r.subscriptions.len(), 1);
3815 assert_eq!(r.subscriptions[0].source_count, 2);
3816 }
3817 other => panic!("unexpected: {other:?}"),
3818 }
3819 }
3820
3821 #[test]
3822 fn subscription_close_pair_round_trips_through_request_and_response() {
3823 let req = IpcRequest::SubscriptionClose(SubscriptionCloseParams {
3824 sub_id: "sub-1".to_owned(),
3825 });
3826 let back: IpcRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
3827 assert!(matches!(back, IpcRequest::SubscriptionClose(_)));
3828
3829 let resp = IpcResponse::SubscriptionClose(SubscriptionCloseResponse { closed: true });
3830 let back: IpcResponse =
3831 serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
3832 match back {
3833 IpcResponse::SubscriptionClose(r) => assert!(r.closed),
3834 other => panic!("unexpected: {other:?}"),
3835 }
3836 }
3837
3838 #[test]
3839 fn subscription_seek_pair_round_trips_through_request_and_response() {
3840 let req = IpcRequest::SubscriptionSeek(SubscriptionSeekParams {
3841 sub_id: "sub-1".to_owned(),
3842 bucket_id: BucketId::new(),
3843 seq: 42,
3844 });
3845 let back: IpcRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
3846 match back {
3847 IpcRequest::SubscriptionSeek(p) => assert_eq!(p.seq, 42),
3848 other => panic!("unexpected: {other:?}"),
3849 }
3850
3851 let resp = IpcResponse::SubscriptionSeek(SubscriptionSeekResponse {
3852 clamped_seq: 7,
3853 lagged: true,
3854 });
3855 let back: IpcResponse =
3856 serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
3857 match back {
3858 IpcResponse::SubscriptionSeek(r) => {
3859 assert_eq!(r.clamped_seq, 7);
3860 assert!(r.lagged);
3861 }
3862 other => panic!("unexpected: {other:?}"),
3863 }
3864 }
3865
3866 #[test]
3867 fn command_output_tail_params_round_trip() {
3868 let params = CommandOutputTailParams {
3869 job_id: JobId::new(),
3870 max_lines: 50,
3871 max_bytes: 65_536,
3872 strip_ansi: true,
3873 };
3874 let json = serde_json::to_string(¶ms).unwrap();
3875 let back: CommandOutputTailParams = serde_json::from_str(&json).unwrap();
3876 assert_eq!(back, params);
3877 // defaults kick in when fields are absent
3878 let minimal = format!(r#"{{"job_id":"{}"}}"#, params.job_id);
3879 let def: CommandOutputTailParams = serde_json::from_str(&minimal).unwrap();
3880 assert_eq!(def.max_lines, 50);
3881 assert_eq!(def.max_bytes, 65_536);
3882 assert!(!def.strip_ansi);
3883 }
3884
3885 #[test]
3886 fn response_envelope_err_round_trips() {
3887 let env = ResponseEnvelope {
3888 correlation_id: 7,
3889 result: IpcResult::Err {
3890 error: IpcError::new(IpcErrorCode::PolicyDenied, "nope"),
3891 },
3892 };
3893 let frame = encode_frame(&env).unwrap();
3894 let back: ResponseEnvelope = decode_payload(&frame[4..]).unwrap();
3895 match back.result {
3896 IpcResult::Err { error } => {
3897 assert_eq!(error.code, IpcErrorCode::PolicyDenied);
3898 assert_eq!(error.message, "nope");
3899 }
3900 IpcResult::Ok { .. } => panic!("expected err variant"),
3901 }
3902 }
3903
3904 #[test]
3905 fn shutdown_variants_serde_roundtrip() {
3906 // Request round-trips. `IpcRequest` does not derive `PartialEq`,
3907 // so compare the serialized forms instead of the values.
3908 let req = IpcRequest::Shutdown;
3909 let s = serde_json::to_string(&req).unwrap();
3910 let s2 = serde_json::to_string(&serde_json::from_str::<IpcRequest>(&s).unwrap()).unwrap();
3911 assert_eq!(s, s2);
3912
3913 // Response round-trips with the draining flag.
3914 let resp = IpcResponse::ShutdownAck { draining: true };
3915 let s = serde_json::to_string(&resp).unwrap();
3916 match serde_json::from_str::<IpcResponse>(&s).unwrap() {
3917 IpcResponse::ShutdownAck { draining } => assert!(draining),
3918 other => panic!("unexpected: {other:?}"),
3919 }
3920
3921 // The new error code serializes.
3922 let _ = serde_json::to_string(&IpcErrorCode::ShuttingDown).unwrap();
3923 }
3924
3925 #[test]
3926 fn audit_since_params_round_trip() {
3927 let full = AuditSinceParams {
3928 cursor: 7,
3929 action_filter: Some("registry_activate".to_owned()),
3930 decision_filter: Some("info".to_owned()),
3931 limit: Some(50),
3932 };
3933 let json = serde_json::to_string(&full).unwrap();
3934 let back: AuditSinceParams = serde_json::from_str(&json).unwrap();
3935 assert_eq!(back, full);
3936
3937 // Optional fields default to None and are omitted on the wire.
3938 let minimal = AuditSinceParams {
3939 cursor: 0,
3940 action_filter: None,
3941 decision_filter: None,
3942 limit: None,
3943 };
3944 let json = serde_json::to_string(&minimal).unwrap();
3945 assert_eq!(json, r#"{"cursor":0}"#);
3946 let back: AuditSinceParams = serde_json::from_str(&json).unwrap();
3947 assert_eq!(back, minimal);
3948 }
3949
3950 #[test]
3951 fn audit_since_response_round_trips_through_envelope() {
3952 let resp = AuditSinceResponse {
3953 cursor_in: 0,
3954 next_cursor: 2,
3955 rows: vec![
3956 AuditRowWire {
3957 audit_id: 1,
3958 timestamp: "2026-06-01T00:00:00Z".to_owned(),
3959 action: "registry_activate".to_owned(),
3960 subject: "peer".to_owned(),
3961 decision: "info".to_owned(),
3962 profile: Some("developer_local".to_owned()),
3963 reason: None,
3964 actor: Some("cli".to_owned()),
3965 metadata_json: None,
3966 },
3967 AuditRowWire {
3968 audit_id: 2,
3969 timestamp: "2026-06-01T00:00:01Z".to_owned(),
3970 action: "system_discover".to_owned(),
3971 subject: "peer".to_owned(),
3972 decision: "info".to_owned(),
3973 profile: None,
3974 reason: None,
3975 actor: None,
3976 metadata_json: None,
3977 },
3978 ],
3979 };
3980 let env = ResponseEnvelope {
3981 correlation_id: 9,
3982 result: IpcResult::Ok {
3983 response: IpcResponse::AuditSince(resp.clone()),
3984 },
3985 };
3986 let frame = encode_frame(&env).unwrap();
3987 let back: ResponseEnvelope = decode_payload(&frame[4..]).unwrap();
3988 match back.result {
3989 IpcResult::Ok {
3990 response: IpcResponse::AuditSince(r),
3991 } => assert_eq!(r, resp),
3992 other => panic!("unexpected: {other:?}"),
3993 }
3994 }
3995
3996 #[test]
3997 fn audit_since_request_round_trips_through_envelope() {
3998 let req = RequestEnvelope {
3999 correlation_id: 3,
4000 request: IpcRequest::AuditSince(AuditSinceParams {
4001 cursor: 0,
4002 action_filter: None,
4003 decision_filter: None,
4004 limit: Some(50),
4005 }),
4006 };
4007 let frame = encode_frame(&req).unwrap();
4008 let back: RequestEnvelope = decode_payload(&frame[4..]).unwrap();
4009 match back.request {
4010 IpcRequest::AuditSince(p) => {
4011 assert_eq!(p.cursor, 0);
4012 assert_eq!(p.limit, Some(50));
4013 }
4014 other => panic!("unexpected: {other:?}"),
4015 }
4016 }
4017}