@camstack/types 1.2.115 → 1.2.117

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 (32) hide show
  1. package/dist/addon.js +4 -3
  2. package/dist/addon.mjs +4 -3
  3. package/dist/capabilities/data-store-provider.cap.d.ts +11 -0
  4. package/dist/capabilities/index.d.ts +6 -3
  5. package/dist/capabilities/load-contribution.cap.d.ts +146 -0
  6. package/dist/capabilities/metrics-provider.cap.d.ts +156 -38
  7. package/dist/capabilities/settings-store.cap.d.ts +48 -1
  8. package/dist/capabilities/system.cap.d.ts +67 -1
  9. package/dist/enums/event-category.d.ts +1 -1
  10. package/dist/enums.js +1 -1
  11. package/dist/enums.mjs +1 -1
  12. package/dist/{event-category-CIa_iT6b.mjs → event-category-BZL-fdNj.mjs} +1 -1
  13. package/dist/{event-category-EY0GNjV9.js → event-category-BaEgqJNv.js} +1 -1
  14. package/dist/generated/addon-api.d.ts +40 -3
  15. package/dist/generated/capability-router-map.d.ts +5 -2
  16. package/dist/generated/collection-array-methods.d.ts +1 -1
  17. package/dist/generated/method-access-map.d.ts +1 -1
  18. package/dist/generated/system-proxy.d.ts +3 -3
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.js +585 -36
  21. package/dist/index.mjs +575 -37
  22. package/dist/interfaces/addon.d.ts +28 -0
  23. package/dist/interfaces/event-bus.d.ts +1 -1
  24. package/dist/interfaces/metrics-provider.d.ts +5 -3
  25. package/dist/metrics/load-series-fold.d.ts +142 -0
  26. package/dist/node.d.ts +2 -0
  27. package/dist/node.js +191 -0
  28. package/dist/node.mjs +186 -1
  29. package/dist/process/child-cost-registry.d.ts +105 -0
  30. package/dist/{sleep-CSodb2vQ.js → sleep-9d8tJRbO.js} +1 -1
  31. package/dist/{sleep-CdbM8ge4.mjs → sleep-Dolp38qx.mjs} +1 -1
  32. package/package.json +1 -1
@@ -1178,6 +1178,34 @@ export interface AddonExecution {
1178
1178
  * `0` means "no ceiling for this addon" and, being the maximum, lifts the whole runner's cap.
1179
1179
  */
1180
1180
  readonly maxOldSpaceMb?: number;
1181
+ /**
1182
+ * Whole-process RSS budget in MB — the number the memory watchdog alarms on.
1183
+ *
1184
+ * This is NOT a second heap ceiling and it enforces nothing: it is the
1185
+ * footprint this addon's role considers legitimate, and crossing it emits one
1186
+ * WARN carrying the breakdown (`heapUsed` / `external` / native residue) so the
1187
+ * operator learns WHICH KIND of growth it is.
1188
+ *
1189
+ * It exists because every other bound is blind to most of a process.
1190
+ * {@link AddonExecution.maxOldSpaceMb} bounds V8's old space and nothing else,
1191
+ * and the watchdog's `stranded` metric subtracts `external` by construction.
1192
+ * `hub/pipeline-analytics` was OOM-killed at 11.4GB on 2026-08-27 with a
1193
+ * 1024MB old-space ceiling in force the entire time, holding a 536MB heap and
1194
+ * 4264MB of `external` — 96% of the growth was never V8's, and nothing warned.
1195
+ * RSS is what the host's OOM killer reads, so RSS is what needs a declared
1196
+ * ceiling.
1197
+ *
1198
+ * OMITTED (the common case) → this addon declares no budget. If NO co-located
1199
+ * addon declares one, the runner reports itself UNWATCHED once at boot; there
1200
+ * is deliberately no class default, because the legitimate footprint differs by
1201
+ * an order of magnitude by role (a recorder holding a 1.5GB segment index is
1202
+ * behaving correctly; analytics holding 1.5GB is not). Declare it from a
1203
+ * MEASURED peak RSS, never from a heap number.
1204
+ *
1205
+ * A runner's budget is the MAXIMUM declared by any co-located addon —
1206
+ * co-location must never silence the hungriest member (D2/D29).
1207
+ */
1208
+ readonly rssBudgetMb?: number;
1181
1209
  /**
1182
1210
  * `@camstack/system` builtin ONLY: run this builtin in its OWN forked runner
1183
1211
  * instead of in-process on the hub root.
@@ -1172,7 +1172,7 @@ export interface EventCatalog {
1172
1172
  };
1173
1173
  /**
1174
1174
  * Periodic per-node process-tree snapshot (`NodeProcess[]` —
1175
- * camstack-related pids with ghost / managed / root classification).
1175
+ * camstack-related pids with root / managed / system classification).
1176
1176
  * One event per node per tick. Replaces polling on
1177
1177
  * `metricsProvider.listNodeProcesses`.
1178
1178
  */
@@ -1,5 +1,5 @@
1
1
  import type { z } from 'zod';
2
- import type { CpuBreakdownSchema, MemoryInfoSchema, DiskIoSnapshotSchema, NetworkIoSnapshotSchema, MetricsGpuInfoSchema, ProcessResourceInfoSchema, PressureInfoSchema, SystemResourceSnapshotSchema, DiskSpaceInfoSchema, PidResourceStatsSchema, AddonInstanceSchema, NodeProcessSchema, KillProcessInputSchema, KillProcessResultSchema, DumpHeapSnapshotInputSchema, DumpHeapSnapshotResultSchema } from '../capabilities/metrics-provider.cap.js';
2
+ import type { CpuBreakdownSchema, MemoryInfoSchema, DiskIoSnapshotSchema, NetworkIoSnapshotSchema, MetricsGpuInfoSchema, ProcessResourceInfoSchema, PressureInfoSchema, SystemResourceSnapshotSchema, DiskSpaceInfoSchema, PidResourceStatsSchema, AddonInstanceSchema, NodeProcessSchema, DumpHeapSnapshotInputSchema, DumpHeapSnapshotResultSchema, LoadPointSchema, LoadFunctionSeriesSchema, NodeLoadSeriesSchema, GetLoadSeriesInputSchema } from '../capabilities/metrics-provider.cap.js';
3
3
  export type CpuBreakdown = z.infer<typeof CpuBreakdownSchema>;
4
4
  export type MemoryInfo = z.infer<typeof MemoryInfoSchema>;
5
5
  export type DiskIoSnapshot = z.infer<typeof DiskIoSnapshotSchema>;
@@ -12,7 +12,9 @@ export type DiskSpaceInfo = z.infer<typeof DiskSpaceInfoSchema>;
12
12
  export type PidResourceStats = z.infer<typeof PidResourceStatsSchema>;
13
13
  export type AddonInstance = z.infer<typeof AddonInstanceSchema>;
14
14
  export type NodeProcess = z.infer<typeof NodeProcessSchema>;
15
- export type KillProcessInput = z.infer<typeof KillProcessInputSchema>;
16
- export type KillProcessResult = z.infer<typeof KillProcessResultSchema>;
17
15
  export type DumpHeapSnapshotInput = z.infer<typeof DumpHeapSnapshotInputSchema>;
18
16
  export type DumpHeapSnapshotResult = z.infer<typeof DumpHeapSnapshotResultSchema>;
17
+ export type LoadSeriesPoint = z.infer<typeof LoadPointSchema>;
18
+ export type LoadFunctionSeries = z.infer<typeof LoadFunctionSeriesSchema>;
19
+ export type NodeLoadSeries = z.infer<typeof NodeLoadSeriesSchema>;
20
+ export type GetLoadSeriesInput = z.infer<typeof GetLoadSeriesInputSchema>;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Folding a process snapshot into per-FUNCTION load points, and reducing a
3
+ * long series to a drawable number of them WITHOUT losing a peak.
4
+ *
5
+ * ## Why this lives in `@camstack/types`
6
+ *
7
+ * Two processes need the identical arithmetic and neither may import the
8
+ * other: the hub's `native-metrics` folds the retained series before it leaves
9
+ * the node (server-side reduction — the browser must never be handed 2 160
10
+ * points per series to throw away), and the admin UI folds the LIVE
11
+ * `metrics.node-processes-snapshot` events it is already subscribed to. Two
12
+ * copies of a fold is two charts that disagree about the same node.
13
+ *
14
+ * Browser-safe by construction: no imports at all, so it stays on the root
15
+ * `@camstack/types` entry (the node-only surface is `@camstack/types/node`).
16
+ *
17
+ * ## The reduction, and why min/max rather than a mean
18
+ *
19
+ * Six hours at the 10 s cadence is 2 160 points per series. A chart a few
20
+ * hundred pixels wide has to reduce them, and the reduction is exactly where a
21
+ * chart lies:
22
+ *
23
+ * - a **mean per bucket** smears the peak across the bucket and it disappears;
24
+ * - **one sample every N** skips the peak outright.
25
+ *
26
+ * Either way the tool built to find peaks stops showing peaks. So a bucket
27
+ * carries `min`, `max` and the number of samples that landed in it, and the
28
+ * REPRESENTATIVE value of a bucket — the field the chart already reads — is
29
+ * the **max**. A mean is still derivable where it is wanted; a lost peak is
30
+ * not recoverable from anything.
31
+ *
32
+ * An unreduced series is a series of one-sample buckets, where `min === max`
33
+ * and every field equals the raw value. Reduced and unreduced are therefore
34
+ * the same shape, and the caller cannot tell which it is looking at — which is
35
+ * the whole reason there is one reader.
36
+ *
37
+ * ## An empty bucket stays empty
38
+ *
39
+ * A bucket with no samples is ABSENT from the output. It is never emitted with
40
+ * zeros, never interpolated, and never carried forward from the previous
41
+ * point. Emission is fixed-cadence, so a gap means one thing only: **nobody
42
+ * reported**. Filling it would destroy the single property that fixed cadence
43
+ * was adopted to buy.
44
+ */
45
+ /**
46
+ * One process row as both tiers carry it.
47
+ *
48
+ * Deliberately NOT the full `NodeProcess`. `command` is absent because it is
49
+ * the fattest field in a snapshot and the same identical string on every
50
+ * runner — the runner id travels in the environment, not in argv — while
51
+ * `addonId` is the short identifier that already names the process. Nothing
52
+ * that draws or reduces this series has ever read `command`.
53
+ */
54
+ export interface LoadProcessRow {
55
+ readonly pid: number;
56
+ readonly addonId: string | null;
57
+ readonly classification: string;
58
+ /** `ps pcpu` — a LIFETIME average, not a rate. Always present. */
59
+ readonly cpuPercent: number;
60
+ readonly memoryRssBytes: number;
61
+ /** Instantaneous main-thread CPU%, or `null` when unknown — never zero. */
62
+ readonly cpuMainPercent: number | null;
63
+ /** Instantaneous V8-helper-pool CPU%, or `null` under the same rule. */
64
+ readonly cpuGcPercent: number | null;
65
+ }
66
+ /**
67
+ * The bucket a process is attributed to.
68
+ *
69
+ * `addon` is the only attributed kind. `root` is the node's own supervisor and
70
+ * `unattributed` is everything the cluster cannot name. They are separate
71
+ * buckets rather than folded into a nearby addon precisely because nothing
72
+ * licenses attributing them.
73
+ */
74
+ export type LoadBucketKind = 'addon' | 'root' | 'unattributed';
75
+ /** Bucket key for rows no addon owns. Stable, so its series is continuous. */
76
+ export declare const UNATTRIBUTED_BUCKET_KEY = "__unattributed__";
77
+ export declare const ROOT_BUCKET_KEY = "__root__";
78
+ /**
79
+ * One point of one function's series.
80
+ *
81
+ * The unsuffixed fields are the bucket's **maximum** — the peak-preserving
82
+ * representative. `...Min` carries the other end so a caller can draw a band,
83
+ * and `samples` says how many raw snapshots the bucket folded. For an
84
+ * unreduced series `samples === 1` and each `...Min` equals its unsuffixed
85
+ * twin.
86
+ */
87
+ export interface LoadPoint {
88
+ /** Bucket START, or the snapshot's own timestamp when unreduced. */
89
+ readonly atMs: number;
90
+ /** Raw snapshots folded into this point. Never 0 — an empty bucket is absent. */
91
+ readonly samples: number;
92
+ /**
93
+ * Instantaneous CPU% of the function's own threads, or `null`.
94
+ *
95
+ * `null` PROPAGATES: a bucket is `null` unless every process in every
96
+ * snapshot it covers reported a split. Summing only the processes that
97
+ * answered produces a smaller number that is indistinguishable, on a chart,
98
+ * from a complete one — the exact failure this surface exists to prevent.
99
+ */
100
+ readonly cpuMainPercent: number | null;
101
+ readonly cpuMainPercentMin: number | null;
102
+ readonly cpuGcPercent: number | null;
103
+ readonly cpuGcPercentMin: number | null;
104
+ /** Lifetime-average CPU%, summed. Always known — and never a rate. */
105
+ readonly cpuLifetimePercent: number;
106
+ readonly cpuLifetimePercentMin: number;
107
+ readonly memoryRssBytes: number;
108
+ readonly memoryRssBytesMin: number;
109
+ readonly processCount: number;
110
+ readonly processCountMin: number;
111
+ }
112
+ export interface LoadBucket {
113
+ readonly key: string;
114
+ readonly kind: LoadBucketKind;
115
+ readonly point: LoadPoint;
116
+ }
117
+ /**
118
+ * Fold one snapshot into one point per function.
119
+ *
120
+ * Produces a ONE-SAMPLE point: `min === max` on every field, `samples === 1`.
121
+ * That is what lets a live event and a reduced server bucket sit in the same
122
+ * series without the consumer knowing which is which.
123
+ */
124
+ export declare function foldSnapshotByFunction(rows: readonly LoadProcessRow[], atMs: number): readonly LoadBucket[];
125
+ /**
126
+ * The bucket width that brings `spanMs` down to at most `maxPoints` points,
127
+ * snapped up to a whole multiple of the sampling cadence.
128
+ *
129
+ * Returns `cadenceMs` (no reduction) when the span already fits. A caller that
130
+ * asks for a `maxPoints` of 0 or less gets no reduction rather than an
131
+ * infinite bucket — a nonsensical request must not produce a plausible chart.
132
+ */
133
+ export declare function resolveBucketMs(spanMs: number, cadenceMs: number, maxPoints: number): number;
134
+ /**
135
+ * Reduce one function's points into buckets of `bucketMs`, preserving the
136
+ * extremes.
137
+ *
138
+ * Points are expected oldest-first and are returned oldest-first. A bucket
139
+ * with no samples is ABSENT — not zero, not interpolated, not the previous
140
+ * value held over.
141
+ */
142
+ export declare function reducePoints(points: readonly LoadPoint[], bucketMs: number, origin: number): readonly LoadPoint[];
package/dist/node.d.ts CHANGED
@@ -2,6 +2,8 @@ export { ensureBinary, downloadBinary, findInPath, getPlatformInfo, buildBinaryP
2
2
  export type { PlatformInfo } from './deps/binary-downloader.js';
3
3
  export { ensureFfmpeg, getFfmpegDownloadUrl } from './deps/ffmpeg-downloader.js';
4
4
  export { ensurePython, installPythonPackages, installPythonRequirements, getPythonDownloadUrl, PYTHON_VERSION, } from './deps/python-downloader.js';
5
+ export { ChildCostRegistry, NO_COST_CLAIM, nodeProcStatReader, parseProcCpuSeconds, parseProcRssBytes, readProcessCost, } from './process/child-cost-registry.js';
6
+ export type { ChildCostClaimHandle, ChildCostClaimInput, ProcStatReader, } from './process/child-cost-registry.js';
5
7
  export { FilesystemStorageProvider } from './storage/filesystem-storage-provider.js';
6
8
  export { canonicalHash } from './utils/canonical-hash.js';
7
9
  export { signExpiringUrl, verifyExpiringUrl } from './utils/expiring-url-signature.js';
package/dist/node.js CHANGED
@@ -31,6 +31,7 @@ node_path = __toESM(node_path);
31
31
  let node_stream_promises = require("node:stream/promises");
32
32
  let node_stream = require("node:stream");
33
33
  let node_child_process = require("node:child_process");
34
+ let node_fs_promises = require("node:fs/promises");
34
35
  //#region src/deps/binary-downloader.ts
35
36
  /**
36
37
  * Recursively find the first file named exactly `name` under `dir`. Used as the
@@ -457,6 +458,190 @@ async function installPythonRequirements(pythonPath, requirementsFile, logger) {
457
458
  } });
458
459
  }
459
460
  //#endregion
461
+ //#region src/process/child-cost-registry.ts
462
+ /**
463
+ * The registry an addon claims its OWN child processes in — the spawn-site
464
+ * half of `load-contribution.cap.ts`.
465
+ *
466
+ * ## Where a claim is made, and where it is released
467
+ *
468
+ * At the spawn site, and nowhere else. It is the only place that knows both
469
+ * halves: the recorder's controller knows it is starting ffmpeg for camera 615
470
+ * profile `high`; the decode coordinator knows the session it is forking a
471
+ * worker for. Nothing has to deduce it afterwards, and no entity needs a
472
+ * global list of which pid belongs to which camera.
473
+ *
474
+ * {@link ChildCostRegistry.claim} returns a HANDLE, and the handle is the only
475
+ * way to release. That is not decoration: the alternative — `release(pid)` —
476
+ * lets a late release from a dead generation delete the live claim of a
477
+ * process that inherited the same pid, which is precisely how a per-camera
478
+ * chart charges one camera for another camera's work. A handle can only ever
479
+ * remove the entry it created.
480
+ *
481
+ * ## What the registry does NOT do
482
+ *
483
+ * It keeps no history, no counters and no timers. It holds live claims and
484
+ * answers questions about them; when the process holding it dies, every claim
485
+ * in it dies with it, which is correct — those children were its children.
486
+ *
487
+ * ## The numbers
488
+ *
489
+ * {@link ChildCostRegistry.contributions} reads each claimed child's
490
+ * CUMULATIVE CPU seconds and resident bytes out of `/proc/<pid>` at the moment
491
+ * it is asked. On demand, never on a timer: a new periodic per-node sampler is
492
+ * the defect half of `docs/architecture/load-ledger.md` documents. A counter
493
+ * can be differenced by whoever already keeps a history; a rate cannot be
494
+ * un-averaged.
495
+ *
496
+ * Where `/proc` does not exist (macOS, Windows) or the read fails, the numbers
497
+ * are ABSENT — never zero. Zero would say the camera cost nothing.
498
+ */
499
+ /**
500
+ * Kernel jiffies per second (`USER_HZ`). Same constant, same reasoning, as
501
+ * `packages/system/src/builtins/native-metrics/thread-cpu-sampler.ts`:
502
+ * `sysconf(_SC_CLK_TCK)` is not exposed to Node and this has been 100 on every
503
+ * kernel configuration we ship to. A wrong value would scale every CPU number
504
+ * by a constant — visible immediately, not a silent skew.
505
+ */
506
+ var CLOCK_TICKS_PER_SEC = 100;
507
+ /** Bytes per page, for `/proc/<pid>/statm`'s page counts. */
508
+ var PAGE_BYTES = 4096;
509
+ /**
510
+ * The handle a spawn site gets when nobody is collecting — a test harness, or
511
+ * a runner built before its addon registered a registry. Reporting nothing is
512
+ * the correct behaviour: the process still appears in the node's process
513
+ * snapshot, unclaimed, which is exactly what "nobody reported this" should
514
+ * look like.
515
+ */
516
+ var NO_COST_CLAIM = { release: () => void 0 };
517
+ var nodeProcStatReader = {
518
+ readStat: (pid) => (0, node_fs_promises.readFile)(`/proc/${pid}/stat`, "utf8"),
519
+ readStatm: (pid) => (0, node_fs_promises.readFile)(`/proc/${pid}/statm`, "utf8")
520
+ };
521
+ /**
522
+ * Cumulative CPU seconds from a `/proc/<pid>/stat` line.
523
+ *
524
+ * The `comm` field is field 2, wrapped in parentheses, and can itself contain
525
+ * spaces and parentheses — so the only correct parse cuts at the LAST `)` and
526
+ * indexes from there. After the cut, field 3 (`state`) is index 0, so `utime`
527
+ * (field 14) is index 11 and `stime` (field 15) is index 12.
528
+ *
529
+ * `null` for a line that does not parse: a process that exited between the
530
+ * claim and the read leaves a truncated or empty file, and that is normal.
531
+ */
532
+ function parseProcCpuSeconds(line) {
533
+ const close = line.lastIndexOf(")");
534
+ if (close < 0) return null;
535
+ const rest = line.slice(close + 1).trim().split(/\s+/);
536
+ const utime = Number(rest[11]);
537
+ const stime = Number(rest[12]);
538
+ if (!Number.isFinite(utime) || !Number.isFinite(stime)) return null;
539
+ return (utime + stime) / CLOCK_TICKS_PER_SEC;
540
+ }
541
+ /**
542
+ * Resident bytes from a `/proc/<pid>/statm` line — field 2 is the resident set
543
+ * in pages. `null` when the line does not parse.
544
+ */
545
+ function parseProcRssBytes(line) {
546
+ const fields = line.trim().split(/\s+/);
547
+ const residentPages = Number(fields[1]);
548
+ if (!Number.isFinite(residentPages)) return null;
549
+ return residentPages * PAGE_BYTES;
550
+ }
551
+ /**
552
+ * What the OS will say about one process right now: cumulative CPU seconds and
553
+ * resident bytes, each ABSENT when it cannot be read.
554
+ *
555
+ * Exported because not every cost has a spawn site inside a registry. The
556
+ * shared inference pool is the case that forced it: its process is created
557
+ * deep inside the engine factory and belongs to no camera, so it is reported
558
+ * as an `unattributable` contribution built from the live pool's pids rather
559
+ * than from a claim — but its numbers must be read the same way, by the same
560
+ * parsers, or two "CPU seconds" in one payload would mean two things.
561
+ */
562
+ async function readProcessCost(pid, reader = nodeProcStatReader) {
563
+ let cpuSeconds = null;
564
+ let rssBytes = null;
565
+ try {
566
+ cpuSeconds = parseProcCpuSeconds(await reader.readStat(pid));
567
+ } catch {
568
+ cpuSeconds = null;
569
+ }
570
+ try {
571
+ rssBytes = parseProcRssBytes(await reader.readStatm(pid));
572
+ } catch {
573
+ rssBytes = null;
574
+ }
575
+ return {
576
+ ...cpuSeconds === null ? {} : { cpuSeconds },
577
+ ...rssBytes === null ? {} : { rssBytes }
578
+ };
579
+ }
580
+ var ChildCostRegistry = class {
581
+ reader;
582
+ now;
583
+ claims = /* @__PURE__ */ new Map();
584
+ constructor(reader = nodeProcStatReader, now = Date.now) {
585
+ this.reader = reader;
586
+ this.now = now;
587
+ }
588
+ /**
589
+ * Record one child. The returned handle is the only way to remove it.
590
+ *
591
+ * A claim with no pid is dropped rather than stored: it could carry no
592
+ * measurement, and an entry with a camera and no numbers reads on a chart as
593
+ * a camera that cost nothing.
594
+ */
595
+ claim(input) {
596
+ const pid = input.pid;
597
+ if (pid === void 0 || !Number.isInteger(pid) || pid <= 0) return NO_COST_CLAIM;
598
+ const key = Symbol("child-cost-claim");
599
+ this.claims.set(key, {
600
+ role: input.role,
601
+ deviceId: input.deviceId,
602
+ unit: input.unit,
603
+ attribution: input.attribution ?? "measured",
604
+ pid,
605
+ startedAtMs: this.now()
606
+ });
607
+ return { release: () => {
608
+ this.claims.delete(key);
609
+ } };
610
+ }
611
+ /** Live claims, in claim order. Diagnostics and tests; not a contribution. */
612
+ list() {
613
+ return [...this.claims.values()].map((c) => ({
614
+ role: c.role,
615
+ deviceId: c.deviceId,
616
+ unit: c.unit,
617
+ attribution: c.attribution,
618
+ pid: c.pid
619
+ }));
620
+ }
621
+ /**
622
+ * This addon's contribution: one entry per live claim, with whatever the OS
623
+ * will tell us about that child right now.
624
+ *
625
+ * A child that has exited between the claim and this read contributes an
626
+ * entry with no numbers rather than no entry — the unit exists, the addon
627
+ * believes it is running, and hiding it would make a dying writer look like
628
+ * a writer that was never started.
629
+ */
630
+ async contributions() {
631
+ const out = [];
632
+ for (const claim of this.claims.values()) out.push({
633
+ role: claim.role,
634
+ deviceId: claim.deviceId,
635
+ attribution: claim.attribution,
636
+ unit: claim.unit,
637
+ pid: claim.pid,
638
+ startedAtMs: claim.startedAtMs,
639
+ ...await readProcessCost(claim.pid, this.reader)
640
+ });
641
+ return out;
642
+ }
643
+ };
644
+ //#endregion
460
645
  //#region src/storage/filesystem-storage-provider.ts
461
646
  var STORAGE_LOCATION_TYPES = [
462
647
  "data",
@@ -1481,10 +1666,12 @@ var Fmp4FragmentChild = class {
1481
1666
  }
1482
1667
  };
1483
1668
  //#endregion
1669
+ exports.ChildCostRegistry = ChildCostRegistry;
1484
1670
  exports.FfmpegProcess = FfmpegProcess;
1485
1671
  exports.FilesystemStorageProvider = FilesystemStorageProvider;
1486
1672
  exports.Fmp4FragmentChild = Fmp4FragmentChild;
1487
1673
  exports.Fmp4FragmentPlane = Fmp4FragmentPlane;
1674
+ exports.NO_COST_CLAIM = NO_COST_CLAIM;
1488
1675
  exports.PYTHON_VERSION = PYTHON_VERSION;
1489
1676
  exports.buildBinaryPath = buildBinaryPath;
1490
1677
  exports.canonicalDeviceFingerprint = canonicalDeviceFingerprint;
@@ -1500,6 +1687,10 @@ exports.getPlatformInfo = getPlatformInfo;
1500
1687
  exports.getPythonDownloadUrl = getPythonDownloadUrl;
1501
1688
  exports.installPythonPackages = installPythonPackages;
1502
1689
  exports.installPythonRequirements = installPythonRequirements;
1690
+ exports.nodeProcStatReader = nodeProcStatReader;
1691
+ exports.parseProcCpuSeconds = parseProcCpuSeconds;
1692
+ exports.parseProcRssBytes = parseProcRssBytes;
1693
+ exports.readProcessCost = readProcessCost;
1503
1694
  exports.resolveExportFingerprint = resolveExportFingerprint;
1504
1695
  exports.signExpiringUrl = signExpiringUrl;
1505
1696
  exports.verifyExpiringUrl = verifyExpiringUrl;
package/dist/node.mjs CHANGED
@@ -8,6 +8,7 @@ import { basename, join } from "node:path";
8
8
  import { pipeline } from "node:stream/promises";
9
9
  import { Readable } from "node:stream";
10
10
  import { execFileSync, spawn } from "node:child_process";
11
+ import { readFile } from "node:fs/promises";
11
12
  //#region src/deps/binary-downloader.ts
12
13
  /**
13
14
  * Recursively find the first file named exactly `name` under `dir`. Used as the
@@ -434,6 +435,190 @@ async function installPythonRequirements(pythonPath, requirementsFile, logger) {
434
435
  } });
435
436
  }
436
437
  //#endregion
438
+ //#region src/process/child-cost-registry.ts
439
+ /**
440
+ * The registry an addon claims its OWN child processes in — the spawn-site
441
+ * half of `load-contribution.cap.ts`.
442
+ *
443
+ * ## Where a claim is made, and where it is released
444
+ *
445
+ * At the spawn site, and nowhere else. It is the only place that knows both
446
+ * halves: the recorder's controller knows it is starting ffmpeg for camera 615
447
+ * profile `high`; the decode coordinator knows the session it is forking a
448
+ * worker for. Nothing has to deduce it afterwards, and no entity needs a
449
+ * global list of which pid belongs to which camera.
450
+ *
451
+ * {@link ChildCostRegistry.claim} returns a HANDLE, and the handle is the only
452
+ * way to release. That is not decoration: the alternative — `release(pid)` —
453
+ * lets a late release from a dead generation delete the live claim of a
454
+ * process that inherited the same pid, which is precisely how a per-camera
455
+ * chart charges one camera for another camera's work. A handle can only ever
456
+ * remove the entry it created.
457
+ *
458
+ * ## What the registry does NOT do
459
+ *
460
+ * It keeps no history, no counters and no timers. It holds live claims and
461
+ * answers questions about them; when the process holding it dies, every claim
462
+ * in it dies with it, which is correct — those children were its children.
463
+ *
464
+ * ## The numbers
465
+ *
466
+ * {@link ChildCostRegistry.contributions} reads each claimed child's
467
+ * CUMULATIVE CPU seconds and resident bytes out of `/proc/<pid>` at the moment
468
+ * it is asked. On demand, never on a timer: a new periodic per-node sampler is
469
+ * the defect half of `docs/architecture/load-ledger.md` documents. A counter
470
+ * can be differenced by whoever already keeps a history; a rate cannot be
471
+ * un-averaged.
472
+ *
473
+ * Where `/proc` does not exist (macOS, Windows) or the read fails, the numbers
474
+ * are ABSENT — never zero. Zero would say the camera cost nothing.
475
+ */
476
+ /**
477
+ * Kernel jiffies per second (`USER_HZ`). Same constant, same reasoning, as
478
+ * `packages/system/src/builtins/native-metrics/thread-cpu-sampler.ts`:
479
+ * `sysconf(_SC_CLK_TCK)` is not exposed to Node and this has been 100 on every
480
+ * kernel configuration we ship to. A wrong value would scale every CPU number
481
+ * by a constant — visible immediately, not a silent skew.
482
+ */
483
+ var CLOCK_TICKS_PER_SEC = 100;
484
+ /** Bytes per page, for `/proc/<pid>/statm`'s page counts. */
485
+ var PAGE_BYTES = 4096;
486
+ /**
487
+ * The handle a spawn site gets when nobody is collecting — a test harness, or
488
+ * a runner built before its addon registered a registry. Reporting nothing is
489
+ * the correct behaviour: the process still appears in the node's process
490
+ * snapshot, unclaimed, which is exactly what "nobody reported this" should
491
+ * look like.
492
+ */
493
+ var NO_COST_CLAIM = { release: () => void 0 };
494
+ var nodeProcStatReader = {
495
+ readStat: (pid) => readFile(`/proc/${pid}/stat`, "utf8"),
496
+ readStatm: (pid) => readFile(`/proc/${pid}/statm`, "utf8")
497
+ };
498
+ /**
499
+ * Cumulative CPU seconds from a `/proc/<pid>/stat` line.
500
+ *
501
+ * The `comm` field is field 2, wrapped in parentheses, and can itself contain
502
+ * spaces and parentheses — so the only correct parse cuts at the LAST `)` and
503
+ * indexes from there. After the cut, field 3 (`state`) is index 0, so `utime`
504
+ * (field 14) is index 11 and `stime` (field 15) is index 12.
505
+ *
506
+ * `null` for a line that does not parse: a process that exited between the
507
+ * claim and the read leaves a truncated or empty file, and that is normal.
508
+ */
509
+ function parseProcCpuSeconds(line) {
510
+ const close = line.lastIndexOf(")");
511
+ if (close < 0) return null;
512
+ const rest = line.slice(close + 1).trim().split(/\s+/);
513
+ const utime = Number(rest[11]);
514
+ const stime = Number(rest[12]);
515
+ if (!Number.isFinite(utime) || !Number.isFinite(stime)) return null;
516
+ return (utime + stime) / CLOCK_TICKS_PER_SEC;
517
+ }
518
+ /**
519
+ * Resident bytes from a `/proc/<pid>/statm` line — field 2 is the resident set
520
+ * in pages. `null` when the line does not parse.
521
+ */
522
+ function parseProcRssBytes(line) {
523
+ const fields = line.trim().split(/\s+/);
524
+ const residentPages = Number(fields[1]);
525
+ if (!Number.isFinite(residentPages)) return null;
526
+ return residentPages * PAGE_BYTES;
527
+ }
528
+ /**
529
+ * What the OS will say about one process right now: cumulative CPU seconds and
530
+ * resident bytes, each ABSENT when it cannot be read.
531
+ *
532
+ * Exported because not every cost has a spawn site inside a registry. The
533
+ * shared inference pool is the case that forced it: its process is created
534
+ * deep inside the engine factory and belongs to no camera, so it is reported
535
+ * as an `unattributable` contribution built from the live pool's pids rather
536
+ * than from a claim — but its numbers must be read the same way, by the same
537
+ * parsers, or two "CPU seconds" in one payload would mean two things.
538
+ */
539
+ async function readProcessCost(pid, reader = nodeProcStatReader) {
540
+ let cpuSeconds = null;
541
+ let rssBytes = null;
542
+ try {
543
+ cpuSeconds = parseProcCpuSeconds(await reader.readStat(pid));
544
+ } catch {
545
+ cpuSeconds = null;
546
+ }
547
+ try {
548
+ rssBytes = parseProcRssBytes(await reader.readStatm(pid));
549
+ } catch {
550
+ rssBytes = null;
551
+ }
552
+ return {
553
+ ...cpuSeconds === null ? {} : { cpuSeconds },
554
+ ...rssBytes === null ? {} : { rssBytes }
555
+ };
556
+ }
557
+ var ChildCostRegistry = class {
558
+ reader;
559
+ now;
560
+ claims = /* @__PURE__ */ new Map();
561
+ constructor(reader = nodeProcStatReader, now = Date.now) {
562
+ this.reader = reader;
563
+ this.now = now;
564
+ }
565
+ /**
566
+ * Record one child. The returned handle is the only way to remove it.
567
+ *
568
+ * A claim with no pid is dropped rather than stored: it could carry no
569
+ * measurement, and an entry with a camera and no numbers reads on a chart as
570
+ * a camera that cost nothing.
571
+ */
572
+ claim(input) {
573
+ const pid = input.pid;
574
+ if (pid === void 0 || !Number.isInteger(pid) || pid <= 0) return NO_COST_CLAIM;
575
+ const key = Symbol("child-cost-claim");
576
+ this.claims.set(key, {
577
+ role: input.role,
578
+ deviceId: input.deviceId,
579
+ unit: input.unit,
580
+ attribution: input.attribution ?? "measured",
581
+ pid,
582
+ startedAtMs: this.now()
583
+ });
584
+ return { release: () => {
585
+ this.claims.delete(key);
586
+ } };
587
+ }
588
+ /** Live claims, in claim order. Diagnostics and tests; not a contribution. */
589
+ list() {
590
+ return [...this.claims.values()].map((c) => ({
591
+ role: c.role,
592
+ deviceId: c.deviceId,
593
+ unit: c.unit,
594
+ attribution: c.attribution,
595
+ pid: c.pid
596
+ }));
597
+ }
598
+ /**
599
+ * This addon's contribution: one entry per live claim, with whatever the OS
600
+ * will tell us about that child right now.
601
+ *
602
+ * A child that has exited between the claim and this read contributes an
603
+ * entry with no numbers rather than no entry — the unit exists, the addon
604
+ * believes it is running, and hiding it would make a dying writer look like
605
+ * a writer that was never started.
606
+ */
607
+ async contributions() {
608
+ const out = [];
609
+ for (const claim of this.claims.values()) out.push({
610
+ role: claim.role,
611
+ deviceId: claim.deviceId,
612
+ attribution: claim.attribution,
613
+ unit: claim.unit,
614
+ pid: claim.pid,
615
+ startedAtMs: claim.startedAtMs,
616
+ ...await readProcessCost(claim.pid, this.reader)
617
+ });
618
+ return out;
619
+ }
620
+ };
621
+ //#endregion
437
622
  //#region src/storage/filesystem-storage-provider.ts
438
623
  var STORAGE_LOCATION_TYPES = [
439
624
  "data",
@@ -1458,4 +1643,4 @@ var Fmp4FragmentChild = class {
1458
1643
  }
1459
1644
  };
1460
1645
  //#endregion
1461
- export { FfmpegProcess, FilesystemStorageProvider, Fmp4FragmentChild, Fmp4FragmentPlane, PYTHON_VERSION, buildBinaryPath, canonicalDeviceFingerprint, canonicalHash, diffExportTargets, downloadBinary, ensureBinary, ensureFfmpeg, ensurePython, findInPath, getFfmpegDownloadUrl, getPlatformInfo, getPythonDownloadUrl, installPythonPackages, installPythonRequirements, resolveExportFingerprint, signExpiringUrl, verifyExpiringUrl };
1646
+ export { ChildCostRegistry, FfmpegProcess, FilesystemStorageProvider, Fmp4FragmentChild, Fmp4FragmentPlane, NO_COST_CLAIM, PYTHON_VERSION, buildBinaryPath, canonicalDeviceFingerprint, canonicalHash, diffExportTargets, downloadBinary, ensureBinary, ensureFfmpeg, ensurePython, findInPath, getFfmpegDownloadUrl, getPlatformInfo, getPythonDownloadUrl, installPythonPackages, installPythonRequirements, nodeProcStatReader, parseProcCpuSeconds, parseProcRssBytes, readProcessCost, resolveExportFingerprint, signExpiringUrl, verifyExpiringUrl };