@camstack/system 1.2.217 → 1.2.219

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/addon-runner.js +9 -6
  2. package/dist/addon-runner.mjs +9 -6
  3. package/dist/addon-utils.js +2 -2
  4. package/dist/addon-utils.mjs +2 -2
  5. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  8. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  9. package/dist/builtins/alerts/alerts.addon.js +1 -1
  10. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  13. package/dist/builtins/console-logging/index.js +1 -1
  14. package/dist/builtins/console-logging/index.mjs +1 -1
  15. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  16. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  17. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  18. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  19. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  20. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  21. package/dist/builtins/hub-forwarder/index.js +1 -1
  22. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  23. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  24. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  25. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  26. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  27. package/dist/builtins/local-network/local-network.addon.js +2 -2
  28. package/dist/builtins/local-network/local-network.addon.mjs +2 -2
  29. package/dist/builtins/loki-logging/index.js +1 -1
  30. package/dist/builtins/loki-logging/index.mjs +1 -1
  31. package/dist/builtins/native-metrics/gpu-probe.d.ts +18 -0
  32. package/dist/builtins/native-metrics/native-metrics-provider.d.ts +1 -1
  33. package/dist/builtins/native-metrics/native-metrics.addon.d.ts +24 -8
  34. package/dist/builtins/native-metrics/native-metrics.addon.js +1318 -1098
  35. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1318 -1101
  36. package/dist/builtins/native-metrics/proc-process-table.d.ts +25 -0
  37. package/dist/builtins/platform-probe/index.js +3 -3
  38. package/dist/builtins/platform-probe/index.mjs +3 -3
  39. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  40. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  41. package/dist/builtins/snapshot/index.js +1 -1
  42. package/dist/builtins/snapshot/index.mjs +1 -1
  43. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  44. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  45. package/dist/builtins/sqlite-storage/sqlite-pragmas.d.ts +23 -0
  46. package/dist/builtins/sqlite-storage/sqlite-settings-backend.d.ts +1 -1
  47. package/dist/builtins/sqlite-storage/sqlite-settings.addon.d.ts +1 -0
  48. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  49. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  50. package/dist/builtins/sqlite-storage/wal-checkpoint-policy.d.ts +115 -0
  51. package/dist/builtins/sqlite-storage/wal-checkpoint-worker.d.ts +1 -0
  52. package/dist/builtins/sqlite-storage/wal-checkpointer.d.ts +70 -0
  53. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  54. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  55. package/dist/builtins/system-config/system-config.addon.js +1 -1
  56. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  57. package/dist/builtins/winston-logging/index.js +1 -1
  58. package/dist/builtins/winston-logging/index.mjs +1 -1
  59. package/dist/{custom-action-registry-F__gp_VX.mjs → custom-action-registry-B6gOtUWA.mjs} +19 -0
  60. package/dist/{custom-action-registry-jY0NOZK8.js → custom-action-registry-DKWhaWL1.js} +19 -0
  61. package/dist/{dist-C85OOASj.js → dist-B17W9ngu.js} +118 -3
  62. package/dist/{dist-DtDKMSeG.mjs → dist-Cyz5_i7z.mjs} +113 -4
  63. package/dist/index.d.ts +9 -2
  64. package/dist/index.js +39 -36
  65. package/dist/index.mjs +23 -38
  66. package/dist/kernel/array-buffer-census.d.ts +122 -0
  67. package/dist/kernel/custom-action-registry.d.ts +10 -1
  68. package/dist/kernel/heap-watch.d.ts +39 -1
  69. package/dist/kernel/moleculer/mesh-queue-report.d.ts +51 -0
  70. package/dist/kernel/outbound-queue-watch.d.ts +94 -0
  71. package/dist/kernel/socket-plane-report.d.ts +75 -4
  72. package/dist/kernel/transport/cap-routing-hints.d.ts +21 -0
  73. package/dist/kernel/transport/child-cap-protocol.d.ts +43 -3
  74. package/dist/kernel/transport/frame-codec.d.ts +49 -0
  75. package/dist/kernel/transport/local-child-client.d.ts +25 -2
  76. package/dist/kernel/transport/local-child-registry.d.ts +36 -1
  77. package/dist/kernel/transport/local-transport.d.ts +45 -2
  78. package/dist/kernel/transport/socket-channel.d.ts +19 -1
  79. package/dist/{manifest-system-deps-726RBOX7.mjs → manifest-system-deps-C7dihwWT.mjs} +1604 -788
  80. package/dist/{manifest-system-deps-DvY1bY_n.js → manifest-system-deps-g_nK0xsF.js} +1607 -683
  81. package/dist/process/proc-stat.d.ts +75 -0
  82. package/dist/process/resource-monitor.d.ts +9 -1
  83. package/dist/{resource-monitor-CdnzxBLP.js → resource-monitor-LVE1BoLs.js} +42 -23
  84. package/dist/{resource-monitor-BWmQ5i-o.mjs → resource-monitor-Vp0bPV3U.mjs} +42 -22
  85. package/dist/{retired-settings-keys-Cx-lYIbU.mjs → retired-settings-keys-BCYUrdsA.mjs} +1 -1
  86. package/dist/{retired-settings-keys-Dfj9LA-i.js → retired-settings-keys-CjvB7Etx.js} +1 -1
  87. package/dist/wal-checkpoint-policy-CAcg63o-.mjs +92 -0
  88. package/dist/wal-checkpoint-policy-CCJZngds.js +127 -0
  89. package/dist/wal-checkpoint-worker.js +138 -0
  90. package/dist/wal-checkpoint-worker.mjs +137 -0
  91. package/package.json +1 -1
  92. package/dist/{event-loop-stall-monitor-DM9OAzy8.js → event-loop-stall-monitor-CAsHdMwP.js} +1 -1
  93. package/dist/{event-loop-stall-monitor-B3CFd9tM.mjs → event-loop-stall-monitor-GPrVvx14.mjs} +1 -1
  94. package/dist/{tls-DOTmtLCW.mjs → tls-2xbgg78V.mjs} +2 -2
  95. package/dist/{tls-BxQlomxd.js → tls-BFs4PzoW.js} +2 -2
@@ -0,0 +1,122 @@
1
+ /** One power-of-two bucket of the live set: every buffer with `byteLength <= upToBytes`
2
+ * and above the previous bucket. */
3
+ export interface ArrayBufferSizeBucket {
4
+ readonly upToBytes: number;
5
+ readonly count: number;
6
+ readonly bytes: number;
7
+ }
8
+ /** One exact byteLength and how much of the live set it accounts for. */
9
+ export interface ArrayBufferExactSize {
10
+ readonly byteLength: number;
11
+ readonly count: number;
12
+ readonly bytes: number;
13
+ }
14
+ export interface ArrayBufferCensusReport {
15
+ /** Live `ArrayBuffer` objects reachable after the census's own GC. */
16
+ readonly count: number;
17
+ /** Their total `byteLength`. */
18
+ readonly bytes: number;
19
+ /** Buffers whose backing store is gone (`byteLength === 0`, detached). */
20
+ readonly detached: number;
21
+ /** Largest buckets first, by bytes. */
22
+ readonly buckets: readonly ArrayBufferSizeBucket[];
23
+ /** Largest exact sizes first, by bytes. */
24
+ readonly topSizes: readonly ArrayBufferExactSize[];
25
+ }
26
+ /** Take a census. Injectable so the heartbeat's gating is testable without an inspector. */
27
+ export type ArrayBufferCensus = () => Promise<ArrayBufferCensusReport>;
28
+ /**
29
+ * Take the census once `arrayBuffers` has been at or above this for
30
+ * {@link ARRAY_BUFFER_CENSUS_SUSTAIN_MS}.
31
+ *
32
+ * 512 MB. The two 2026-09-10 episodes crossed it 25 and 8 minutes in; the
33
+ * routine reading in six hours of 2-second samples around them was 18–100 MB
34
+ * with single-sample spikes to 479 MB, and the process's floor after every
35
+ * compaction is 12–18 MB. A dead-buffer sawtooth between major GCs (measured
36
+ * up to 3.65 GB on 2026-08-08 when the heap had 4 GB of headroom) cannot reach
37
+ * it while GC runs every few seconds — and if one did, the census would SAY
38
+ * so: it reports the live total after its own GC next to the raw reading.
39
+ */
40
+ export declare const ARRAY_BUFFER_CENSUS_THRESHOLD_MB = 512;
41
+ /**
42
+ * How long the reading must stay above the threshold before a census is taken.
43
+ *
44
+ * 60 s = 30 probes at the 2 s cadence. A burst that a scavenge or the next
45
+ * major GC returns never pays a forced GC; an episode climbing at the slowest
46
+ * rate measured (20 MB/min) is still 20 MB higher by the time it is measured,
47
+ * which is nothing against the threshold.
48
+ */
49
+ export declare const ARRAY_BUFFER_CENSUS_SUSTAIN_MS = 60000;
50
+ /**
51
+ * Minimum spacing between two censuses.
52
+ *
53
+ * 30 minutes: the second episode ran 76 minutes, so it would have produced
54
+ * three readings — enough to see the shape change (or not) as it grew — at a
55
+ * cost the router already pays six times an hour for the reclaim pass.
56
+ */
57
+ export declare const ARRAY_BUFFER_CENSUS_COOLDOWN_MS: number;
58
+ /**
59
+ * A drop of at least this share of the threshold between two consecutive
60
+ * probes, from a reading that had been SUSTAINED above the threshold, is a
61
+ * RELEASE — the boundary both episodes crossed with no line, and the one line
62
+ * that lets the release be correlated with whatever else the process logged
63
+ * that second. Sustained, not merely above: a one-probe spike that the next
64
+ * scavenge sweeps (a 1.3 GB burst in 11 s was measured on 2026-08-29) is not
65
+ * an episode ending, and a WARN for every one of those would bury the line
66
+ * this exists to produce.
67
+ */
68
+ export declare const ARRAY_BUFFER_RELEASE_MIN_DROP_RATIO = 0.5;
69
+ export interface ArrayBufferCensusGateOptions {
70
+ readonly thresholdMb?: number;
71
+ readonly sustainMs?: number;
72
+ readonly cooldownMs?: number;
73
+ }
74
+ /** What one probe reading means to the gate. */
75
+ export type ArrayBufferCensusVerdict = {
76
+ readonly kind: 'hold';
77
+ }
78
+ /** Take a census now. `reason` is printed on the resulting line. */
79
+ | {
80
+ readonly kind: 'take';
81
+ readonly reason: string;
82
+ }
83
+ /** The live set was released between this probe and the previous one. */
84
+ | {
85
+ readonly kind: 'released';
86
+ readonly fromMb: number;
87
+ readonly toMb: number;
88
+ };
89
+ export interface ArrayBufferCensusGate {
90
+ /** Judge one reading. A `take` verdict starts the cooldown. */
91
+ readonly observe: (arrayBuffersMb: number, at: number) => ArrayBufferCensusVerdict;
92
+ }
93
+ /**
94
+ * The pure decision: threshold, sustain, cooldown, release — testable without
95
+ * allocating a gigabyte or forcing a GC.
96
+ */
97
+ export declare function createArrayBufferCensusGate(options?: ArrayBufferCensusGateOptions): ArrayBufferCensusGate;
98
+ /** The inspector answers `unknown`; the report is trusted only once it has this shape. */
99
+ export declare function isArrayBufferCensusReport(value: unknown): value is ArrayBufferCensusReport;
100
+ /**
101
+ * The real census: an in-process inspector session, opened for the call and
102
+ * closed after it, so no session is left listening between censuses.
103
+ *
104
+ * Every remote object it creates is released before the session closes; a
105
+ * failure anywhere still disconnects. The session is in-process — nothing
106
+ * listens on a port, nothing outside this process can reach it.
107
+ */
108
+ export declare function createInspectorArrayBufferCensus(): ArrayBufferCensus;
109
+ /** What a census line needs beyond the report. */
110
+ export interface ArrayBufferCensusContext {
111
+ /** `process.memoryUsage().arrayBuffers` at the probe that triggered it. */
112
+ readonly rawMb: number;
113
+ readonly tookMs: number;
114
+ /** The gate's `take` reason. */
115
+ readonly reason: string;
116
+ }
117
+ /**
118
+ * The census line. `live` is what survived the census's own GC; `raw` is the
119
+ * reading that triggered it — when they disagree by a lot, the growth was dead
120
+ * buffers awaiting a sweep, not a retention, and the line says which.
121
+ */
122
+ export declare function formatArrayBufferCensus(label: string, report: ArrayBufferCensusReport, context: ArrayBufferCensusContext): string;
@@ -1,4 +1,4 @@
1
- import { CustomActionCaller, CustomActionSpec, CustomActionsSpec } from '@camstack/types';
1
+ import { ChildCustomActionCatalogs, CustomActionCaller, CustomActionSpec, CustomActionsSpec } from '@camstack/types';
2
2
  type Handler = (action: string, input: unknown, caller?: CustomActionCaller) => Promise<unknown>;
3
3
  export interface CustomActionEntry {
4
4
  readonly spec: CustomActionSpec;
@@ -19,5 +19,14 @@ export declare class CustomActionRegistry {
19
19
  resolve(addonId: string, action: string): CustomActionEntry | null;
20
20
  listActions(addonId: string): readonly string[];
21
21
  listAddons(): readonly string[];
22
+ /**
23
+ * Every registered catalog with its schemas stripped, keyed by addon id —
24
+ * what a runner puts in its register frame so the hub learns the catalogs
25
+ * from the process that owns them (D444). `hostedAddonIds` are the addons
26
+ * this runner hosts: each is present in the result, with an EMPTY catalog
27
+ * when it registered none, so the parent can say "declares no actions" per
28
+ * addon instead of guessing from an absent key.
29
+ */
30
+ describeAll(hostedAddonIds: readonly string[]): ChildCustomActionCatalogs;
22
31
  }
23
32
  export {};
@@ -1,6 +1,9 @@
1
1
  import { EventPlaneReader } from './event-plane-report.js';
2
- import { SocketPlaneReader } from './socket-plane-report.js';
2
+ import { SocketPlaneCounters, SocketPlaneReader } from './socket-plane-report.js';
3
+ import { MeshPeerQueue, MeshQueueReader } from './moleculer/mesh-queue-report.js';
4
+ import { OutboundQueueGateOptions, OutboundQueueReading } from './outbound-queue-watch.js';
3
5
  import { HeapSpaceReader } from './heap-spaces.js';
6
+ import { ArrayBufferCensus, ArrayBufferCensusGateOptions } from './array-buffer-census.js';
4
7
  /**
5
8
  * What hub-main calls itself in a `[mem]` line.
6
9
  *
@@ -606,7 +609,42 @@ export interface HeapWatchProbes {
606
609
  * from the parent's side. Only hub-main and an agent's main pass one.
607
610
  */
608
611
  readonly socketPlane?: SocketPlaneReader;
612
+ /**
613
+ * The write queue of every Moleculer TCP socket to a peer node. No default:
614
+ * only a process with a broker on a real transporter has one. Those sockets
615
+ * are UNREF'D by Moleculer, so a sum over `_getActiveHandles()` never listed
616
+ * them — the hole in the 2026-09-10 exclusion of "every socket queue"
617
+ * (D446). Read on every probe, judged with the UDS queues below.
618
+ */
619
+ readonly meshQueue?: MeshQueueReader;
620
+ /**
621
+ * Thresholds for the outbound-queue gate. The gate itself is created
622
+ * whenever {@link socketPlane} or {@link meshQueue} is given; this only
623
+ * tunes it (tests, or a process with a different notion of "held").
624
+ */
625
+ readonly outboundQueue?: OutboundQueueGateOptions;
626
+ /**
627
+ * The ArrayBuffer census that ARMS ITSELF. No default: it costs a forced
628
+ * full GC when it fires, and only hub-main has an episode on record (two
629
+ * silent linear climbs to 1.3 GB and 5 GB on 2026-09-10, each released in
630
+ * one instant with no line, the second two hours before the OOM). A runner
631
+ * that legitimately holds gigabytes of ArrayBuffers (`hub/stream-broker`,
632
+ * ~900 MB of RTP pre-roll rings) would fire it forever. See
633
+ * `array-buffer-census.ts` for the gate, the cost and what it is NOT.
634
+ */
635
+ readonly arrayBufferCensus?: ArrayBufferCensusProbe;
636
+ }
637
+ /** How the census is wired: the census itself plus the gate's thresholds. */
638
+ export interface ArrayBufferCensusProbe extends ArrayBufferCensusGateOptions {
639
+ readonly census: ArrayBufferCensus;
609
640
  }
641
+ /**
642
+ * Every outbound queue the heartbeat can reach, as the gate wants them: the
643
+ * UDS children (only those whose channel keeps a reading — an unmeasured
644
+ * peer is not a zero) and the mesh writer sockets, each prefixed with its
645
+ * plane.
646
+ */
647
+ export declare function outboundQueueReadings(uds: SocketPlaneCounters | undefined, mesh: readonly MeshPeerQueue[] | undefined): readonly OutboundQueueReading[];
610
648
  /**
611
649
  * Start the heartbeat. Returns a stop function.
612
650
  *
@@ -0,0 +1,51 @@
1
+ /**
2
+ * mesh-queue-report — the write queue of every Moleculer TCP socket this
3
+ * process holds to a peer node, on the `[mem]` line (D446).
4
+ *
5
+ * ## Why a separate reader
6
+ *
7
+ * The UDS plane is measured on the channel (`SocketChannel.queuedBytes`). The
8
+ * mesh plane is Moleculer's: `TcpTransporter.writer` is a `TcpWriter` whose
9
+ * `sockets` map holds one `net.Socket` per peer node, opened lazily on the
10
+ * first send and — this is the point — `unref()`'d at connect
11
+ * (`transporters/tcp/tcp-writer.js`). An unref'd handle is not in
12
+ * `process._getActiveHandles()`, so the 2026-09-10 census that summed
13
+ * `writableLength` over that list and excluded "every socket queue" could not
14
+ * have seen a backlog to an agent, however large.
15
+ *
16
+ * ## What is read, and how carefully
17
+ *
18
+ * `broker.transit.tx.writer.sockets` — an internal path, walked with type
19
+ * guards and never a cast. Every step that is not the expected shape yields
20
+ * `undefined` for the WHOLE reading: a test broker (`Fake` transporter), a
21
+ * broker that does not exist yet, a Moleculer whose internals moved. `undefined`
22
+ * is "this process has no mesh writer to report", never "the mesh is quiet".
23
+ * A socket entry without numeric `writableLength`/`bytesWritten` is skipped
24
+ * on its own.
25
+ */
26
+ /** One peer node's outbound socket, as the writer holds it. */
27
+ export interface MeshPeerQueue {
28
+ readonly nodeId: string;
29
+ /** Bytes handed to the socket that the kernel has not taken yet. */
30
+ readonly queuedBytes: number;
31
+ /** Lifetime bytes written on this socket — so a stalled queue can be read
32
+ * against the peer's normal rate. */
33
+ readonly bytesWritten: number;
34
+ }
35
+ /** `undefined` = no mesh writer to report (no broker, `Fake` transporter). */
36
+ export type MeshQueueReader = () => readonly MeshPeerQueue[] | undefined;
37
+ /**
38
+ * Build a reader over a lazily-resolved broker. Wrapped: a broker mid-teardown
39
+ * may throw, and a diagnostic that can crash the process it watches is worse
40
+ * than none.
41
+ */
42
+ export declare function createMeshQueueReader(getBroker: () => unknown): MeshQueueReader;
43
+ /** At most this many nodes named. A cluster has a handful. */
44
+ export declare const MESH_QUEUE_TOP_N = 3;
45
+ /**
46
+ * The suffix appended to a `[mem]` line — leading space included, empty when
47
+ * this process has no mesh writer. `meshQueued` is printed always, at zero
48
+ * included: a quiet mesh is a reading. `meshQueuedTop` names only nodes with
49
+ * a queue, largest first — a ranked list of zeros is noise.
50
+ */
51
+ export declare function formatMeshQueue(peers: readonly MeshPeerQueue[] | undefined): string;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * outbound-queue-watch — an outbound socket queue that HOLDS is named, and its
3
+ * release is timestamped (D446).
4
+ *
5
+ * ## The blind spot this closes
6
+ *
7
+ * On 2026-09-10 hub-main's `arrayBuffers` climbed twice at a constant rate
8
+ * (20 MB/min to 1 264 MB; 63 MB/min for 77 min to 4 968 MB), LIVE across a
9
+ * forced compaction, with ~4 % of JS heap alongside, and each time released in
10
+ * one instant with no line from any owner. The investigation excluded "every
11
+ * socket write queue" by summing `writableLength` over
12
+ * `process._getActiveHandles()` — 84 MB, all of it the phone's WebSocket.
13
+ *
14
+ * `_getActiveHandles()` lists only handles that keep the loop alive. An
15
+ * UNREF'D socket is not one of them, and Moleculer's `TcpWriter.connect`
16
+ * unrefs every socket it opens to a peer node (`transporters/tcp/tcp-writer.js`,
17
+ * `socket.unref()`). So the one outbound plane that writes to another MACHINE
18
+ * — where a reader that stops draining is the ordinary failure — was never in
19
+ * the sum. The UDS children were in it, but only as a total, at one instant,
20
+ * by hand.
21
+ *
22
+ * ## What this is
23
+ *
24
+ * A pure gate over per-peer queue depths the heartbeat reads on every probe:
25
+ * the UDS children's `socket.writableLength` (`SocketChannel.queuedBytes`) and
26
+ * the mesh writer sockets' (`mesh-queue-report.ts`). It says three things and
27
+ * nothing else:
28
+ *
29
+ * - a peer whose queue has been at or above {@link OUTBOUND_QUEUE_THRESHOLD_BYTES}
30
+ * for {@link OUTBOUND_QUEUE_SUSTAIN_MS} is NAMED, with its current and peak
31
+ * depth and how long it has held — at most once per
32
+ * {@link OUTBOUND_QUEUE_COOLDOWN_MS};
33
+ * - a named peer whose queue then falls under half the threshold, or that
34
+ * vanishes from the readings (its socket died — which is how a queue is
35
+ * dropped in one instant), is reported as RELEASED with its peak and the
36
+ * hold, so the release the episodes never logged has a line and a peer;
37
+ * - a burst that drains inside the sustain window says nothing. Backpressure
38
+ * measures LIVENESS, not identity (D444): a peer momentarily behind is
39
+ * coming back.
40
+ *
41
+ * ## What it is NOT
42
+ *
43
+ * Not a bound. It terminates nothing: a UDS child whose channel is destroyed
44
+ * loses every in-flight call, and a mesh socket Moleculer reconnects on its
45
+ * own terms. The WS guard (`ws-slow-consumer-guard.ts`) bounds the one plane
46
+ * whose client is expendable. Here the line is the deliverable; what to do
47
+ * about a peer that holds is decided with the peer named, not before.
48
+ */
49
+ /** 64 MiB. Below this a queue is a burst on a busy peer (the orchestrator's
50
+ * channel moves ~1.5 MB/s of audio responses); above it, and held, it is
51
+ * retention with a name. */
52
+ export declare const OUTBOUND_QUEUE_THRESHOLD_BYTES: number;
53
+ /** Matches the ArrayBuffer census sustain: a minute above the bound. */
54
+ export declare const OUTBOUND_QUEUE_SUSTAIN_MS = 60000;
55
+ /** A held queue is re-named every ten minutes, not every probe. */
56
+ export declare const OUTBOUND_QUEUE_COOLDOWN_MS: number;
57
+ /** One outbound channel's queue depth at one probe. `peerId` carries its
58
+ * plane as a prefix (`uds:<childId>`, `mesh:<nodeId>`). */
59
+ export interface OutboundQueueReading {
60
+ readonly peerId: string;
61
+ readonly bytes: number;
62
+ }
63
+ export type OutboundQueueVerdict = {
64
+ readonly kind: 'sustained';
65
+ readonly peerId: string;
66
+ readonly bytes: number;
67
+ readonly peakBytes: number;
68
+ readonly heldForMs: number;
69
+ } | {
70
+ readonly kind: 'released';
71
+ readonly peerId: string;
72
+ readonly peakBytes: number;
73
+ readonly toBytes: number;
74
+ readonly heldForMs: number;
75
+ /** The peer left the readings altogether — its socket is gone. */
76
+ readonly gone: boolean;
77
+ };
78
+ export interface OutboundQueueGateOptions {
79
+ readonly thresholdBytes?: number;
80
+ readonly sustainMs?: number;
81
+ readonly cooldownMs?: number;
82
+ }
83
+ export interface OutboundQueueGate {
84
+ /** Judge one probe's readings. Every peer is judged on its own. */
85
+ readonly observe: (readings: readonly OutboundQueueReading[], at: number) => readonly OutboundQueueVerdict[];
86
+ }
87
+ /**
88
+ * The pure decision — threshold, sustain, cooldown, release, gone — testable
89
+ * without a socket.
90
+ */
91
+ export declare function createOutboundQueueGate(options?: OutboundQueueGateOptions): OutboundQueueGate;
92
+ /** The line for one verdict. Both carry the peer, the bytes and the hold;
93
+ * the sustained one also says why the older census could not see it. */
94
+ export declare function formatOutboundQueueVerdict(label: string, verdict: OutboundQueueVerdict): string;
@@ -1,24 +1,52 @@
1
1
  import { SocketDirectionSample, SocketTrafficSample } from './transport/socket-traffic.js';
2
- /** One peer's cumulative traffic, as the channel keeps it. */
2
+ /** One peer's cumulative traffic, as the channel keeps it, plus its outbound
3
+ * queue depth at the instant of the read. */
3
4
  export interface SocketPeerSample {
4
5
  readonly peerId: string;
5
6
  readonly tx: SocketDirectionSample;
6
7
  readonly rx: SocketDirectionSample;
8
+ /**
9
+ * Bytes written to this peer that it has not yet taken — `writableLength`
10
+ * on the channel, read live (D446). `null` when the channel keeps no such
11
+ * reading: a measurement that failed is null, never 0 (D393), and the
12
+ * window counts it as unmeasured rather than as empty.
13
+ */
14
+ readonly queuedBytes: number | null;
7
15
  }
8
16
  /** What `LocalChildRegistry` exposes, structurally — the kernel's heartbeat
9
17
  * must not depend on the transport layer's class. */
10
18
  export interface SocketPlaneRegistryChild {
11
19
  readonly childId: string;
12
20
  }
21
+ /**
22
+ * How many child→sibling cap calls the parent forwarded without reading
23
+ * their args (`raw`) against how many it decoded — see
24
+ * `transport/local-child-registry.ts` `ForwardCensus`. Cumulative on the
25
+ * registry; a delta on the window.
26
+ */
27
+ export interface SocketForwardCensus {
28
+ readonly raw: number;
29
+ readonly decoded: number;
30
+ }
13
31
  export interface SocketPlaneRegistry {
14
32
  readonly listChildren: () => readonly SocketPlaneRegistryChild[];
15
33
  readonly getChildSocketTraffic: (childId: string) => SocketTrafficSample | null;
34
+ /** Live outbound queue depth to one child, or `null` when unmeasured. */
35
+ readonly getChildQueuedBytes: (childId: string) => number | null;
36
+ /**
37
+ * The raw-forward census, when the registry keeps one. OPTIONAL: a registry
38
+ * that predates raw-forward has no such number, and the line must then omit
39
+ * the field rather than print a zero that reads as "nothing was forwarded".
40
+ */
41
+ readonly readForwardCensus?: () => SocketForwardCensus;
16
42
  }
17
43
  /** One cumulative reading of every measured peer. */
18
44
  export interface SocketPlaneCounters {
19
45
  readonly peers: readonly SocketPeerSample[];
20
46
  /** Connected children, INCLUDING any whose channel keeps no counters. */
21
47
  readonly peersConnected: number;
48
+ /** Cumulative forward census, absent when the registry keeps none. */
49
+ readonly forward?: SocketForwardCensus;
22
50
  }
23
51
  /** `undefined` = this process has no local socket plane to report (every forked
24
52
  * runner, and hub-main before the registry exists). */
@@ -57,6 +85,23 @@ export interface SocketPeerDelta {
57
85
  readonly tx: SocketDirectionSample;
58
86
  readonly rx: SocketDirectionSample;
59
87
  }
88
+ /** One peer holding an outbound queue at the instant of the read. */
89
+ export interface SocketPeerQueue {
90
+ readonly peerId: string;
91
+ readonly bytes: number;
92
+ }
93
+ /**
94
+ * The outbound queues at the instant of the read — NOT a delta: a queue is a
95
+ * level, and the level is the reading (D446).
96
+ */
97
+ export interface SocketQueueReading {
98
+ /** Summed over every peer with a reading. */
99
+ readonly totalBytes: number;
100
+ /** Peers whose channel keeps no queue reading. Never folded into the total. */
101
+ readonly unmeasured: number;
102
+ /** Peers holding a queue, largest first, at most {@link SOCKET_QUEUE_TOP_N}. */
103
+ readonly top: readonly SocketPeerQueue[];
104
+ }
60
105
  /** The whole plane over one heartbeat window. */
61
106
  export interface SocketPlaneWindow {
62
107
  readonly peersConnected: number;
@@ -67,10 +112,31 @@ export interface SocketPlaneWindow {
67
112
  readonly rx: SocketDirectionSample;
68
113
  /** Busiest first (tx+rx messages), at most {@link SOCKET_PLANE_TOP_N}. */
69
114
  readonly top: readonly SocketPeerDelta[];
115
+ /**
116
+ * Heaviest first (tx+rx BYTES), at most {@link SOCKET_PLANE_TOP_N}.
117
+ *
118
+ * A separate ranking rather than a replacement, because the two questions
119
+ * have different answers and both are live: `top` names the peer paying for
120
+ * round trips (`audio-codec` at 8 379 calls/min of small frames), `topBytes`
121
+ * names the peer paying for copies and GC. A peer that moves FEW, HUGE
122
+ * frames is absent from `top` by construction — measured 2026-09-11, the
123
+ * five peers named by messages left 116 MB/min of hub-main's outbound bytes
124
+ * with no name on them (D454).
125
+ */
126
+ readonly topBytes: readonly SocketPeerDelta[];
127
+ readonly queued: SocketQueueReading;
128
+ /** Forwards in this window, absent when the registry keeps no census. */
129
+ readonly forward?: SocketForwardCensus;
70
130
  }
131
+ /** How many queue-holding peers get named. Three: a queue is an exception,
132
+ * and the line already carries five names for traffic. */
133
+ export declare const SOCKET_QUEUE_TOP_N = 3;
134
+ /** The queue reading over the current counters — a level, so no baseline. */
135
+ export declare function readSocketQueues(current: SocketPlaneCounters): SocketQueueReading;
71
136
  /** Previous cumulative reading, kept between lines. */
72
137
  export interface SocketPlaneBaseline {
73
138
  readonly peers: ReadonlyMap<string, SocketPeerSample>;
139
+ readonly forward?: SocketForwardCensus;
74
140
  }
75
141
  /** A process that has never read the plane. */
76
142
  export declare const EMPTY_SOCKET_PLANE_BASELINE: SocketPlaneBaseline;
@@ -92,8 +158,13 @@ export declare function createSocketPlaneMeter(reader: SocketPlaneReader, topN?:
92
158
  * is the only way to see an unmeasured channel, and the per-kind splits because
93
159
  * "the plane went quiet for a minute" is a reading.
94
160
  *
95
- * `socketTop` is the one conditional field — a ranked list of nothing is noise —
96
- * and it is a single comma-separated value so the field COUNT does not change
97
- * from line to line.
161
+ * `socketTop` is a conditional field — a ranked list of nothing is noise — and
162
+ * it is a single comma-separated value so the field COUNT does not change from
163
+ * line to line.
164
+ *
165
+ * `socketTopBytes` is the other one, and its PRESENCE is the reading: it is
166
+ * printed only when ranking by bytes names a peer that ranking by messages
167
+ * does not. A line without it says the two rankings agree; a line with it says
168
+ * a peer is moving few, heavy frames and names it (D454).
98
169
  */
99
170
  export declare function formatSocketPlane(window: SocketPlaneWindow | undefined): string;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The routing facts a parent reads OUT OF a cap call's args.
3
+ *
4
+ * `LocalChildRegistry` routes a child's `cap-call-out` on two things it used
5
+ * to read straight out of `args`: an inline `nodeId` (the historical per-call
6
+ * node pin — `extractNodeId`) and an `addonId` selector on a collection cap
7
+ * (`addonPinOf`). Under raw-forward the parent never decodes `args`, so the
8
+ * SENDER lifts those two fields into the envelope, and the parent routes on
9
+ * the lifted copy in both modes — one code path, whichever way the args
10
+ * travelled.
11
+ *
12
+ * Only top-level string fields are lifted, which is exactly what the two
13
+ * readers ever looked at. Nothing else about the args is visible to the
14
+ * parent under raw-forward, and that is the point.
15
+ */
16
+ export interface CapRoutingHints {
17
+ readonly nodeId?: string;
18
+ readonly addonId?: string;
19
+ }
20
+ /** Lift {@link CapRoutingHints} from a call's args. Never throws. */
21
+ export declare function liftRoutingHints(args: unknown): CapRoutingHints;
@@ -1,4 +1,5 @@
1
- import { CustomActionCaller, IReadinessRegistryRecord, LogLevel, LogTags, SystemEvent } from '@camstack/types';
1
+ import { ChildCustomActionCatalogs, CustomActionCaller, IReadinessRegistryRecord, LogLevel, LogTags, SystemEvent } from '@camstack/types';
2
+ import { CapRoutingHints } from './cap-routing-hints.js';
2
3
  /**
3
4
  * Routing descriptor a child sends so the parent can route cap calls to it.
4
5
  * The provider implementation never crosses the wire — only these keys do.
@@ -35,6 +36,34 @@ export interface RegisterMessage {
35
36
  * `EventSubUpdateMessage` for post-boot updates.
36
37
  */
37
38
  readonly eventPatterns?: readonly string[];
39
+ /**
40
+ * Custom-action catalogs of the addons this child hosts, keyed by addon id,
41
+ * with the Zod schemas stripped — see `CustomActionDescriptor`. Carried IN
42
+ * the register frame for the same reason as `eventPatterns`: the parent
43
+ * learns it race-free, at the post-init re-register, FROM THE PROCESS THAT
44
+ * OWNS THE ADDON. It is never learned by evaluating the addon's bundle in the
45
+ * parent (D444: ten 9 MB evaluations in hub-main, none of which produced a
46
+ * catalog, and every one pinned the evicted graph's module-scope state).
47
+ * OPTIONAL: absent from the pre-init register (the catalogs do not exist yet)
48
+ * and from a legacy child; absent means "not described yet", never "none".
49
+ */
50
+ readonly customActions?: ChildCustomActionCatalogs;
51
+ /**
52
+ * The child can RECEIVE a `cap-call` whose args ride as an opaque payload
53
+ * beside the envelope (raw-forward), and answer with one. Declared, never
54
+ * assumed: a parent forwards a payload only to a child that said so, and
55
+ * decodes it for one that did not. Absent = legacy child = inline args.
56
+ */
57
+ readonly rawForward?: true;
58
+ }
59
+ /**
60
+ * Parent → child answer to `register`. `rawForward` says the parent accepts
61
+ * a `cap-call-out` whose args ride as a payload — a child packs its args
62
+ * only after seeing it. Absent = legacy parent = inline args.
63
+ */
64
+ export interface RegisterAck {
65
+ readonly ok: true;
66
+ readonly rawForward?: true;
38
67
  }
39
68
  /**
40
69
  * Child → parent, fire-and-forget: the child's category-pattern subscription
@@ -47,12 +76,21 @@ export interface EventSubUpdateMessage {
47
76
  readonly kind: 'event-sub';
48
77
  readonly patterns: readonly string[];
49
78
  }
50
- /** Child → parent outbound cap invocation — the child asks the parent to route a cap call it does not own. */
79
+ /**
80
+ * Child → parent outbound cap invocation — the child asks the parent to route
81
+ * a cap call it does not own.
82
+ *
83
+ * Under raw-forward `args` is ABSENT from the envelope: the packed args ride
84
+ * as the frame's opaque payload and `hints` carries the two fields the parent
85
+ * routes on. A legacy child sends `args` inline and no `hints`; the parent
86
+ * lifts the hints itself. See `cap-routing-hints.ts`.
87
+ */
51
88
  export interface CapCallOutMessage {
52
89
  readonly kind: 'cap-call-out';
53
90
  readonly capName: string;
54
91
  readonly method: string;
55
- readonly args: unknown;
92
+ readonly args?: unknown;
93
+ readonly hints?: CapRoutingHints;
56
94
  readonly deviceId?: number;
57
95
  /**
58
96
  * Optional per-call node pin (out-of-band; NOT part of the validated method
@@ -248,6 +286,8 @@ export interface RegisteredChild {
248
286
  * one. See docs/decisions/adr-0188-an-unregister-carries-proof-of-ownership.md.
249
287
  */
250
288
  readonly incarnation: number;
289
+ /** See {@link RegisterMessage.customActions}. Absent = not described yet. */
290
+ readonly customActions?: ChildCustomActionCatalogs;
251
291
  }
252
292
  /** Arguments to route a cap method call (the `cap-call` message minus its discriminant). */
253
293
  export type CapCallInput = Omit<CapCallMessage, 'kind'>;
@@ -1,5 +1,54 @@
1
1
  /** Encode a value as a length-prefixed (4-byte big-endian) MsgPack frame. */
2
2
  export declare function encodeFrame(value: unknown): Buffer;
3
+ /**
4
+ * The key under which an opaque payload rides on a frame — see
5
+ * {@link encodeFrameHead}. Short on purpose: it is written once per frame by
6
+ * hand, and it is the one msgpack key on this plane a decoder must never
7
+ * interpret.
8
+ */
9
+ export declare const PAYLOAD_KEY = "pl";
10
+ /**
11
+ * Encode the HEAD of a frame that carries an opaque payload: the length
12
+ * prefix, the msgpack envelope with one extra key ({@link PAYLOAD_KEY}), and
13
+ * the `bin 32` header for the payload — everything but the payload bytes
14
+ * themselves, which the caller writes straight after it.
15
+ *
16
+ * ## Why the payload is the frame's TAIL and not a field
17
+ *
18
+ * hub-main routes every sibling↔sibling call. Until this existed it decoded
19
+ * the whole request (args included), built a new message, and re-encoded it
20
+ * — walking a 13.5 KB PCM chunk or a 2 000-row settings answer twice on the
21
+ * one thread that serves every tRPC request, and allocating an `Encoder`
22
+ * buffer that doubles from 2 KiB up to the body's size on every write
23
+ * (D441: ~27 % of hub-main's busy time in msgpack decode, ~6 % in encode,
24
+ * and the churn that fed a 29.5 % GC share).
25
+ *
26
+ * With the payload appended as the LAST key of the map, the receiver's
27
+ * ordinary msgpack decode yields it as a zero-copy VIEW of the frame
28
+ * (`@msgpack/msgpack` decodes `bin` as `subarray`), the router reads only the
29
+ * envelope, and forwarding is: encode a ~60-byte head, write it, write the
30
+ * view. The wire stays plain msgpack — a peer that never heard of payloads
31
+ * decodes `{ …frame, pl: <bytes> }` and can be told, at register, not to be
32
+ * sent one.
33
+ *
34
+ * ## The one hand-written msgpack in this repository
35
+ *
36
+ * `@msgpack/msgpack` has no "encode a map, then append a key" API, so the
37
+ * envelope is encoded normally and its `fixmap` count byte is bumped by one
38
+ * before the key and the `bin 32` header are appended. That patch is only
39
+ * valid for a `fixmap` (≤ 15 keys); a `Frame` has at most four, and anything
40
+ * else is refused rather than silently corrupted.
41
+ */
42
+ export declare function encodeFrameHead(frame: Record<string, unknown>, payload: Uint8Array): Buffer;
43
+ /**
44
+ * Serialise a value into the bytes a payload carries. The SAME options as the
45
+ * envelope codec, so `undefined` properties are omitted rather than turned
46
+ * into `null` — a `z.void()` cap output must read back as `undefined` on the
47
+ * far side exactly as it did when it travelled inline.
48
+ */
49
+ export declare function packPayload(value: unknown): Uint8Array;
50
+ /** Inverse of {@link packPayload}. A `bin` inside decodes as a view of `bytes`. */
51
+ export declare function unpackPayload(bytes: Uint8Array): unknown;
3
52
  /**
4
53
  * Reassembles length-prefixed MsgPack frames from a byte stream.
5
54
  *
@@ -1,4 +1,4 @@
1
- import { IReadinessRegistryRecord, SystemEvent } from '@camstack/types';
1
+ import { ChildCustomActionCatalogs, IReadinessRegistryRecord, SystemEvent } from '@camstack/types';
2
2
  import { AddonCallInput, CapCallInput, ChildCapDescriptor, ChildLogMessage } from './child-cap-protocol.js';
3
3
  export interface LocalChildClientOptions {
4
4
  /** Parent node id — selects the UDS endpoint this child connects to. */
@@ -56,8 +56,24 @@ export declare class LocalChildClient {
56
56
  * subscriptions do), the register carries the real union — even if empty.
57
57
  */
58
58
  private hasDeclaredEventPatterns;
59
+ /**
60
+ * The custom-action catalogs of every hosted addon, once the runner's init
61
+ * loop has produced them (`updateCaps(caps, catalogs)` at post-init). `null`
62
+ * until then, and OMITTED from the register frame while null: the parent
63
+ * reads absence as "not described yet", and a pre-init register must never
64
+ * look like "this child declares no actions". Kept on the client so every
65
+ * later re-register (native-cap change, reconnect) re-carries it unchanged.
66
+ */
67
+ private latestCustomActions;
59
68
  /** Events and logs queued while the channel is not yet open. */
60
69
  private readonly pendingEmits;
70
+ /**
71
+ * Whether the parent acknowledged raw-forward at `register`. Until it has,
72
+ * `callOut` sends args inline exactly as every child always did — a legacy
73
+ * parent would decode a payload-borne call to `args: undefined` and run the
74
+ * provider on nothing, silently. Re-read on every re-register.
75
+ */
76
+ private parentRawForward;
61
77
  /** Handler for parent→child events. Registered via `onEvent`. */
62
78
  private eventHandler;
63
79
  /**
@@ -138,7 +154,14 @@ export declare class LocalChildClient {
138
154
  * it (device-restore can race ahead of the UDS connect). After `start()`,
139
155
  * the set is sent immediately.
140
156
  */
141
- updateCaps(caps: readonly ChildCapDescriptor[]): Promise<void>;
157
+ updateCaps(caps: readonly ChildCapDescriptor[], customActions?: ChildCustomActionCatalogs): Promise<void>;
158
+ /**
159
+ * The register frame: caps, plus everything a re-register must atomically
160
+ * re-carry so no separate re-sync can be missed — the subscription union
161
+ * (omitted if nothing ever declared, which keeps the fail-open shape) and the
162
+ * custom-action catalogs (omitted until the init loop produced them).
163
+ */
164
+ private registerFrame;
142
165
  /**
143
166
  * Fire-and-forget: send a system event to the parent for forwarding to the
144
167
  * hub event bus. Safe to call before `start()` — events are buffered and