kanade_shared/subject.rs
1/// Most dot-separated labels a pc_id may carry. A dotted pc_id (an OS
2/// hostname such as `host.example.com`) becomes several NATS subject
3/// tokens, and the role-level permission block in
4/// `configs/nats-server.users.conf` can only enumerate that many label
5/// counts where the pc_id sits in the middle of a subject. Keep the two in
6/// step: the conformance test asserts the config against this constant.
7pub const MAX_PC_ID_LABELS: usize = 4;
8
9/// Reject a pc_id the role-level NATS permissions cannot cover. The only
10/// refusal is "too many labels": every other pc_id that was valid before
11/// stays valid.
12pub fn validate_pc_id(pc_id: &str) -> Result<(), String> {
13 let labels = pc_id.split('.').count();
14 if labels > MAX_PC_ID_LABELS {
15 return Err(format!(
16 "pc_id {pc_id:?} has {labels} dot-separated labels; at most {MAX_PC_ID_LABELS} are supported"
17 ));
18 }
19 Ok(())
20}
21
22pub const COMMANDS_ALL: &str = "commands.all";
23
24pub fn commands_group(name: &str) -> String {
25 format!("commands.group.{name}")
26}
27
28pub fn commands_pc(pc_id: &str) -> String {
29 format!("commands.pc.{pc_id}")
30}
31
32/// `notifications.all` — broadcast end-user notification (SPEC
33/// §2.2.1 / Phase E). Mirrors [`COMMANDS_ALL`] but on the
34/// notification fan-out plane: the backend publishes here, the
35/// `NOTIFICATIONS` stream retains it, and every agent forwards it to
36/// the Client Apps in matching user sessions.
37pub const NOTIFICATIONS_ALL: &str = "notifications.all";
38
39/// Subject prefix for [`notifications_group`]. Exposed so callers that
40/// parse a subject back into its group name (the backend's notification
41/// audience resolver) strip against the same string the builder emits —
42/// if the format ever changes, both move together instead of the parser
43/// silently failing to match.
44pub const NOTIFICATIONS_GROUP_PREFIX: &str = "notifications.group.";
45
46/// Subject prefix for [`notifications_pc`]. See
47/// [`NOTIFICATIONS_GROUP_PREFIX`].
48pub const NOTIFICATIONS_PC_PREFIX: &str = "notifications.pc.";
49
50/// `notifications.group.{group_name}` — group-scoped end-user
51/// notification. Sibling of [`commands_group`] on the notification
52/// plane.
53pub fn notifications_group(name: &str) -> String {
54 format!("{NOTIFICATIONS_GROUP_PREFIX}{name}")
55}
56
57/// `notifications.pc.{pc_id}` — single-PC end-user notification.
58/// Sibling of [`commands_pc`] on the notification plane.
59pub fn notifications_pc(pc_id: &str) -> String {
60 format!("{NOTIFICATIONS_PC_PREFIX}{pc_id}")
61}
62
63/// `notif-amend` — ephemeral fleet-wide control channel for post-send
64/// operations on a notification (currently: recall). Deliberately NOT
65/// under `notifications.>` so the `NOTIFICATIONS` JetStream stream doesn't
66/// retain it (these are live control pushes, not history). Published via
67/// **core NATS** (fire-and-forget); every agent subscribes and forwards a
68/// `notifications.amended` push to its clients, each of which applies it
69/// only if it holds the referenced id — so a single broadcast needs no
70/// per-audience routing (an id a client doesn't have is a no-op). The
71/// durable half of a recall is the stream message deletion; this is just
72/// the "remove it from screens that are showing it now" half.
73pub const NOTIFICATIONS_AMEND_SUBJECT: &str = "notif-amend";
74
75/// `events.notifications.acked.{pc_id}.{user_sid}.{notif_id}` — the
76/// agent publishes this when a user clicks "確認" on a notification
77/// (SPEC §2.2.2 / Phase E). The `{user_sid}` segment distinguishes
78/// concurrent users on a shared PC (Fast User Switching / RDP). Lives
79/// under `events.>` so the existing `EVENTS` stream retains it; the
80/// backend's notification-acks projector consumes the narrowed
81/// [`EVENTS_NOTIFICATIONS_ACKED_FILTER`] to build the SPA's
82/// per-recipient confirmation view.
83///
84/// Subject spelling is fixed by SPEC §2.2.2 / §2.12.8 (`events.>`), so
85/// acks ride the `EVENTS` stream's retention (shorter than the 90-day
86/// `NOTIFICATIONS` history), not the notification stream's. That only
87/// bounds **re-projection** after a `-WipeDb`: the durable source of
88/// truth for ack_status is the `notification_acks` SQLite table, which
89/// persists independently — so a live fleet keeps full ack history;
90/// only a DB wipe truncates re-derivable acks to the EVENTS window,
91/// the same limitation every `events.*`-projected table already has.
92pub fn events_notifications_acked(pc_id: &str, user_sid: &str, notif_id: &str) -> String {
93 format!("events.notifications.acked.{pc_id}.{user_sid}.{notif_id}")
94}
95
96/// `events.notifications.unacked.{pc_id}.{user_sid}.{notif_id}` — the
97/// agent publishes this when a user *retracts* a prior "確認" (the
98/// read↔unread toggle). Mirror of [`events_notifications_acked`]; the
99/// backend's notification-acks projector consumes it on the same
100/// consumer (see [`EVENTS_NOTIFICATIONS_FILTER`]) so ack and unack for
101/// one recipient stay strictly ordered on the `EVENTS` stream. Layer
102/// split: the **agent** tombstones the `notifications_read` KV (so the
103/// user's own `notifications.list` goes back to unread); this event is
104/// the **projector's** half — it stamps `unacked_at` on the read-model
105/// row and appends the revoke to the audit log. Same retention caveat as
106/// the acked subject: the durable source of truth is SQLite, the stream
107/// only bounds re-projection after a `-WipeDb`.
108pub fn events_notifications_unacked(pc_id: &str, user_sid: &str, notif_id: &str) -> String {
109 format!("events.notifications.unacked.{pc_id}.{user_sid}.{notif_id}")
110}
111
112// `commands_exec` (subject `commands.exec.<job_id>`) was removed in
113// v0.22.1. The STREAM_EXEC stream now catches the existing
114// `commands.{all,group.X,pc.Y}` subjects directly, so the dedicated
115// per-exec subject isn't needed any more. See
116// `kanade-agent::command_replay` for how reconnecting agents catch
117// up on missed messages.
118
119pub fn results(request_id: &str) -> String {
120 format!("results.{request_id}")
121}
122
123pub fn heartbeat(pc_id: &str) -> String {
124 format!("heartbeat.{pc_id}")
125}
126
127/// `host_perf.<pc_id>` — Phase 1 of the perf telemetry pipeline. The
128/// agent publishes a whole-host CPU / Memory / Disk I/O / Network
129/// snapshot here on the cadence set by `host_perf_interval` in
130/// agent_config (default 60 s). Distinct subject from `heartbeat.<pc_id>`
131/// so the periodic heartbeat publisher stays untouched and pre-host_perf
132/// backends that don't subscribe simply ignore the new traffic.
133pub fn host_perf(pc_id: &str) -> String {
134 format!("host_perf.{pc_id}")
135}
136
137/// `process_perf.<pc_id>` — Phase 2: top-N per-process snapshot
138/// published only while `process_perf_enabled` is `true` AND the
139/// `process_perf_expires_at` deadline is in the future. Separate
140/// subject from `host_perf.<pc_id>` because process-perf is an
141/// opt-in investigation mode — having its own subject lets the
142/// projector skip the heavy table entirely for hosts that never
143/// turn it on.
144pub fn process_perf(pc_id: &str) -> String {
145 format!("process_perf.{pc_id}")
146}
147
148/// `obs.<pc_id>` — per-PC observability event stream (Issue #246).
149/// The agent publishes one [`crate::wire::ObsEvent`] per timeline
150/// event (sign-in / out, power on / off, sleep / resume, agent
151/// milestones, diagnostic bundle pointers). Distinct from
152/// `events.started.*` (in-flight script lifecycle) and
153/// `host_perf.<pc_id>` (numeric telemetry) — `obs.*` is the
154/// semantic-event stream the SPA Timeline page consumes.
155pub fn obs(pc_id: &str) -> String {
156 format!("obs.{pc_id}")
157}
158
159/// `obs.>` — filter the backend projector subscribes to so a new
160/// PC starts flowing into the timeline without any per-PC SUB
161/// registration. Pairs with [`obs`] for publish.
162pub const OBS_FILTER: &str = "obs.>";
163
164/// `kill.<exec_id>` — Spec §2.6 Layer 3 abort signal. The exec_id is
165/// the deployment / scheduler-fire UUID (formerly named `job_id`
166/// pre-v0.29; renamed for accuracy — every `Command.exec_id` is a
167/// per-deploy UUID, not a job-catalog id).
168pub fn kill(exec_id: &str) -> String {
169 format!("kill.{exec_id}")
170}
171
172pub fn inventory(pc_id: &str, category: &str) -> String {
173 format!("inventory.{pc_id}.{category}")
174}
175
176/// `events.started.<exec_id>.<pc_id>` — v0.30 / PR α' lifecycle
177/// event published by the agent just before spawning a script's
178/// child process. Lets the backend project an in-flight row into
179/// `execution_results` (with `finished_at = NULL`) so the SPA
180/// Activity table can show running rows alongside finished ones.
181/// Backend subscribes via [`EVENTS_STARTED_FILTER`].
182pub fn events_started(exec_id: &str, pc_id: &str) -> String {
183 format!("events.started.{exec_id}.{pc_id}")
184}
185
186/// Wildcard the backend events projector consumes on STREAM_EVENTS.
187/// Narrow (`events.started.>`) rather than the whole `events.>` so
188/// future event types can carry their own filters without rerouting
189/// the started subset.
190pub const EVENTS_STARTED_FILTER: &str = "events.started.>";
191
192/// Wildcard the backend notification-acks projector consumes on
193/// `STREAM_EVENTS`. Narrow (`events.notifications.acked.>`) rather
194/// than the whole `events.>` so the projector only wakes for ack
195/// events and not the high-volume `events.started.*` lifecycle
196/// traffic (which the events projector handles separately).
197/// NOTE: since unack landed, the backend projector consumes the broader
198/// [`EVENTS_NOTIFICATIONS_FILTER`] (ack + unack on one ordered consumer), so
199/// this narrower `acked.>` filter is **no longer the active consumer filter**.
200/// Kept as a named constant for the ack-only subject shape (docs + tests).
201pub const EVENTS_NOTIFICATIONS_ACKED_FILTER: &str = "events.notifications.acked.>";
202
203/// Wildcard the backend notification-acks projector consumes on
204/// `STREAM_EVENTS` once it handles both ack **and** unack. Broader than
205/// [`EVENTS_NOTIFICATIONS_ACKED_FILTER`] (`acked.>`) so a single durable
206/// consumer sees both `events.notifications.acked.*` and
207/// `events.notifications.unacked.*` in stream-sequence order — the only
208/// way `ack → unack → re-ack` stays correctly serialised (a second
209/// consumer would race the DELETE past a later INSERT). Still a strict
210/// subset of `events.>` so STREAM_EVENTS retains it without a config
211/// change, and still narrower than the high-volume `events.started.*`
212/// traffic the events projector owns.
213pub const EVENTS_NOTIFICATIONS_FILTER: &str = "events.notifications.>";
214
215pub const INVENTORY_HW: &str = "hw";
216pub const INVENTORY_SW: &str = "sw";
217pub const INVENTORY_NET: &str = "net";
218
219/// `logs.fetch.<pc_id>` — request/reply: operator (or backend) sends
220/// a `LogsRequest`; the addressed agent replies with the tail of its
221/// local log file. On-demand only, no stream.
222pub fn logs_fetch(pc_id: &str) -> String {
223 format!("logs.fetch.{pc_id}")
224}
225
226/// `job.tail.<pc_id>` — request/reply for the live tail of a
227/// still-running job's stdout/stderr. The operator (or backend, on
228/// the SPA's behalf) sends a [`crate::wire::JobTailRequest`] carrying
229/// the `result_id`; the addressed agent replies with the current
230/// ring-buffer tail from its in-memory live registry. On-demand only,
231/// no stream — the SPA polls this every few seconds (same shape as
232/// `logs.fetch.<pc_id>`) while a job is in flight. Distinct subject
233/// from `logs.fetch.<pc_id>` (whole-agent log file) because this is
234/// scoped to a single job's captured output, not the agent's log.
235pub fn job_tail(pc_id: &str) -> String {
236 format!("job.tail.{pc_id}")
237}
238
239/// `agents.<pc_id>.ping` — v0.38 / #133 request/reply for the
240/// active "ping" round-trip. The agent answers with a fresh
241/// `Heartbeat` on demand instead of the backend waiting up to ~30 s
242/// for the next periodic heartbeat tick to land. Distinct subject
243/// from `heartbeat.<pc_id>` so the periodic publisher is unaffected
244/// and old agents that don't subscribe simply time the request out.
245pub fn ping(pc_id: &str) -> String {
246 format!("agents.{pc_id}.ping")
247}
248
249/// `remote.ctrl.<pc_id>` — #1140 control plane for a remote-assistance
250/// session: start, stop, retune.
251///
252/// Addressed by **pc_id, not session id**, because the agent has to already
253/// be subscribed when the first request arrives — a session-scoped control
254/// subject would have nothing listening at the moment an operator clicks
255/// "connect". Request/reply: the backend sends a [`crate::wire::RemoteCtrl`]
256/// and the agent answers with a [`crate::wire::RemoteCtrlReply`] carrying
257/// the screen geometry (so the SPA can size its canvas before the first
258/// frame lands) or a refusal reason.
259pub fn remote_ctrl(pc_id: &str) -> String {
260 format!("remote.ctrl.{pc_id}")
261}
262
263/// `remote.frame.<session_id>` — agent → backend, one encoded tile per
264/// message.
265///
266/// Session-scoped rather than pc-scoped so two operators viewing the same
267/// machine never have to demultiplex each other, and so a torn-down
268/// session's late frames land on a subject nobody reads any more.
269///
270/// **Core NATS only — deliberately not JetStream.** A frame is worthless the
271/// moment it has been displayed; retaining megabytes per second of them
272/// would cost storage and add latency for nothing. `remote.*` sits outside
273/// the `events.>` / `obs.>` / `commands.*` filters precisely so no stream
274/// catches it — the same reasoning [`NOTIFICATIONS_AMEND_SUBJECT`] records.
275///
276/// The payload is the **raw encoded image**, not JSON: tile metadata rides
277/// in NATS headers (see [`crate::wire::FrameMeta`]). Base64 inside a JSON
278/// envelope inflates every frame by a third, and #1142 measured a typical
279/// session at ~4.0 Mbps — so the envelope alone would cost ~1.3 Mbps per
280/// viewer to carry nothing.
281pub fn remote_frame(session_id: &str) -> String {
282 format!("remote.frame.{session_id}")
283}
284
285/// `remote.input.<session_id>` — backend → agent, one input event per
286/// message. JSON, unlike frames: these are small and structured, and the
287/// encoding overhead that matters for pixels is irrelevant for a mouse move.
288pub fn remote_input(session_id: &str) -> String {
289 format!("remote.input.{session_id}")
290}
291
292// v0.14: subject::inventory_request was retired alongside the
293// hardcoded inventory loop. On-demand collection now goes through
294// the normal exec path (`kanade exec configs/jobs/inventory-
295// hw.yaml`) — Command + ExecResult + the inventory-fact projector
296// give operators the same effect with no extra subject.
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn validate_pc_id_limits_label_count_only() {
304 for ok in ["pc", "a.b", "a.b.c.d", "PC 1/x?", "", "m1air.local"] {
305 assert!(validate_pc_id(ok).is_ok(), "{ok:?}");
306 }
307 let err = validate_pc_id("a.b.c.d.e").unwrap_err();
308 assert!(err.contains("at most 4"), "{err}");
309 }
310
311 #[test]
312 fn commands_all_constant() {
313 assert_eq!(COMMANDS_ALL, "commands.all");
314 }
315
316 #[test]
317 fn commands_group_formats_name() {
318 assert_eq!(commands_group("canary"), "commands.group.canary");
319 assert_eq!(commands_group("wave1"), "commands.group.wave1");
320 }
321
322 #[test]
323 fn commands_pc_formats_id() {
324 assert_eq!(commands_pc("pc-01"), "commands.pc.pc-01");
325 assert_eq!(commands_pc("PC1234"), "commands.pc.PC1234");
326 }
327
328 #[test]
329 fn notifications_all_constant() {
330 assert_eq!(NOTIFICATIONS_ALL, "notifications.all");
331 }
332
333 #[test]
334 fn notifications_group_formats_name() {
335 assert_eq!(
336 notifications_group("tokyo-office"),
337 "notifications.group.tokyo-office"
338 );
339 }
340
341 #[test]
342 fn notifications_pc_formats_id() {
343 assert_eq!(notifications_pc("PC1234"), "notifications.pc.PC1234");
344 }
345
346 #[test]
347 fn notifications_amend_subject_constant() {
348 // Pin the value: backend publisher and every agent subscriber must
349 // agree on it, and it must stay OUTSIDE `notifications.>` so the
350 // NOTIFICATIONS stream never retains these ephemeral control messages.
351 assert_eq!(NOTIFICATIONS_AMEND_SUBJECT, "notif-amend");
352 assert!(!NOTIFICATIONS_AMEND_SUBJECT.starts_with("notifications."));
353 }
354
355 #[test]
356 fn events_notifications_acked_formats_all_segments() {
357 assert_eq!(
358 events_notifications_acked("PC1234", "S-1-5-21-1001", "notif-9f3a"),
359 "events.notifications.acked.PC1234.S-1-5-21-1001.notif-9f3a"
360 );
361 }
362
363 #[test]
364 fn events_notifications_acked_filter_is_narrow_wildcard() {
365 assert_eq!(
366 EVENTS_NOTIFICATIONS_ACKED_FILTER,
367 "events.notifications.acked.>"
368 );
369 // Must stay a strict subset of the EVENTS stream's `events.>`
370 // subjects so STREAM_EVENTS retains it without a config change.
371 assert!(EVENTS_NOTIFICATIONS_ACKED_FILTER.starts_with("events."));
372 }
373
374 #[test]
375 fn events_notifications_unacked_formats_all_segments() {
376 assert_eq!(
377 events_notifications_unacked("PC1234", "S-1-5-21-1001", "notif-9f3a"),
378 "events.notifications.unacked.PC1234.S-1-5-21-1001.notif-9f3a"
379 );
380 }
381
382 #[test]
383 fn events_notifications_filter_covers_acked_and_unacked() {
384 assert_eq!(EVENTS_NOTIFICATIONS_FILTER, "events.notifications.>");
385 // The broadened filter must subsume both the acked and unacked
386 // subjects so one durable consumer serialises them in order.
387 let acked = events_notifications_acked("PC1", "S-1", "n1");
388 let unacked = events_notifications_unacked("PC1", "S-1", "n1");
389 assert!(acked.starts_with("events.notifications."));
390 assert!(unacked.starts_with("events.notifications."));
391 // Still a subset of the EVENTS stream's retained subjects.
392 assert!(EVENTS_NOTIFICATIONS_FILTER.starts_with("events."));
393 }
394
395 #[test]
396 fn remote_subjects_format_their_scope() {
397 assert_eq!(remote_ctrl("PC1234"), "remote.ctrl.PC1234");
398 assert_eq!(remote_frame("sess-9f3a"), "remote.frame.sess-9f3a");
399 assert_eq!(remote_input("sess-9f3a"), "remote.input.sess-9f3a");
400 }
401
402 #[test]
403 fn remote_subjects_stay_outside_every_retained_prefix() {
404 // The whole plane is core-NATS-only. If any of these ever started
405 // with a retained prefix, JetStream would quietly begin storing a
406 // video stream — this test is the guard on that.
407 for s in [remote_ctrl("PC1"), remote_frame("s1"), remote_input("s1")] {
408 for retained in [
409 "events.",
410 "obs.",
411 "commands.",
412 "notifications.",
413 "inventory.",
414 ] {
415 assert!(!s.starts_with(retained), "{s} collides with {retained}");
416 }
417 }
418 }
419
420 #[test]
421 fn remote_ctrl_is_pc_scoped_and_frame_is_session_scoped() {
422 // Encodes the design decision: control must be reachable before a
423 // session exists, frames must not be.
424 assert!(remote_ctrl("PC1").ends_with("PC1"));
425 assert!(remote_frame("sess-1").ends_with("sess-1"));
426 }
427
428 #[test]
429 fn results_formats_request_id() {
430 assert_eq!(results("req-1"), "results.req-1");
431 }
432
433 #[test]
434 fn heartbeat_formats_pc_id() {
435 assert_eq!(heartbeat("pc-01"), "heartbeat.pc-01");
436 }
437
438 #[test]
439 fn host_perf_formats_pc_id() {
440 assert_eq!(host_perf("pc-01"), "host_perf.pc-01");
441 assert_eq!(host_perf("PC1234"), "host_perf.PC1234");
442 }
443
444 #[test]
445 fn process_perf_formats_pc_id() {
446 assert_eq!(process_perf("pc-01"), "process_perf.pc-01");
447 assert_eq!(process_perf("PC1234"), "process_perf.PC1234");
448 }
449
450 #[test]
451 fn obs_formats_pc_id() {
452 assert_eq!(obs("pc-01"), "obs.pc-01");
453 assert_eq!(obs("PC1234"), "obs.PC1234");
454 }
455
456 #[test]
457 fn obs_filter_constant() {
458 assert_eq!(OBS_FILTER, "obs.>");
459 }
460
461 #[test]
462 fn kill_formats_exec_id() {
463 assert_eq!(kill("exec-uuid-1"), "kill.exec-uuid-1");
464 }
465
466 #[test]
467 fn logs_fetch_formats_pc_id() {
468 assert_eq!(logs_fetch("pc-01"), "logs.fetch.pc-01");
469 }
470
471 #[test]
472 fn ping_formats_pc_id() {
473 assert_eq!(ping("pc-01"), "agents.pc-01.ping");
474 }
475
476 #[test]
477 fn job_tail_formats_pc_id() {
478 assert_eq!(job_tail("pc-01"), "job.tail.pc-01");
479 assert_eq!(job_tail("PC1234"), "job.tail.PC1234");
480 }
481
482 #[test]
483 fn events_started_formats_exec_id_and_pc_id() {
484 assert_eq!(
485 events_started("exec-uuid-1", "pc-01"),
486 "events.started.exec-uuid-1.pc-01",
487 );
488 }
489
490 #[test]
491 fn events_started_filter_is_narrow_wildcard() {
492 assert_eq!(EVENTS_STARTED_FILTER, "events.started.>");
493 }
494
495 #[test]
496 fn inventory_formats_pc_id_and_category() {
497 assert_eq!(inventory("pc-01", "hw"), "inventory.pc-01.hw");
498 assert_eq!(inventory("pc-01", INVENTORY_HW), "inventory.pc-01.hw");
499 assert_eq!(inventory("pc-01", INVENTORY_SW), "inventory.pc-01.sw");
500 assert_eq!(inventory("pc-01", INVENTORY_NET), "inventory.pc-01.net");
501 }
502}