@camstack/system 1.2.219 → 1.2.220

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 (68) hide show
  1. package/dist/addon-runner.js +2 -2
  2. package/dist/addon-runner.mjs +2 -2
  3. package/dist/addon-utils.js +1 -1
  4. package/dist/addon-utils.mjs +1 -1
  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 +1 -1
  28. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  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/native-metrics.addon.js +1 -1
  32. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  33. package/dist/builtins/platform-probe/index.js +1 -1
  34. package/dist/builtins/platform-probe/index.mjs +1 -1
  35. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  36. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  37. package/dist/builtins/snapshot/index.js +1 -1
  38. package/dist/builtins/snapshot/index.mjs +1 -1
  39. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  40. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  41. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  42. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  43. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  44. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  45. package/dist/builtins/system-config/system-config.addon.js +1 -1
  46. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  47. package/dist/builtins/winston-logging/index.js +1 -1
  48. package/dist/builtins/winston-logging/index.mjs +1 -1
  49. package/dist/{custom-action-registry-DKWhaWL1.js → custom-action-registry-B42MC_Pv.js} +1 -1
  50. package/dist/{custom-action-registry-B6gOtUWA.mjs → custom-action-registry-CpR58DXT.mjs} +1 -1
  51. package/dist/{dist-Cyz5_i7z.mjs → dist-Ca_jaxXs.mjs} +230 -2
  52. package/dist/{dist-B17W9ngu.js → dist-CllKpcTd.js} +230 -2
  53. package/dist/index.js +71 -32
  54. package/dist/index.mjs +65 -33
  55. package/dist/kernel/gc-census.d.ts +144 -0
  56. package/dist/kernel/heap-watch.d.ts +8 -0
  57. package/dist/kernel/socket-plane-report.d.ts +37 -0
  58. package/dist/kernel/transport/cap-action-census.d.ts +218 -0
  59. package/dist/kernel/transport/cap-child-index.d.ts +49 -0
  60. package/dist/kernel/transport/index.d.ts +2 -0
  61. package/dist/kernel/transport/local-child-registry.d.ts +63 -4
  62. package/dist/kernel/transport/local-transport.d.ts +9 -0
  63. package/dist/kernel/transport/socket-channel.d.ts +21 -0
  64. package/dist/{manifest-system-deps-g_nK0xsF.js → manifest-system-deps-DfpwaXkh.js} +550 -27
  65. package/dist/{manifest-system-deps-C7dihwWT.mjs → manifest-system-deps-iadzxSBr.mjs} +509 -28
  66. package/dist/{retired-settings-keys-CjvB7Etx.js → retired-settings-keys-7iEsSzmD.js} +1 -1
  67. package/dist/{retired-settings-keys-BCYUrdsA.mjs → retired-settings-keys-DQnfr__p.mjs} +1 -1
  68. package/package.json +1 -1
@@ -0,0 +1,218 @@
1
+ /**
2
+ * cap-action-census — which cap METHOD moves the bytes, and how much of each
3
+ * one this parent forwarded without ever reading it.
4
+ *
5
+ * ## Why `socket-traffic.ts` keeps no method, and why this is a separate file
6
+ *
7
+ * Read `socket-traffic.ts` before changing anything here. It does not omit the
8
+ * method by oversight; three separate properties of that file forbid it, and
9
+ * this module exists because each can be answered somewhere else rather than
10
+ * relaxed:
11
+ *
12
+ * 1. **The channel does not know one.** `SocketChannel` sees a {@link
13
+ * import('./local-transport.js').Frame} — `{ k, id, body }` — and `body` is
14
+ * `unknown` by contract. The cap envelope lives one layer up, in
15
+ * `child-cap-protocol.ts`. A counter inside the channel that reached into
16
+ * `body.capName` would make the transport depend on the protocol it
17
+ * carries. So the LABEL is supplied from above ({@link capActionLabel}) and
18
+ * only the BYTES come from the wire.
19
+ *
20
+ * 2. **A `res` frame carries no method at all** — only its correlation `id`.
21
+ * And the responses are the whole question: measured 2026-09-11, hub-main
22
+ * wrote 144–262 MB/min to `hub/pipeline-analytics` on 1 966–2 411 responses
23
+ * (a 73–108 kB mean) against 2 kB of requests. Charging those bytes to a
24
+ * method is therefore not a counter at all, it is a CORRELATION: the label
25
+ * is taken off the `req`, held against its id, and settled when the `res`
26
+ * for that id is written. `socket-traffic.ts` is a two-addition hot path
27
+ * with no state per message; this is state per in-flight request, and the
28
+ * two do not belong in one file.
29
+ *
30
+ * 3. **Cardinality.** `recordSocketFrame` costs 2.37–2.41 ns and allocates
31
+ * nothing, on a path that runs ~44 000 times a second. Its docblock's rule
32
+ * — "an instrument that changes what it measures is not an instrument" — is
33
+ * the binding constraint, and a naive per-method map breaks it twice: a Map
34
+ * lookup and a counter object per method NAME, where the names arrive off
35
+ * the wire and are therefore unbounded in principle.
36
+ *
37
+ * ## What was chosen for the cardinality, and why not a sample
38
+ *
39
+ * **Full per-method counters in memory, bounded per peer; top-N only at REPORT
40
+ * time.** The constraint that is real is the LINE's width (D446: Loki reads
41
+ * these prefixes), not the map's size — and those are different limits:
42
+ *
43
+ * - *Measured shape.* The cap-usage graph already keeps `(caller, provider,
44
+ * cap, method)` and, queried on this hub on 2026-09-11, holds **53 rows for
45
+ * the whole cluster** — 14 of them for `pipeline-analytics`, whose top three
46
+ * caps are 2 077 of its 2 141 calls/min. A peer uses a handful of methods,
47
+ * not hundreds. ~39 peers × a few dozen labels is a few hundred counter
48
+ * objects, each allocated ONCE and then only incremented. That is not an
49
+ * explosion; it is smaller than the `childEventStats` map next to it.
50
+ * - *The unbounded case is still refused*, because "measured today" is not a
51
+ * guarantee: {@link MAX_CAP_ACTIONS_PER_PEER} caps the distinct labels per
52
+ * peer and everything past it is charged to ONE
53
+ * {@link CAP_ACTION_OVERFLOW_LABEL} bucket. The bytes are never dropped —
54
+ * they stop being *named*, which is a different and visible failure.
55
+ * - *A sampled window was rejected.* The flow this exists to name is few,
56
+ * enormous responses: 1 966 responses carrying 144 MB. A 1-in-N sample of a
57
+ * distribution whose top item fires ~30 times a minute answers "probably"
58
+ * to a question that has an exact answer for the price of an integer add.
59
+ * And a sample that misses a method would report it as zero bytes, which is
60
+ * exactly the `null`-vs-`0` confusion D393 exists to forbid.
61
+ * - *A byte THRESHOLD was rejected for the same reason in reverse*: a
62
+ * threshold decides what to keep before it knows the window's shape, so the
63
+ * method that only matters during an episode is the one it discards. Rank
64
+ * at report time, when the totals are known.
65
+ *
66
+ * ## What a reader may conclude, and what it may not
67
+ *
68
+ * {@link CapActionWindow.labelledBytes} against `totalBytes` is the census's
69
+ * own coverage, and it is on the line. Bytes this parent moved that carry no
70
+ * cap envelope (`register`, `addon-call`, log and event frames) are charged to
71
+ * {@link CAP_ACTION_UNLABELLED} rather than omitted: an instrument that
72
+ * silently drops what it cannot classify reports a clean attribution of a
73
+ * fraction it never names. A window whose `labelledBytes` is a small part of
74
+ * `totalBytes` has NOT attributed the plane, and says so.
75
+ *
76
+ * ## Mutation, as in `socket-traffic.ts`
77
+ *
78
+ * Same deliberate exception, for the same measured reason: the counters are
79
+ * plain mutable numbers written only by {@link CapActionCensus.record} and
80
+ * friends, and read only through {@link CapActionCensus.read}, which COPIES.
81
+ * The copy is not a nicety — the reporting layer subtracts the previous
82
+ * reading, and a `read()` that handed back the live maps would advance the
83
+ * baseline together with the counters and report every window as empty.
84
+ */
85
+ /**
86
+ * The channel's half of the census — see `socket-channel.ts` for the seam.
87
+ *
88
+ * The channel owns the BYTES and the request↔response correlation and NOTHING
89
+ * else: it sees `{ k, id, body }` with `body: unknown`, and a `res` frame
90
+ * carries no method at all. So the layer that understands the protocol supplies
91
+ * {@link CapActionChannelObserver.label}, and the channel calls it once per
92
+ * `req` frame, holds the answer against the frame's id, and settles it with
93
+ * {@link CapActionChannelObserver.record} when the matching `res` is written or
94
+ * read. A `null` label is still recorded — those bytes crossed, and a census
95
+ * that drops what it cannot name reports a clean attribution of a fraction it
96
+ * never mentions.
97
+ *
98
+ * Declared HERE rather than in the channel so `local-transport.ts` can name it
99
+ * on {@link import('./local-transport.js').LocalChannel} without importing the
100
+ * implementation that satisfies it.
101
+ */
102
+ export interface CapActionChannelObserver {
103
+ /** `<cap>.<method>`, or `null` for a frame that carries no cap envelope. */
104
+ label(body: unknown): string | null;
105
+ /** One completed request/response pair, its on-the-wire bytes both ways. */
106
+ record(label: string | null, reqBytes: number, resBytes: number): void;
107
+ }
108
+ /** One action's cumulative cost on one peer's channel. Mutable by design. */
109
+ export interface CapActionCounters {
110
+ /** Completed request/response pairs. */
111
+ calls: number;
112
+ /** On-the-wire bytes of the requests, prefix and payload included. */
113
+ reqBytes: number;
114
+ /** On-the-wire bytes of the answers. */
115
+ resBytes: number;
116
+ /** Of `calls`, how many this parent forwarded without reading (D448). */
117
+ raw: number;
118
+ /** Of `calls`, how many it materialised the args of. */
119
+ decoded: number;
120
+ }
121
+ /** An immutable reading of one action. */
122
+ export interface CapActionSample {
123
+ readonly calls: number;
124
+ readonly reqBytes: number;
125
+ readonly resBytes: number;
126
+ readonly raw: number;
127
+ readonly decoded: number;
128
+ }
129
+ /** One complete reading: peer → action → counters. */
130
+ export type CapActionReading = ReadonlyMap<string, ReadonlyMap<string, CapActionSample>>;
131
+ /** A process that has never read the census. */
132
+ export declare const EMPTY_CAP_ACTION_READING: CapActionReading;
133
+ /**
134
+ * Bytes that crossed on a frame carrying no cap envelope.
135
+ *
136
+ * Named rather than omitted so the census's coverage is readable off its own
137
+ * output: `register`, `addon-call`, log frames and the event fan-out all land
138
+ * here. A census that reported only what it could name would look like a
139
+ * complete attribution of whatever share it happened to cover.
140
+ */
141
+ export declare const CAP_ACTION_UNLABELLED = "(unlabelled)";
142
+ /** Bytes of actions past {@link MAX_CAP_ACTIONS_PER_PEER} on one peer. */
143
+ export declare const CAP_ACTION_OVERFLOW_LABEL = "(overflow)";
144
+ /**
145
+ * Distinct named actions kept per peer before the overflow bucket takes over.
146
+ *
147
+ * Sixty-four against a measured worst case of 14 (`pipeline-analytics`, the
148
+ * heaviest peer on this hub, 2026-09-11) — four times the observed shape, so a
149
+ * peer has to change character before it overflows, and a bound that is never
150
+ * reached in practice still forbids the unbounded growth a wire-supplied key
151
+ * makes possible in principle.
152
+ */
153
+ export declare const MAX_CAP_ACTIONS_PER_PEER = 64;
154
+ /**
155
+ * `<cap>.<method>` for a cap envelope, `null` for anything else.
156
+ *
157
+ * Both directions are labelled by the same function: a child's `cap-call-out`
158
+ * and the parent's `cap-call` carry `capName` + `method` in the same two keys,
159
+ * so one predicate covers the whole plane. Anything else — a `register`, an
160
+ * `addon-call`, a log or an event — is deliberately unlabelled; see
161
+ * {@link CAP_ACTION_UNLABELLED} for where its bytes go.
162
+ *
163
+ * Cost: two property reads, two `typeof`s and one concatenation of two short
164
+ * strings, called once per `req` frame (not per frame). Against the
165
+ * `encodeFrame` it sits beside — 1 948–1 975 ns on a 0.33 kB body — it is
166
+ * noise, and unlike `recordSocketFrame` it is not on the `evt` path at all.
167
+ */
168
+ export declare function capActionLabel(body: unknown): string | null;
169
+ /** Which half of D448's census one call fell on. */
170
+ export type CapActionForwardKind = 'raw' | 'decoded';
171
+ /** The census as its writers see it. */
172
+ export interface CapActionCensus {
173
+ /** One completed request/response pair on `peerId`, charged to `action`. */
174
+ record(peerId: string, action: string, reqBytes: number, resBytes: number): void;
175
+ /** How `action`'s most recent call was routed — see {@link CapActionForwardKind}. */
176
+ recordForward(peerId: string, action: string, kind: CapActionForwardKind): void;
177
+ /** Bytes on a frame that carried no cap envelope. */
178
+ recordUnlabelled(peerId: string, bytes: number): void;
179
+ /** Drop a peer's counters when its channel closes for good. */
180
+ forget(peerId: string): void;
181
+ /** A COPY of the counters — see the module docblock on why it must be one. */
182
+ read(): CapActionReading;
183
+ }
184
+ export declare function createCapActionCensus(): CapActionCensus;
185
+ /** One (peer, action) pair over the window since the previous reading. */
186
+ export interface CapActionDelta {
187
+ readonly peerId: string;
188
+ readonly action: string;
189
+ readonly calls: number;
190
+ readonly reqBytes: number;
191
+ readonly resBytes: number;
192
+ readonly raw: number;
193
+ readonly decoded: number;
194
+ }
195
+ /** The census over one heartbeat window. */
196
+ export interface CapActionWindow {
197
+ /**
198
+ * Heaviest first by `reqBytes + resBytes`, at most the requested N.
199
+ *
200
+ * By BYTES and never by calls, for the reason D454 records: the flow this
201
+ * exists to name is few, enormous responses, and a ranking by count cannot
202
+ * see it by construction.
203
+ */
204
+ readonly top: readonly CapActionDelta[];
205
+ /** Bytes the census could put a cap method on, this window. */
206
+ readonly labelledBytes: number;
207
+ /** Every byte it saw, named or not. `labelled/total` is its own coverage. */
208
+ readonly totalBytes: number;
209
+ }
210
+ /**
211
+ * Subtract the previous reading from the current one.
212
+ *
213
+ * A peer whose runner RESPAWNED reconnects under the same id with counters
214
+ * that went backwards. Clamped at 0, exactly as `diffSocketPlane` clamps for
215
+ * the same reason: its traffic in that window is genuinely unknown, and a
216
+ * negative number on a rate reads as a broken meter.
217
+ */
218
+ export declare function diffCapActionCensus(before: CapActionReading, current: CapActionReading, topN: number): CapActionWindow;
@@ -0,0 +1,49 @@
1
+ import { ChildCapDescriptor } from './child-cap-protocol.js';
2
+ /**
3
+ * One connected child as the index reads it: its id and the cap manifest it
4
+ * registered. A structural subset of `LocalChildRegistry`'s own child entry,
5
+ * so the index can be built from `children.values()` directly and unit-tested
6
+ * without a socket.
7
+ */
8
+ export interface CapIndexEntry {
9
+ readonly childId: string;
10
+ readonly caps: readonly ChildCapDescriptor[];
11
+ }
12
+ /**
13
+ * The two questions `resolveChildId` asks, answered in O(1).
14
+ *
15
+ * Deliberately NOT "resolve a child": the operator's `setActiveSingleton`
16
+ * preference is read at CALL time from the capability registry and can change
17
+ * without any child connecting or leaving, so the index must not be allowed to
18
+ * hold an answer that depends on it. It holds only what the `children` map
19
+ * says, which is the same thing that invalidates it.
20
+ */
21
+ export interface CapChildIndex {
22
+ /** The FIRST child owning the exact `(capName, deviceId)` pair, or null. */
23
+ deviceOwner(capName: string, deviceId: number): string | null;
24
+ /**
25
+ * Every child owning a deviceId-LESS descriptor for `capName`, in children
26
+ * insertion order (so `[0]` is the first-registered), each named once.
27
+ */
28
+ singletonCandidates(capName: string): readonly string[];
29
+ }
30
+ /**
31
+ * Build the `(cap, device?) → child` index from the connected children.
32
+ *
33
+ * A PURE function of what it is handed — it captures nothing, subscribes to
34
+ * nothing and cannot be updated in place. That is the whole safety argument
35
+ * (D3, D457): the only way to change what it says is to build a new one from
36
+ * the `children` map, so an index can never name a child that map has already
37
+ * dropped. An incremental index — add on register, remove on close — would be
38
+ * a second registry that can disagree with the first, and a stale one routes
39
+ * to a dead runner, which is strictly worse than a slow scan.
40
+ *
41
+ * Cost: O(children × descriptors), paid once per mutation of the children map
42
+ * (a register, a re-register, a disconnect) rather than once per cap call.
43
+ *
44
+ * The two tiers stay separate, exactly as the scan had them: a device lookup
45
+ * is never answered from a deviceId-less descriptor. `resolveChildId` owns
46
+ * the fallback from tier 1 to tier 2, and folding it in here would make it
47
+ * unreachable.
48
+ */
49
+ export declare function buildCapChildIndex(entries: Iterable<CapIndexEntry>): CapChildIndex;
@@ -2,6 +2,8 @@ export type { Frame, RequestHandler, EventHandler, LocalChannel, LocalTransportS
2
2
  export { encodeFrame, FrameDecoder } from './frame-codec.js';
3
3
  export type { SocketDirectionCounters, SocketDirectionSample, SocketMessageKind, SocketTrafficSample, } from './socket-traffic.js';
4
4
  export { createSocketDirectionCounters, EMPTY_SOCKET_DIRECTION, recordSocketFrame, sampleSocketDirection, socketDirectionBytes, socketDirectionMessages, } from './socket-traffic.js';
5
+ export type { CapActionCensus, CapActionChannelObserver, CapActionDelta, CapActionReading, CapActionSample, CapActionWindow, } from './cap-action-census.js';
6
+ export { CAP_ACTION_OVERFLOW_LABEL, CAP_ACTION_UNLABELLED, capActionLabel, createCapActionCensus, diffCapActionCensus, EMPTY_CAP_ACTION_READING, MAX_CAP_ACTIONS_PER_PEER, } from './cap-action-census.js';
5
7
  export { localEndpointPath } from './local-endpoint-path.js';
6
8
  export { SocketChannel } from './socket-channel.js';
7
9
  export { UdsLocalTransportServer, UdsLocalTransportClient } from './uds-local-transport.js';
@@ -1,5 +1,6 @@
1
1
  import { IReadinessRegistryRecord, SystemEvent } from '@camstack/types';
2
2
  import { CapUsageCallRecord } from '../moleculer/cap-usage-registry.js';
3
+ import { CapActionReading } from './cap-action-census.js';
3
4
  import { CapRoutingHints } from './cap-routing-hints.js';
4
5
  import { AddonCallInput, CapCallInput, ChildLogMessage, RegisteredChild } from './child-cap-protocol.js';
5
6
  import { LocalTransportServer } from './local-transport.js';
@@ -19,6 +20,14 @@ export interface ForwardCensus {
19
20
  readonly raw: number;
20
21
  readonly decoded: number;
21
22
  }
23
+ /**
24
+ * Peer id the action census charges frames that arrived before `register`.
25
+ *
26
+ * One frame per connection, and it is named rather than dropped: a census is
27
+ * only worth its coverage, and coverage you cannot see is coverage you cannot
28
+ * check.
29
+ */
30
+ export declare const PRE_REGISTER_PEER_ID = "(pre-register)";
22
31
  /**
23
32
  * Fan-out mode for parent→child event delivery, from the
24
33
  * `CAMSTACK_UDS_EVENT_FANOUT` env (read once at registry construction):
@@ -225,6 +234,14 @@ export declare class LocalChildRegistry {
225
234
  /** See {@link ForwardCensus}. Mutable counters on the hot path, by design. */
226
235
  private forwardedRaw;
227
236
  private forwardedDecoded;
237
+ /**
238
+ * The per-ACTION byte census (D456). `ForwardCensus` above is the same
239
+ * question asked of the whole plane; this one asks it per `<cap>.<method>`,
240
+ * beside the bytes that method actually moved. Both are kept: `socketFwd=`
241
+ * says whether the negotiation is working at all, and a `raw=0` on ONE
242
+ * method with `raw>0` on its neighbours is a different fault.
243
+ */
244
+ private readonly actionCensus;
228
245
  /** See {@link LocalChildRegistryOptions.capTimeoutMs}. */
229
246
  private readonly capTimeoutMs?;
230
247
  /** See {@link LocalChildRegistryOptions.capUsageObserver}. */
@@ -237,6 +254,12 @@ export declare class LocalChildRegistry {
237
254
  private readonly childEventStats;
238
255
  /** Last pattern-set string logged per child, to dedup the INFO line. */
239
256
  private readonly loggedPatternSet;
257
+ /**
258
+ * Derived `(cap, device?) → child` index over {@link children}, or `null`
259
+ * when it must be rebuilt. See {@link capIndex} for why it is a cache and
260
+ * never a registry (D3, D457).
261
+ */
262
+ private capChildIndex;
240
263
  /**
241
264
  * Accepts either a plain positional `server` argument (backward-compatible)
242
265
  * or a full `LocalChildRegistryOptions` object.
@@ -277,6 +300,19 @@ export declare class LocalChildRegistry {
277
300
  getChildQueuedBytes(childId: string): number | null;
278
301
  /** Cumulative {@link ForwardCensus} of this parent's `cap-call-out` routing. */
279
302
  readForwardCensus(): ForwardCensus;
303
+ /**
304
+ * Cumulative per-action byte census: peer → `<cap>.<method>` → bytes, calls
305
+ * and the raw/decoded split. A COPY (`cap-action-census.ts` explains why it
306
+ * must be one); the heartbeat subtracts the previous reading.
307
+ *
308
+ * This is the instrument D454 closed by asking for. `socketTopBytes=` names
309
+ * the peer that moves the bytes and `txkB=` says they are responses; neither
310
+ * can say WHICH method originates them, and after raw-forward the bytes that
311
+ * cross are no longer the proxy for what hub-main pays — 85.7 % of sibling
312
+ * calls cross unread. What costs is what hub-main LOOKS at, and that is the
313
+ * `decoded` half of an entry, next to the bytes it carried.
314
+ */
315
+ readActionCensus(): CapActionReading;
280
316
  /**
281
317
  * The regime the counters above were produced under, read once from
282
318
  * `CAMSTACK_UDS_EVENT_FANOUT` at construction.
@@ -366,10 +402,33 @@ export declare class LocalChildRegistry {
366
402
  * is not the registry.
367
403
  */
368
404
  private recordCapUsage;
369
- /** First child whose cap manifest contains a descriptor matching `predicate`. */
370
- private findChildId;
371
- /** Every child whose cap manifest contains a descriptor matching `predicate`. */
372
- private findAllChildIds;
405
+ /**
406
+ * The cap index over the CURRENT children, rebuilt on first use after any
407
+ * mutation of {@link children}.
408
+ *
409
+ * This replaced a linear walk of every child's cap manifest per
410
+ * `cap-call-out`, measured at **7.1 % of hub-main's busy main thread**
411
+ * across 39 children (D456). Why it did not exist before is the whole
412
+ * design: what it indexes moves — a runner is replaced on every update, a
413
+ * crash respawns one, a child re-registers with a replacement manifest after
414
+ * init — and an index that names a dead child routes work into a socket
415
+ * nobody is reading, which is strictly worse than a slow scan.
416
+ *
417
+ * So it is not maintained; it is DISCARDED. `buildCapChildIndex` is a pure
418
+ * function of `children.values()`, the only two writers of that map
419
+ * ({@link invalidateCapIndex}'s call sites) drop the index in the same
420
+ * statement, and nothing else can construct one. There is no add-on-register
421
+ * / remove-on-close path that could disagree with the map — i.e. no shadow
422
+ * registry (D3): the map remains the single authority and this holds no fact
423
+ * it does not.
424
+ *
425
+ * The operator's active-singleton preference is deliberately NOT indexed —
426
+ * it changes without any child connecting or leaving, so `resolveChildId`
427
+ * reads it live and the index only supplies the candidate list.
428
+ */
429
+ private capIndex;
430
+ /** Drop the derived index. MUST follow every write to {@link children}. */
431
+ private invalidateCapIndex;
373
432
  /**
374
433
  * Does the named child currently provide `(capName, deviceId?)`?
375
434
  *
@@ -1,3 +1,4 @@
1
+ import { CapActionChannelObserver } from './cap-action-census.js';
1
2
  import { SocketTrafficSample } from './socket-traffic.js';
2
3
  /**
3
4
  * Wire frame exchanged over a local channel.
@@ -91,6 +92,14 @@ export interface LocalChannel {
91
92
  * unmeasured — never as zero (D393).
92
93
  */
93
94
  queuedBytes?(): number;
95
+ /**
96
+ * Wire the per-action byte census (`cap-action-census.ts`). OPTIONAL for the
97
+ * same reason as {@link readTraffic}: only a real socket has bytes to
98
+ * charge. A registry whose channel does not implement it keeps no per-action
99
+ * numbers for that peer, and the report omits the field rather than printing
100
+ * a zero (D393).
101
+ */
102
+ observeActions?(observer: CapActionChannelObserver): void;
94
103
  }
95
104
  /** Parent side: listens and hands a channel per accepted child connection. */
96
105
  export interface LocalTransportServer {
@@ -1,4 +1,5 @@
1
1
  import { Socket } from 'node:net';
2
+ import { CapActionChannelObserver } from './cap-action-census.js';
2
3
  import { EventHandler, LocalChannel, RequestHandler } from './local-transport.js';
3
4
  import { SocketTrafficSample } from './socket-traffic.js';
4
5
  /**
@@ -34,6 +35,8 @@ export declare class SocketChannel implements LocalChannel {
34
35
  private readonly txCounters;
35
36
  /** Frames read from the peer, cumulative, split by kind. */
36
37
  private readonly rxCounters;
38
+ /** See {@link CapActionChannelObserver}. Absent = no per-action census. */
39
+ private actionObserver;
37
40
  constructor(socket: Socket);
38
41
  /**
39
42
  * Send a request and wait for the peer's response.
@@ -48,6 +51,20 @@ export declare class SocketChannel implements LocalChannel {
48
51
  */
49
52
  request(body: unknown, opts?: SocketRequestOptions): Promise<unknown>;
50
53
  emit(body: unknown): void;
54
+ /**
55
+ * Wire the per-action census. Only one observer is active at a time; an
56
+ * unwired channel records nothing at all (never zeros).
57
+ */
58
+ observeActions(observer: CapActionChannelObserver): void;
59
+ /**
60
+ * The label for one envelope, or `undefined` when there is nothing to
61
+ * record. Wrapped: a broken labeller must never fail the call it watches —
62
+ * the same discipline `LocalChildRegistry.recordCapUsage` applies, and for
63
+ * the same reason.
64
+ */
65
+ private labelSafely;
66
+ /** Settle one request/response pair on the census. Never throws. */
67
+ private recordAction;
51
68
  /** Replace the request handler. Only one handler is active at a time. */
52
69
  onRequest(handler: RequestHandler): void;
53
70
  /** Replace the event handler. Only one handler is active at a time. */
@@ -78,6 +95,10 @@ export declare class SocketChannel implements LocalChannel {
78
95
  * are never copied into the head. A forwarded payload is a view into the
79
96
  * frame it arrived on, and that frame's buffer is never mutated, so handing
80
97
  * the view to the socket is safe.
98
+ *
99
+ * Returns the on-the-wire bytes it charged — the same number the per-kind
100
+ * counter got, so the per-action census can never disagree with the per-peer
101
+ * one. `0` when the channel is closed and nothing was written.
81
102
  */
82
103
  private send;
83
104
  private onData;