aion_core/cluster_event.rs
1//! Cluster topology and ownership events for the ops console real-time channel (WS3).
2//!
3//! This module defines the *typed contract* for the second class of real-time push the ops console
4//! consumes: cluster/topology/ownership deltas that today exist only as `tracing` logs and
5//! Prometheus gauges. The wire shapes live in `aion-core` (not `aion-server`) for one reason: only
6//! this leaf crate depends on `ts-rs`, so this is the single place a Rust type can cross the
7//! Rust -> TypeScript boundary into the ops console's generated bindings.
8//!
9//! # Scope (WS3 FOUNDATION)
10//!
11//! This file is *types only*. It deliberately does **not** define the broadcast publisher, the
12//! emit call-sites in the supervisor / registry, the subscription endpoint, or the namespace gate.
13//! Those are wired in later WS3 increments. Defining the contract first lets the ops console and the
14//! server agree on shapes before any behaviour ships.
15//!
16//! # Honesty corrections applied to the original design
17//!
18//! The design table named several variants whose payloads cannot be sourced from the subsystems
19//! that would emit them today. Rather than ship a type whose fields are guaranteed to be faked,
20//! those variants are **descoped from Phase 1** and documented here so the omission is explicit and
21//! not silently rediscovered at wiring time:
22//!
23//! - **`NodeMetricsSampled` — DEFERRED.** `aion-server`'s metrics (`observability/metrics.rs`) have
24//! no `node` dimension (`connected_workers` is an `IntGaugeVec` keyed by *namespace*) and there
25//! is no `workflows_running` gauge at all. A `{ node, connected_workers, workflows_running }`
26//! payload cannot be produced by "piggybacking the gauge setters". Sourcing it honestly requires
27//! a real metrics change (node-labelled gauges + a running gauge). Until then the ops console
28//! derives `connected_workers` client-side from [`ClusterEvent::WorkerConnected`] /
29//! [`ClusterEvent::WorkerDisconnected`] deltas against the [`ClusterSnapshot`] baseline. Adding
30//! a timer that scrapes Prometheus to fill this variant is the exact polling-as-push regression
31//! WS3 exists to remove, so the variant is omitted rather than tempting that shortcut.
32//! - **`ShardOwnerChanged` / `FencedCasRejected` — DEFERRED (fast-follow).** The haematite seam
33//! (`HaematiteStore::publish_ln(shard)`) carries only `shard`; the fenced-CAS reject is a
34//! `haematite::DatabaseError::Fenced { .. }` destructured with `..` and mapped to
35//! `StoreError::NotOwner { shard }`, carrying only `shard` — never the owner name or the
36//! attempted/current epochs. The proposed payloads require enriching haematite's `Fenced` error
37//! to surface owner identity and epoch, which is a cross-crate (likely cross-repo) change. The
38//! cluster map is still *honest* without these two: shard adoption is fully observable from the
39//! supervisor's `tick()` ([`ClusterEvent::ShardAdopted`] and friends). Only the store-side
40//! CAS-reject detail is delayed.
41//!
42//! # u64 precision across the TS boundary
43//!
44//! The ts-rs config exports every `u64` as TS `number` (`with_large_int("number")` in
45//! `generated_types.rs`), which truncates above `2^53`. [`ClusterEventMeta::cluster_seq`] and the
46//! epoch fields below are `u64`. This is the *same* accepted ceiling that already applies to
47//! [`crate::EventEnvelope::seq`]; cluster sequencing follows the established project convention
48//! rather than introducing a divergent string encoding. The ceiling is documented on each field so
49//! the gap-detection math on the client is aware of the bound. A long-lived deployment must keep
50//! `cluster_seq` below `2^53`; in practice this is never reached for a topology-event counter.
51
52use chrono::{DateTime, Utc};
53use serde::{Deserialize, Serialize};
54
55/// Metadata stamped on every [`ClusterEvent`], mirroring [`crate::EventEnvelope`] for the cluster
56/// channel.
57#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
58pub struct ClusterEventMeta {
59 /// Monotonic sequence number assigned by the cluster publisher (a single deployment-global
60 /// `AtomicU64`). The client uses this for gap detection and `after_seq` reconnect dedup, the
61 /// cluster analog of [`crate::EventEnvelope::seq`].
62 ///
63 /// Exported to TypeScript as `number`; see the module docs for the accepted `2^53` ceiling.
64 pub cluster_seq: u64,
65 /// UTC wall-clock instant at which the originating subsystem observed the state change.
66 pub observed_at: DateTime<Utc>,
67}
68
69/// Transport a connected worker is delivered to over.
70///
71/// Mirrors the live `aion_server::worker::registry::WorkerDelivery` discriminants without carrying
72/// the (non-serializable) delivery channels, so it can cross the wire and the ts-rs boundary.
73#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
74#[serde(tag = "transport")]
75pub enum WorkerTransport {
76 /// gRPC bidirectional-stream delivery (the default transport).
77 Grpc,
78 /// Liminal server-push delivery (feature-gated on the server).
79 Liminal,
80}
81
82/// Why a worker left the connected set.
83///
84/// NOTE (wiring honesty): the single registry deregistration site
85/// (`ConnectedWorkerRegistry::deregister` / `remove_worker`) does not today distinguish a transport
86/// disconnect from an idle timeout from an explicit deregister. The emit increment MUST derive the
87/// reason at the call site from real signal (e.g. a closed delivery channel vs an explicit
88/// deregister RPC vs a liveness-timeout sweep) or collapse to the variant it can actually prove.
89/// This enum defines the *contract*; it must not be populated with a fabricated distinction.
90#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
91#[serde(tag = "reason")]
92pub enum WorkerDeathReason {
93 /// The worker's delivery transport dropped (stream/connection closed).
94 Disconnect,
95 /// The worker was removed by a liveness/idle-timeout sweep.
96 Timeout,
97 /// The worker explicitly deregistered.
98 Deregistered,
99}
100
101/// Operator-requested lifecycle state for a durable worker deployment.
102#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
103#[serde(rename_all = "kebab-case")]
104pub enum DesiredState {
105 /// The deployment should run.
106 Running,
107 /// The deployment should remain stopped.
108 Stopped,
109}
110
111impl DesiredState {
112 /// Stable human-readable state token.
113 #[must_use]
114 pub const fn token(self) -> &'static str {
115 match self {
116 Self::Running => "running",
117 Self::Stopped => "stopped",
118 }
119 }
120}
121
122/// Create-or-replace result exposed on the cluster and HTTP wires.
123#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
124#[serde(rename_all = "kebab-case")]
125pub enum PutOutcome {
126 /// No record previously existed for the key.
127 Created,
128 /// A record already existed for the key and was replaced.
129 Replaced,
130}
131
132/// Deployment-record lookup state captured when a worker registers.
133#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
134#[serde(rename_all = "kebab-case")]
135pub enum DeploymentAssociation {
136 /// The named deployment record existed.
137 Known,
138 /// The deployment store was checked and the named record was absent.
139 Absent,
140 /// No deployment store was attached, so existence was not checked.
141 Unchecked,
142}
143
144/// A cluster/topology/ownership delta pushed over the ops console real-time channel.
145///
146/// Tagged union on `type` to match the existing [`crate::Event`] wire shape. Every variant carries
147/// [`ClusterEventMeta`] as `meta`. Cluster events are deployment-scoped, not namespace-stamped at
148/// the envelope level; the `Worker*` variants carry their own `namespaces` so the server-side gate
149/// can intersect them against the caller's grants (see the deferred `cluster_filter`).
150#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
151#[serde(tag = "type")]
152pub enum ClusterEvent {
153 /// A peer was added to this node's watch set (cluster-membership grew mid-session).
154 ///
155 /// Distinct from [`Self::PeerConnected`]: this is a *topology* change (a new peer to watch),
156 /// not a *liveness* transition. Without it, a peer that joins after the priming
157 /// [`ClusterSnapshot`] would silently never appear on the map until the next snapshot.
158 PeerAdded {
159 /// Cluster-event metadata.
160 meta: ClusterEventMeta,
161 /// The newly-watched peer's node name.
162 peer_name: String,
163 /// The peer's forwarding address, when known.
164 forward_addr: Option<String>,
165 },
166 /// A watched peer transitioned from down/unknown to connected (a liveness recovery).
167 ///
168 /// EMIT NOTE: the supervisor `tick()` resets `consecutive_down`/`adopted` on the same tick it
169 /// observes `connected`, so the recovery condition (`was_down = consecutive_down > 0 ||
170 /// adopted`) MUST be captured *before* that reset and the emit driven by the captured value.
171 /// Emitting after the reset produces no recovery events (every tick looks freshly connected).
172 PeerConnected {
173 /// Cluster-event metadata.
174 meta: ClusterEventMeta,
175 /// The recovered peer's node name.
176 peer_name: String,
177 /// The peer's forwarding address, when known.
178 forward_addr: Option<String>,
179 },
180 /// A watched peer was observed down on a supervisor tick.
181 ///
182 /// Emitted on every tick the peer is observed down; `confirmed` flips to `true` once
183 /// `consecutive_down >= confirmations` (the debounce threshold that authorizes adoption).
184 PeerDisconnected {
185 /// Cluster-event metadata.
186 meta: ClusterEventMeta,
187 /// The down peer's node name.
188 peer_name: String,
189 /// Consecutive ticks this peer has been observed down.
190 consecutive_down: u32,
191 /// Whether the debounce threshold has been crossed (adoption-eligible).
192 confirmed: bool,
193 },
194 /// This node adopted shards previously owned by a failed peer.
195 ShardAdopted {
196 /// Cluster-event metadata.
197 meta: ClusterEventMeta,
198 /// Shard indices adopted in this transition.
199 shards: Vec<usize>,
200 /// The peer the shards were adopted from.
201 from_peer: String,
202 /// This node's name (the new owner).
203 adopted_by: String,
204 },
205 /// An adoption attempt failed and will be retried on a subsequent tick.
206 ///
207 /// HONESTY: the original design hardcoded `will_retry: true`. That is a silent lie — a peer
208 /// later observed connected resets `adopted` and stops retrying, and the
209 /// "handled elsewhere" path terminally stops. The retry decision is not expressible as a
210 /// constant, so the field is omitted; the ops console infers retry by observing whether a
211 /// subsequent [`Self::ShardAdopted`] / [`Self::ShardAdoptionSkipped`] for the same
212 /// `(from_peer, shards)` arrives, rather than trusting a promise that may never be kept
213 /// (ADR-016 no-silent-failure).
214 ShardAdoptionFailed {
215 /// Cluster-event metadata.
216 meta: ClusterEventMeta,
217 /// Shard indices the failed attempt targeted.
218 shards: Vec<usize>,
219 /// The peer the shards would have been adopted from.
220 from_peer: String,
221 /// Human-readable adoption error.
222 error: String,
223 },
224 /// Adoption was skipped because the shards are already held by a live third-party owner.
225 ShardAdoptionSkipped {
226 /// Cluster-event metadata.
227 meta: ClusterEventMeta,
228 /// Shard indices that were skipped.
229 shards: Vec<usize>,
230 /// The peer the shards would have been adopted from.
231 from_peer: String,
232 /// The live node currently holding the shards.
233 held_by: String,
234 },
235 /// A worker joined the connected set.
236 WorkerConnected {
237 /// Cluster-event metadata.
238 meta: ClusterEventMeta,
239 /// Stable worker identifier.
240 worker_id: String,
241 /// Namespaces this worker serves.
242 namespaces: Vec<String>,
243 /// Task-queue pool this worker serves within its namespaces.
244 task_queue: String,
245 /// Delivery transport for this worker.
246 transport: WorkerTransport,
247 /// Locality/node label, when the worker reported one.
248 node: Option<String>,
249 /// Deployment named by the worker's instance identity, when supplied.
250 deployment: Option<String>,
251 /// Lookup state for the named deployment. `None` means the worker
252 /// supplied no instance identity.
253 deployment_association: Option<DeploymentAssociation>,
254 },
255 /// A worker left the connected set.
256 WorkerDisconnected {
257 /// Cluster-event metadata.
258 meta: ClusterEventMeta,
259 /// Stable worker identifier.
260 worker_id: String,
261 /// Namespaces this worker served.
262 namespaces: Vec<String>,
263 /// Why the worker left (see [`WorkerDeathReason`] wiring note).
264 reason: WorkerDeathReason,
265 },
266 /// The cluster supervisor started on this node (lifecycle).
267 ///
268 /// Lets the calm-state view (ADR-019) distinguish "supervisor running, all peers healthy" from
269 /// "supervisor not running".
270 SupervisorStarted {
271 /// Cluster-event metadata.
272 meta: ClusterEventMeta,
273 /// This node's name.
274 node: String,
275 },
276 /// The cluster supervisor stopped on this node (clean drain / shutdown).
277 SupervisorStopped {
278 /// Cluster-event metadata.
279 meta: ClusterEventMeta,
280 /// This node's name.
281 node: String,
282 },
283 /// A brand-new namespace was minted in the durable registry (Control-Plane
284 /// Phase 1, S8).
285 ///
286 /// Emitted exactly once per genuinely-new namespace at the registry's single
287 /// `MintOutcome::Created` choke-point — so the worker-register seam (S5), the
288 /// workflow-start safety net (S6), and the explicit `POST /namespaces` path
289 /// (S7) all surface the same delta. An idempotent re-reference of an existing
290 /// namespace (`MintOutcome::AlreadyExisted`) never emits this, so the ops
291 /// console appends each namespace exactly once with no refresh.
292 ///
293 /// `origin` is the stable `snake_case` label (`worker_mint` / `start_mint` /
294 /// `explicit` / `inferred_from_state`) rather than the `aion-store`
295 /// `NamespaceOrigin` enum, because this leaf crate must not depend on the
296 /// store crate; the label is the same string the audit `tracing` event logs.
297 /// `created_at` is the durable record's first-mint instant, carried so the
298 /// console's created column matches the registry without a follow-up fetch.
299 NamespaceCreated {
300 /// Cluster-event metadata.
301 meta: ClusterEventMeta,
302 /// The minted namespace's name (registry primary key).
303 name: String,
304 /// The durable record's creation instant (its `created_at`).
305 created_at: DateTime<Utc>,
306 /// How the namespace came to exist, as the stable `snake_case` label.
307 origin: String,
308 },
309 /// A namespace's durable placement directive was changed (Control-Plane
310 /// Phase 2, P2-P2 — `PUT /namespaces/{name}/placement`).
311 ///
312 /// Emitted on the SAME deploy-scoped channel as [`Self::NamespaceCreated`]
313 /// after the placement is durably set, so the ops console's namespace panel
314 /// reflects an operator's placement change live with no refresh. `placement`
315 /// is the stable wire projection ([`NamespacePlacementWire`]) of the durable
316 /// `NamespacePlacement`, carried as a `kind` label + label set rather than the
317 /// `aion-store` enum, because this leaf crate must not depend on the store
318 /// crate (the same discipline [`Self::NamespaceCreated`] applies to `origin`).
319 NamespacePlacementChanged {
320 /// Cluster-event metadata.
321 meta: ClusterEventMeta,
322 /// The namespace whose placement changed (registry primary key).
323 name: String,
324 /// The new placement directive, as the stable wire projection.
325 placement: NamespacePlacementWire,
326 },
327 /// A periodic snapshot of a namespace's concurrency-quota state (Control-Plane
328 /// Phase 2, P2-Q3), pushed on the SAME deploy-scoped channel as
329 /// [`Self::NamespaceCreated`] so the ops console renders a live "in-flight /
330 /// ceiling" badge that ticks as work flows.
331 ///
332 /// This is the ONE edge-triggered exception's honest counterweight: unlike the
333 /// other variants (each emitted at a real subsystem mutation), quota state
334 /// changes on every claim/settle, so per-row emission would be a firehose.
335 /// Instead a throttled snapshot task samples each active namespace's REAL
336 /// durable state once per cadence and emits this — a periodic snapshot per
337 /// active namespace, never a client poll. The console folds the latest snapshot
338 /// per namespace, superseding the prior value.
339 ///
340 /// `in_flight` is the durable **Claimed** outbox-row count for the namespace
341 /// (`count_claimed_outbox_rows`) — the SAME notion the keyed backpressure caps,
342 /// never the dead `inflight_activities` gauge (P2-Q0). `ceiling` is the tenant's
343 /// **cluster-wide** contract: its explicit `max_in_flight_activities` override
344 /// or the platform default — the number the operator set, not the per-node
345 /// proportional slice (exposing per-node math is the leaky-abstraction footgun
346 /// §3.6 rejects). On a single node the durable `in_flight` this node sees is the
347 /// whole cluster's; multi-node exact aggregation is the reserved §3.6 follow-up.
348 NamespaceQuotaState {
349 /// Cluster-event metadata.
350 meta: ClusterEventMeta,
351 /// The namespace this quota snapshot describes (registry primary key).
352 namespace: String,
353 /// Durable count of currently-**Claimed** outbox rows for the namespace —
354 /// the in-flight activities the ceiling caps.
355 ///
356 /// Exported to TypeScript as `number`; see the module docs for the accepted
357 /// `2^53` ceiling (an in-flight count never approaches it).
358 in_flight: u64,
359 /// The tenant's cluster-wide concurrency ceiling: its explicit
360 /// `max_in_flight_activities` override, or the platform default when unset.
361 ceiling: u32,
362 },
363 /// A worker deployment was durably created or replaced by an operator.
364 WorkerDeploymentPut {
365 /// Cluster-event metadata.
366 meta: ClusterEventMeta,
367 /// Durable deployment name.
368 name: String,
369 /// Typed create-or-replace outcome.
370 outcome: PutOutcome,
371 /// Typed desired state.
372 desired_state: DesiredState,
373 /// Version captured from the deployed server binary.
374 binary_version: String,
375 /// SHA-256 captured from the deployed server executable bytes.
376 binary_content_hash: String,
377 },
378 /// An operator durably changed a deployment's desired state.
379 WorkerDeploymentDesiredStateChanged {
380 /// Cluster-event metadata.
381 meta: ClusterEventMeta,
382 /// Durable deployment name.
383 name: String,
384 /// Typed desired state.
385 desired_state: DesiredState,
386 },
387 /// An operator durably deleted a deployment record.
388 WorkerDeploymentDeleted {
389 /// Cluster-event metadata.
390 meta: ClusterEventMeta,
391 /// Deleted deployment name.
392 name: String,
393 },
394}
395
396/// Stable wire projection of a namespace's durable placement directive
397/// (`NamespacePlacement` in `aion-store`), for the ops-console real-time channel
398/// (Control-Plane Phase 2).
399///
400/// Lives in this leaf crate (not `aion-server` / `aion-store`) for the same reason
401/// the rest of [`ClusterEvent`] does: only this crate crosses the Rust ->
402/// TypeScript boundary via `ts-rs`. `kind` is the stable `snake_case` variant tag
403/// (`unplaced` / `prefer` / `pinned`) and `nodes` is the (possibly empty)
404/// node-label set; `Unplaced` carries an empty `nodes`. Modelling it as a flat
405/// `{kind, nodes}` shape rather than re-encoding the store's tagged form keeps the
406/// generated TS binding a single simple type the console can switch on.
407#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
408pub struct NamespacePlacementWire {
409 /// Stable `snake_case` placement-kind tag: `unplaced` / `prefer` / `pinned`.
410 pub kind: String,
411 /// The placement's node-label set, deterministically ordered. Empty for
412 /// `unplaced`; the preferred/required labels for `prefer`/`pinned`.
413 pub nodes: Vec<String>,
414}
415
416/// A peer entry in the priming [`ClusterSnapshot`].
417#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
418pub struct ClusterPeer {
419 /// The peer's node name.
420 pub peer_name: String,
421 /// The peer's forwarding address, when known.
422 pub forward_addr: Option<String>,
423 /// Whether the peer is currently observed connected.
424 pub connected: bool,
425 /// Consecutive ticks observed down (0 when connected).
426 pub consecutive_down: u32,
427}
428
429/// A shard ownership entry in the priming [`ClusterSnapshot`].
430#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
431pub struct ClusterShard {
432 /// Shard index.
433 pub shard: usize,
434 /// The node that currently owns the shard.
435 pub owner: String,
436 /// The epoch fence value at which the owner holds the shard.
437 ///
438 /// Exported to TypeScript as `number`; see the module docs for the accepted `2^53` ceiling.
439 pub epoch: u64,
440}
441
442/// A connected-worker entry in the priming [`ClusterSnapshot`].
443#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
444pub struct ClusterWorker {
445 /// Stable worker identifier.
446 pub worker_id: String,
447 /// Namespaces this worker serves (already intersected with the caller's grants by the gate).
448 pub namespaces: Vec<String>,
449 /// Task-queue pool this worker serves.
450 pub task_queue: String,
451 /// Delivery transport for this worker.
452 pub transport: WorkerTransport,
453 /// Locality/node label, when reported.
454 pub node: Option<String>,
455 /// Deployment named by the worker's instance identity, when supplied.
456 pub deployment: Option<String>,
457 /// Lookup state for that deployment. `None` for workers without instance
458 /// identity.
459 pub deployment_association: Option<DeploymentAssociation>,
460}
461
462/// A durable worker-deployment entry in the priming cluster snapshot.
463#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
464pub struct ClusterDeployment {
465 /// Durable deployment name.
466 pub name: String,
467 /// Typed desired state.
468 pub desired_state: DesiredState,
469 /// Version captured at deploy time.
470 pub binary_version: String,
471 /// SHA-256 captured at deploy time.
472 pub binary_content_hash: String,
473}
474
475/// A calm-state baseline of the whole cluster, sent as the priming reply before the live delta
476/// stream so the ops console can render an at-a-glance "all clear" before any [`ClusterEvent`]
477/// arrives (ADR-019). On `cluster_lagged` the client re-requests this rather than replaying a
478/// (non-durable) delta history.
479#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
480pub struct ClusterSnapshot {
481 /// The reading node's own name (self-identity; baselines the `Peer*` deltas which describe
482 /// *other* nodes).
483 pub node: String,
484 /// The `cluster_seq` this snapshot is consistent as-of; the client applies only deltas with a
485 /// strictly greater `cluster_seq`.
486 ///
487 /// Exported to TypeScript as `number`; see the module docs for the accepted `2^53` ceiling.
488 pub as_of_seq: u64,
489 /// Watched peers and their current liveness.
490 pub peers: Vec<ClusterPeer>,
491 /// Shards owned by (or visible to) the reading node.
492 pub shards: Vec<ClusterShard>,
493 /// Connected workers, already gated to the caller's namespaces.
494 pub workers: Vec<ClusterWorker>,
495 /// Durable worker deployments visible under the deploy grant.
496 pub deployments: Vec<ClusterDeployment>,
497}
498
499/// A command the ops console can issue against the cluster channel (ADR-020 command seam).
500///
501/// Defined now for contract coherence. **Phase 1 ships only [`Self::RequestClusterSnapshot`]** (a
502/// read). The mutating variants compile so the contract exists, but their handlers reject with an
503/// `unimplemented` wire error — and, per ADR-020, MUST still run the full auth gate
504/// (`caller.deploy_granted()`) *before* rejecting, so the seam's authorization contract is
505/// exercised now and an `unimplemented` stub is never an auth-bypass-shaped hole.
506///
507/// Tagged on `command` (distinct from [`ClusterEvent`]'s `type`) because commands and events are
508/// different directions on the wire; the ops console's protocol parser keys command frames on
509/// `command` and event frames on `type`.
510#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
511#[serde(tag = "command")]
512pub enum ClusterCommand {
513 /// Read the current cluster baseline (peers + shards + workers). Read-only; needs only
514 /// namespace-or-deploy scope. **Phase 1.**
515 RequestClusterSnapshot {},
516 /// Cancel a running workflow. Aspirational — handler returns `unimplemented`.
517 CancelWorkflow {
518 /// Owning namespace.
519 namespace: String,
520 /// Target workflow.
521 workflow_id: String,
522 },
523 /// Reopen a failed/closed workflow. Aspirational — handler returns `unimplemented`.
524 ReopenWorkflow {
525 /// Owning namespace.
526 namespace: String,
527 /// Target workflow.
528 workflow_id: String,
529 },
530 /// Redrive a single outbox row. Aspirational — handler returns `unimplemented`.
531 RedriveOutboxRow {
532 /// Owning namespace.
533 namespace: String,
534 /// Target workflow.
535 workflow_id: String,
536 /// Outbox row ordinal.
537 ordinal: u64,
538 },
539 /// Drain a node (stop-new-work + finish-in-flight + safe shutdown). Aspirational.
540 DrainNode {
541 /// Target node name.
542 node: String,
543 },
544 /// Planned epoch-fenced shard handoff to a target node. Aspirational.
545 PlannedHandoff {
546 /// Shard to move.
547 shard: usize,
548 /// Destination node.
549 target_node: String,
550 },
551 /// Test-only chaos kill of a node. Aspirational (gated).
552 ChaosKillNode {
553 /// Target node name.
554 node: String,
555 },
556}
557
558/// A typed terminal error on the cluster channel.
559///
560/// Mirrors the workflow path's lagged contract: when a subscriber falls behind the bounded cluster
561/// broadcast buffer the server sends exactly one of these and closes, carrying the skipped count so
562/// the client can decide snapshot-vs-resume (it always re-requests a [`ClusterSnapshot`], since
563/// there is no durable cluster history). Surfaced to the UI as a typed error, never silently
564/// dropped (ADR-016).
565#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
566#[serde(tag = "kind")]
567pub enum ClusterStreamError {
568 /// The subscriber lagged past the bounded broadcast buffer; `skipped` deltas were dropped.
569 ClusterLagged {
570 /// Number of cluster events dropped because the subscriber fell behind.
571 ///
572 /// Exported to TypeScript as `number`; see the module docs for the accepted `2^53` ceiling.
573 skipped: u64,
574 },
575}