@camstack/addon-pipeline 1.1.61 → 1.1.63

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/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +2 -2
  4. package/dist/detection-pipeline/index.mjs +2 -2
  5. package/dist/{dist-p_JqOxtk.js → dist-B2jTt7Lq.js} +377 -3
  6. package/dist/{dist-D16_dBsr.mjs → dist-BzVZ2I4P.mjs} +372 -4
  7. package/dist/motion-wasm/index.js +1 -1
  8. package/dist/motion-wasm/index.mjs +1 -1
  9. package/dist/pipeline-runner/index.js +126 -3
  10. package/dist/pipeline-runner/index.mjs +126 -3
  11. package/dist/recorder/index.js +161 -20
  12. package/dist/recorder/index.mjs +161 -20
  13. package/dist/{step-definitions-DxAcpUV-.mjs → step-definitions-B07M9qhj.mjs} +68 -1
  14. package/dist/{step-definitions-CoxF5G31.js → step-definitions-DXkY5Zor.js} +68 -1
  15. package/dist/stream-broker/_stub.js +2 -2
  16. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Wu4TlSuq.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-dTMki_1D.mjs} +3 -3
  17. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-bAh96WNb.mjs +26 -0
  18. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CV2WbUaO.mjs +26 -0
  19. package/dist/stream-broker/{hostInit-D_dSciqF.mjs → hostInit-BY2oQAEX.mjs} +3 -3
  20. package/dist/stream-broker/index.js +1 -1
  21. package/dist/stream-broker/index.mjs +1 -1
  22. package/dist/stream-broker/remoteEntry.js +1 -1
  23. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-BldL9_Ee.js → MaskShapeCanvas-DI4BY7W2-C03fx7Sp.js} +1 -1
  24. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-BgWqMe4f.js → MotionZonesSettings-NcxxQN8r-BTxzn1rQ.js} +1 -1
  25. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-YBgaXffP.js → PrivacyMaskSettings-APgPLF7p-GKO8O7rC.js} +1 -1
  26. package/embed-dist/assets/index-Bm9LaQPH.css +2 -0
  27. package/embed-dist/assets/{index-e0bQ8yep.js → index-QdssILAA.js} +10 -10
  28. package/embed-dist/index.html +2 -2
  29. package/package.json +1 -1
  30. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BPujpbZ0.mjs +0 -26
  31. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DGxcX3Wf.mjs +0 -26
  32. package/embed-dist/assets/index-Bmpl9O1K.css +0 -2
@@ -3,11 +3,11 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-D6vf50IK.js");
6
- const require_dist = require("../dist-p_JqOxtk.js");
6
+ const require_dist = require("../dist-B2jTt7Lq.js");
7
7
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
8
8
  const require_sheet_geometry = require("../sheet-geometry-BR-ip0CP.js");
9
9
  const require_worker_protocol = require("../worker-protocol-PP4jKHHJ.js");
10
- const require_step_definitions = require("../step-definitions-CoxF5G31.js");
10
+ const require_step_definitions = require("../step-definitions-DXkY5Zor.js");
11
11
  let node_child_process = require("node:child_process");
12
12
  let node_url = require("node:url");
13
13
  let sharp = require("sharp");
@@ -106,6 +106,109 @@ var pipelineRunnerBenchActions = require_dist.defineCustomActions({
106
106
  runSyntheticBench: require_dist.customAction(RunSyntheticBenchInputSchema, RunSyntheticBenchResultSchema, { kind: "mutation" })
107
107
  });
108
108
  //#endregion
109
+ //#region src/pipeline-runner/motion-settle-governor.ts
110
+ /**
111
+ * MotionSettleGovernor — decides WHEN the root-plane `package-detection` step
112
+ * should run, so it is NOT executed on every frame.
113
+ *
114
+ * A delivered package is STATIONARY: per-frame inference on it is wasted cost.
115
+ * The interesting moments are the EDGES — someone just set a parcel down, or
116
+ * just picked one up — which coincide with motion in the package zone
117
+ * SETTLING (transitioning from moving to still). This governor fires:
118
+ *
119
+ * - on a **settle edge**: motion was observed, then has been absent for
120
+ * `settleMs` (kills flicker between motion frames and the "bag briefly
121
+ * set down then snatched back" case), and
122
+ * - on a **low-cadence safety poll** (`pollIntervalMs`, ~30–60s): a
123
+ * backstop so a delivery whose motion was never observed (a session-gap
124
+ * camera that only decodes intermittently) is still eventually caught.
125
+ *
126
+ * Pure logic with an injected clock (every method takes `now`), mirroring
127
+ * `FrameGovernor` / `recomputeGovernor(now)` — no timers, no `Date.now()`
128
+ * inside, fully unit-testable. Per-device state lives in a Map; the caller
129
+ * consults it only while the step is enabled and calls `forget()` on detach.
130
+ *
131
+ * NOTE: today `motionInZone` is the whole-frame analyzer motion signal the
132
+ * runner already tracks (`lastAnalyzerDetected`). Scoping it to the package
133
+ * zone specifically (intersecting motion regions with the `package`-stage
134
+ * zones) is a refinement that lands with the crop-zone frame-prep follow-up,
135
+ * where the package zones first become available at the frame layer. The
136
+ * governor contract does not change when that happens — only the boolean the
137
+ * caller feeds it.
138
+ *
139
+ * See docs/superpowers/specs/2026-07-17-package-zones-design.md
140
+ * (ADDENDUM 2026-07-17 — motion-settle trigger).
141
+ */
142
+ /** The step id (`PipelineStepInput.addonId`) the governor gates. */
143
+ var PACKAGE_DETECTION_STEP_ID = "package-detection";
144
+ var DEFAULT_MOTION_SETTLE_CONFIG = {
145
+ settleMs: 3e3,
146
+ pollIntervalMs: 45e3
147
+ };
148
+ var INITIAL_STATE = {
149
+ lastMotionAt: null,
150
+ armed: false,
151
+ lastRunAt: Number.NEGATIVE_INFINITY
152
+ };
153
+ var NO_RUN = {
154
+ run: false,
155
+ reason: null
156
+ };
157
+ var MotionSettleGovernor = class {
158
+ config;
159
+ state = /* @__PURE__ */ new Map();
160
+ constructor(config = DEFAULT_MOTION_SETTLE_CONFIG) {
161
+ this.config = config;
162
+ }
163
+ /**
164
+ * Decide whether `package-detection` runs for this device this frame, and
165
+ * advance the per-device state immutably. Never throws.
166
+ */
167
+ evaluate(sample) {
168
+ const prev = this.state.get(sample.deviceId) ?? INITIAL_STATE;
169
+ const lastMotionAt = sample.motionInZone ? sample.now : prev.lastMotionAt;
170
+ const armed = sample.motionInZone ? true : prev.armed;
171
+ const settleReady = armed && !sample.motionInZone && lastMotionAt !== null && sample.now - lastMotionAt >= this.config.settleMs;
172
+ const pollReady = sample.now - prev.lastRunAt >= this.config.pollIntervalMs;
173
+ if (settleReady) {
174
+ this.state.set(sample.deviceId, {
175
+ lastMotionAt,
176
+ armed: false,
177
+ lastRunAt: sample.now
178
+ });
179
+ return {
180
+ run: true,
181
+ reason: "settle"
182
+ };
183
+ }
184
+ if (pollReady) {
185
+ this.state.set(sample.deviceId, {
186
+ lastMotionAt,
187
+ armed,
188
+ lastRunAt: sample.now
189
+ });
190
+ return {
191
+ run: true,
192
+ reason: "poll"
193
+ };
194
+ }
195
+ this.state.set(sample.deviceId, {
196
+ lastMotionAt,
197
+ armed,
198
+ lastRunAt: prev.lastRunAt
199
+ });
200
+ return NO_RUN;
201
+ }
202
+ /** Drop a device's state (call on detach / step-disable) to avoid a leak. */
203
+ forget(deviceId) {
204
+ this.state.delete(deviceId);
205
+ }
206
+ /** Clear all state (e.g. runner teardown). */
207
+ reset() {
208
+ this.state.clear();
209
+ }
210
+ };
211
+ //#endregion
109
212
  //#region src/pipeline-runner/frame-governor.ts
110
213
  var FrameGovernor = class {
111
214
  config;
@@ -2401,6 +2504,13 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2401
2504
  * only (otherwise we'd emit on every analyzer frame). */
2402
2505
  lastAnalyzerDetected = /* @__PURE__ */ new Map();
2403
2506
  /**
2507
+ * Gates the root-plane `package-detection` step to motion-settle edges +
2508
+ * a low-cadence safety poll instead of every frame (packages are
2509
+ * stationary — per-frame parcel inference is wasted cost). Consulted in
2510
+ * `runInference` only when the step is enabled; state dropped on detach.
2511
+ */
2512
+ packageGovernor = new MotionSettleGovernor();
2513
+ /**
2404
2514
  * Last positive motion timestamp per device — preserved across the
2405
2515
  * OFF transition so the motion runtime-state slice keeps a stable
2406
2516
  * `lastDetectedAt` after the cooldown closes the phase. Cleared on
@@ -2584,6 +2694,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2584
2694
  this.unsubMotionEvents = null;
2585
2695
  }
2586
2696
  this.lastAnalyzerDetected.clear();
2697
+ this.packageGovernor.reset();
2587
2698
  for (const deviceId of [...this.onboardAnalyzerTeardownTimers.keys(), ...this.onboardAnalyzerSubs.keys()]) this.clearOnboardAnalyzer(deviceId);
2588
2699
  if (this.runner) {
2589
2700
  this.runner.stop();
@@ -3197,6 +3308,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3197
3308
  this.lastMotionFrameAt.delete(deviceId);
3198
3309
  this.lastDecodeUnexpectedEndAt.delete(deviceId);
3199
3310
  this.thumbSampler?.forget(deviceId);
3311
+ this.packageGovernor.forget(deviceId);
3200
3312
  this.attached.delete(deviceId);
3201
3313
  this.lastMotionAt.delete(deviceId);
3202
3314
  this.lastEmittedCameraMetrics.delete(deviceId);
@@ -3615,6 +3727,17 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3615
3727
  return null;
3616
3728
  }
3617
3729
  if (steps.length === 0) return null;
3730
+ let dispatchSteps = steps;
3731
+ if (steps.some((s) => s.addonId === "package-detection" && s.enabled !== false)) {
3732
+ if (!this.packageGovernor.evaluate({
3733
+ deviceId,
3734
+ motionInZone: this.lastAnalyzerDetected.get(deviceId) ?? false,
3735
+ now: Date.now()
3736
+ }).run) {
3737
+ dispatchSteps = steps.filter((s) => s.addonId !== PACKAGE_DETECTION_STEP_ID);
3738
+ if (dispatchSteps.length === 0) return null;
3739
+ }
3740
+ }
3618
3741
  let image;
3619
3742
  if (!this.executorCoLocated()) {
3620
3743
  if (frame.format === "rgb") try {
@@ -3629,7 +3752,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3629
3752
  }
3630
3753
  try {
3631
3754
  return await api.pipelineExecutor.runPipeline.mutate({
3632
- steps: [...steps],
3755
+ steps: [...dispatchSteps],
3633
3756
  ...image !== void 0 ? { image } : { frame },
3634
3757
  deviceId,
3635
3758
  plane: "frame"
@@ -1,8 +1,8 @@
1
- import { $ as _enum, A as pipelineRunnerCapability, B as BaseAddon, O as nodePin, W as createEvent, at as number, b as defineCustomActions, ct as string, et as array, ot as object, rt as lazy, tt as boolean, ut as EventCategory, v as customAction, z as errMsg } from "../dist-D16_dBsr.mjs";
1
+ import { B as errMsg, G as createEvent, V as BaseAddon, dt as EventCategory, et as _enum, it as lazy, j as pipelineRunnerCapability, k as nodePin, lt as string, nt as boolean, ot as number, st as object, tt as array, x as defineCustomActions, y as customAction } from "../dist-BzVZ2I4P.mjs";
2
2
  import { n as profileForStreamId, t as isRemoteRestream } from "../remote-restream-BeHi78PZ.mjs";
3
3
  import { r as resolveHubHostname } from "../sheet-geometry-DcpuGTCm.mjs";
4
4
  import { t as isWorkerReply } from "../worker-protocol-CyVJTZEO.mjs";
5
- import { a as getStepDefinition } from "../step-definitions-DxAcpUV-.mjs";
5
+ import { a as getStepDefinition } from "../step-definitions-B07M9qhj.mjs";
6
6
  import { fork } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import sharp from "sharp";
@@ -100,6 +100,109 @@ var pipelineRunnerBenchActions = defineCustomActions({
100
100
  runSyntheticBench: customAction(RunSyntheticBenchInputSchema, RunSyntheticBenchResultSchema, { kind: "mutation" })
101
101
  });
102
102
  //#endregion
103
+ //#region src/pipeline-runner/motion-settle-governor.ts
104
+ /**
105
+ * MotionSettleGovernor — decides WHEN the root-plane `package-detection` step
106
+ * should run, so it is NOT executed on every frame.
107
+ *
108
+ * A delivered package is STATIONARY: per-frame inference on it is wasted cost.
109
+ * The interesting moments are the EDGES — someone just set a parcel down, or
110
+ * just picked one up — which coincide with motion in the package zone
111
+ * SETTLING (transitioning from moving to still). This governor fires:
112
+ *
113
+ * - on a **settle edge**: motion was observed, then has been absent for
114
+ * `settleMs` (kills flicker between motion frames and the "bag briefly
115
+ * set down then snatched back" case), and
116
+ * - on a **low-cadence safety poll** (`pollIntervalMs`, ~30–60s): a
117
+ * backstop so a delivery whose motion was never observed (a session-gap
118
+ * camera that only decodes intermittently) is still eventually caught.
119
+ *
120
+ * Pure logic with an injected clock (every method takes `now`), mirroring
121
+ * `FrameGovernor` / `recomputeGovernor(now)` — no timers, no `Date.now()`
122
+ * inside, fully unit-testable. Per-device state lives in a Map; the caller
123
+ * consults it only while the step is enabled and calls `forget()` on detach.
124
+ *
125
+ * NOTE: today `motionInZone` is the whole-frame analyzer motion signal the
126
+ * runner already tracks (`lastAnalyzerDetected`). Scoping it to the package
127
+ * zone specifically (intersecting motion regions with the `package`-stage
128
+ * zones) is a refinement that lands with the crop-zone frame-prep follow-up,
129
+ * where the package zones first become available at the frame layer. The
130
+ * governor contract does not change when that happens — only the boolean the
131
+ * caller feeds it.
132
+ *
133
+ * See docs/superpowers/specs/2026-07-17-package-zones-design.md
134
+ * (ADDENDUM 2026-07-17 — motion-settle trigger).
135
+ */
136
+ /** The step id (`PipelineStepInput.addonId`) the governor gates. */
137
+ var PACKAGE_DETECTION_STEP_ID = "package-detection";
138
+ var DEFAULT_MOTION_SETTLE_CONFIG = {
139
+ settleMs: 3e3,
140
+ pollIntervalMs: 45e3
141
+ };
142
+ var INITIAL_STATE = {
143
+ lastMotionAt: null,
144
+ armed: false,
145
+ lastRunAt: Number.NEGATIVE_INFINITY
146
+ };
147
+ var NO_RUN = {
148
+ run: false,
149
+ reason: null
150
+ };
151
+ var MotionSettleGovernor = class {
152
+ config;
153
+ state = /* @__PURE__ */ new Map();
154
+ constructor(config = DEFAULT_MOTION_SETTLE_CONFIG) {
155
+ this.config = config;
156
+ }
157
+ /**
158
+ * Decide whether `package-detection` runs for this device this frame, and
159
+ * advance the per-device state immutably. Never throws.
160
+ */
161
+ evaluate(sample) {
162
+ const prev = this.state.get(sample.deviceId) ?? INITIAL_STATE;
163
+ const lastMotionAt = sample.motionInZone ? sample.now : prev.lastMotionAt;
164
+ const armed = sample.motionInZone ? true : prev.armed;
165
+ const settleReady = armed && !sample.motionInZone && lastMotionAt !== null && sample.now - lastMotionAt >= this.config.settleMs;
166
+ const pollReady = sample.now - prev.lastRunAt >= this.config.pollIntervalMs;
167
+ if (settleReady) {
168
+ this.state.set(sample.deviceId, {
169
+ lastMotionAt,
170
+ armed: false,
171
+ lastRunAt: sample.now
172
+ });
173
+ return {
174
+ run: true,
175
+ reason: "settle"
176
+ };
177
+ }
178
+ if (pollReady) {
179
+ this.state.set(sample.deviceId, {
180
+ lastMotionAt,
181
+ armed,
182
+ lastRunAt: sample.now
183
+ });
184
+ return {
185
+ run: true,
186
+ reason: "poll"
187
+ };
188
+ }
189
+ this.state.set(sample.deviceId, {
190
+ lastMotionAt,
191
+ armed,
192
+ lastRunAt: prev.lastRunAt
193
+ });
194
+ return NO_RUN;
195
+ }
196
+ /** Drop a device's state (call on detach / step-disable) to avoid a leak. */
197
+ forget(deviceId) {
198
+ this.state.delete(deviceId);
199
+ }
200
+ /** Clear all state (e.g. runner teardown). */
201
+ reset() {
202
+ this.state.clear();
203
+ }
204
+ };
205
+ //#endregion
103
206
  //#region src/pipeline-runner/frame-governor.ts
104
207
  var FrameGovernor = class {
105
208
  config;
@@ -2395,6 +2498,13 @@ var PipelineRunnerAddon = class extends BaseAddon {
2395
2498
  * only (otherwise we'd emit on every analyzer frame). */
2396
2499
  lastAnalyzerDetected = /* @__PURE__ */ new Map();
2397
2500
  /**
2501
+ * Gates the root-plane `package-detection` step to motion-settle edges +
2502
+ * a low-cadence safety poll instead of every frame (packages are
2503
+ * stationary — per-frame parcel inference is wasted cost). Consulted in
2504
+ * `runInference` only when the step is enabled; state dropped on detach.
2505
+ */
2506
+ packageGovernor = new MotionSettleGovernor();
2507
+ /**
2398
2508
  * Last positive motion timestamp per device — preserved across the
2399
2509
  * OFF transition so the motion runtime-state slice keeps a stable
2400
2510
  * `lastDetectedAt` after the cooldown closes the phase. Cleared on
@@ -2578,6 +2688,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
2578
2688
  this.unsubMotionEvents = null;
2579
2689
  }
2580
2690
  this.lastAnalyzerDetected.clear();
2691
+ this.packageGovernor.reset();
2581
2692
  for (const deviceId of [...this.onboardAnalyzerTeardownTimers.keys(), ...this.onboardAnalyzerSubs.keys()]) this.clearOnboardAnalyzer(deviceId);
2582
2693
  if (this.runner) {
2583
2694
  this.runner.stop();
@@ -3191,6 +3302,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
3191
3302
  this.lastMotionFrameAt.delete(deviceId);
3192
3303
  this.lastDecodeUnexpectedEndAt.delete(deviceId);
3193
3304
  this.thumbSampler?.forget(deviceId);
3305
+ this.packageGovernor.forget(deviceId);
3194
3306
  this.attached.delete(deviceId);
3195
3307
  this.lastMotionAt.delete(deviceId);
3196
3308
  this.lastEmittedCameraMetrics.delete(deviceId);
@@ -3609,6 +3721,17 @@ var PipelineRunnerAddon = class extends BaseAddon {
3609
3721
  return null;
3610
3722
  }
3611
3723
  if (steps.length === 0) return null;
3724
+ let dispatchSteps = steps;
3725
+ if (steps.some((s) => s.addonId === "package-detection" && s.enabled !== false)) {
3726
+ if (!this.packageGovernor.evaluate({
3727
+ deviceId,
3728
+ motionInZone: this.lastAnalyzerDetected.get(deviceId) ?? false,
3729
+ now: Date.now()
3730
+ }).run) {
3731
+ dispatchSteps = steps.filter((s) => s.addonId !== PACKAGE_DETECTION_STEP_ID);
3732
+ if (dispatchSteps.length === 0) return null;
3733
+ }
3734
+ }
3612
3735
  let image;
3613
3736
  if (!this.executorCoLocated()) {
3614
3737
  if (frame.format === "rgb") try {
@@ -3623,7 +3746,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
3623
3746
  }
3624
3747
  try {
3625
3748
  return await api.pipelineExecutor.runPipeline.mutate({
3626
- steps: [...steps],
3749
+ steps: [...dispatchSteps],
3627
3750
  ...image !== void 0 ? { image } : { frame },
3628
3751
  deviceId,
3629
3752
  plane: "frame"
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require("../chunk-D6vf50IK.js");
2
- const require_dist = require("../dist-p_JqOxtk.js");
2
+ const require_dist = require("../dist-B2jTt7Lq.js");
3
3
  const require_sheet_geometry = require("../sheet-geometry-BR-ip0CP.js");
4
4
  const require_model_download_service_Cp9f4dk6 = require("../model-download-service-Cp9f4dk6-Dv-oFwjN.js");
5
5
  let node_child_process = require("node:child_process");
@@ -1005,6 +1005,34 @@ function segmentsByLocation(rows) {
1005
1005
  return out;
1006
1006
  }
1007
1007
  /**
1008
+ * Evict a set of victim segments through the SegmentStore, grouped by their
1009
+ * storage location, and return the reclaimed bytes + deleted-bucket count.
1010
+ * Per-location isolated: one location's evict failure is logged and never aborts
1011
+ * the others. Shared by `pruneFootage` (retention) and `deleteFootprint`
1012
+ * (manual).
1013
+ */
1014
+ async function evictVictims(deps, deviceId, victims) {
1015
+ let reclaimedBytes = 0;
1016
+ let deletedBuckets = 0;
1017
+ for (const [locationId, locVictims] of segmentsByLocation(victims)) try {
1018
+ const freed = await deps.segmentStore.evict(locationId, locVictims);
1019
+ reclaimedBytes += freed;
1020
+ if (freed > 0) deletedBuckets += locVictims.length;
1021
+ } catch (err) {
1022
+ deps.logger.warn("recorder evict failed for location", {
1023
+ tags: { deviceId },
1024
+ meta: {
1025
+ locationId,
1026
+ error: require_dist.errMsg(err)
1027
+ }
1028
+ });
1029
+ }
1030
+ return {
1031
+ reclaimedBytes,
1032
+ deletedBuckets
1033
+ };
1034
+ }
1035
+ /**
1008
1036
  * Build the `IRecordingProvider`. The provider is a thin façade over the v2
1009
1037
  * core; all storage I/O flows through the injected deps.
1010
1038
  */
@@ -1101,37 +1129,63 @@ function buildRecordingProvider(deps) {
1101
1129
  rescanStorage: async ({ deviceId }) => {
1102
1130
  const locations = await deps.refreshLocations();
1103
1131
  await deps.hydrateDevice(deviceId, locations);
1104
- return statusFor(deps, deviceId, await loadDeviceConfig(deps.configStore, deviceId));
1132
+ const config = await loadDeviceConfig(deps.configStore, deviceId);
1133
+ await deps.opsLog?.append({
1134
+ op: "rescan",
1135
+ reason: "operator",
1136
+ deviceId,
1137
+ itemsAffected: deps.index.segments(deviceId).length,
1138
+ bytesReclaimed: 0,
1139
+ detail: "rescanned footage from disk"
1140
+ });
1141
+ return statusFor(deps, deviceId, config);
1105
1142
  },
1106
- pruneFootage: async ({ deviceId }) => {
1143
+ pruneFootage: async ({ deviceId, reason }) => {
1107
1144
  const retention = (await loadDeviceConfig(deps.configStore, deviceId)).retention;
1108
1145
  const nowMs = Date.now();
1109
- const victims = selectEvictions(deps.index.segments(deviceId), {
1146
+ const { reclaimedBytes, deletedBuckets } = await evictVictims(deps, deviceId, selectEvictions(deps.index.segments(deviceId), {
1110
1147
  maxAgeDays: retention?.maxAgeDays,
1111
1148
  maxSizeGb: retention?.maxSizeGb,
1112
1149
  nowMs
1150
+ }));
1151
+ const floorMs = deps.index.accounting(deviceId).oldestMs ?? (retention?.maxAgeDays ? nowMs - retention.maxAgeDays * DAY_MS : null);
1152
+ const opReason = reason ?? "retention";
1153
+ await deps.opsLog?.append({
1154
+ op: "prune",
1155
+ reason: opReason,
1156
+ deviceId,
1157
+ itemsAffected: deletedBuckets,
1158
+ bytesReclaimed: reclaimedBytes,
1159
+ detail: floorMs === null ? "no footage remaining" : `floor moved to ${floorMs}`
1160
+ });
1161
+ return {
1162
+ floorMs,
1163
+ deletedBuckets,
1164
+ reclaimedBytes
1165
+ };
1166
+ },
1167
+ deleteFootprint: async ({ deviceId, fromMs, toMs }) => {
1168
+ const lo = fromMs ?? Number.NEGATIVE_INFINITY;
1169
+ const hi = toMs ?? Number.POSITIVE_INFINITY;
1170
+ const { reclaimedBytes, deletedBuckets } = await evictVictims(deps, deviceId, deps.index.segments(deviceId).filter((s) => s.startMs < hi && s.startMs + s.durMs > lo));
1171
+ const windowed = fromMs !== void 0 || toMs !== void 0;
1172
+ await deps.opsLog?.append({
1173
+ op: "manual-delete",
1174
+ reason: "manual",
1175
+ deviceId,
1176
+ itemsAffected: deletedBuckets,
1177
+ bytesReclaimed: reclaimedBytes,
1178
+ detail: windowed ? `deleted footage in [${fromMs ?? "−∞"}, ${toMs ?? "+∞"})` : "deleted all footage"
1113
1179
  });
1114
- let reclaimedBytes = 0;
1115
- let deletedBuckets = 0;
1116
- for (const [locationId, locVictims] of segmentsByLocation(victims)) try {
1117
- const freed = await deps.segmentStore.evict(locationId, locVictims);
1118
- reclaimedBytes += freed;
1119
- if (freed > 0) deletedBuckets += locVictims.length;
1120
- } catch (err) {
1121
- deps.logger.warn("recorder pruneFootage evict failed for location", {
1122
- tags: { deviceId },
1123
- meta: {
1124
- locationId,
1125
- error: require_dist.errMsg(err)
1126
- }
1127
- });
1128
- }
1129
1180
  return {
1130
- floorMs: deps.index.accounting(deviceId).oldestMs ?? (retention?.maxAgeDays ? nowMs - retention.maxAgeDays * DAY_MS : null),
1131
1181
  deletedBuckets,
1132
1182
  reclaimedBytes
1133
1183
  };
1134
1184
  },
1185
+ listOpsLog: async ({ deviceId, limit }) => await deps.opsLog?.list({
1186
+ deviceId,
1187
+ limit
1188
+ }) ?? [],
1135
1189
  getDeviceSettingsContribution: async ({ deviceId }) => {
1136
1190
  if (deps.isCameraDevice && !await deps.isCameraDevice(deviceId)) return null;
1137
1191
  return require_dist.hydrateSchema(buildRecordingDeviceSchema(), {});
@@ -1208,6 +1262,81 @@ async function listExportsFor(state, deviceId) {
1208
1262
  return [...(await readExports(state)).values()].filter((r) => deviceId === void 0 || r.deviceId === deviceId).toSorted((a, b) => b.createdAt - a.createdAt);
1209
1263
  }
1210
1264
  //#endregion
1265
+ //#region src/recorder/addon/ops-log-store.ts
1266
+ /**
1267
+ * Recordings ops-log persistence for the recorder addon.
1268
+ *
1269
+ * A bounded, append-only audit ring lives in ONE addon-store blob (same
1270
+ * DurableState pattern as `export-store.ts` / `config-store.ts`) — no SQLite in
1271
+ * the recorder. The whole Zod-validated array round-trips on every read/write,
1272
+ * so no row is silently dropped on persist. Appends evict the oldest rows once
1273
+ * the ring exceeds its cap, so the blob stays bounded regardless of churn. A
1274
+ * corrupt blob reads back as EMPTY rather than crashing boot.
1275
+ *
1276
+ * Writes are BEST-EFFORT: `createRecordingOpsLogSink().append` never throws — a
1277
+ * failed audit write must not fail the operation it records.
1278
+ */
1279
+ /** The addon-store key holding the bounded ops-log ring. */
1280
+ var RECORDING_OPS_LOG_KEY = "recordingOpsLog";
1281
+ /** Persisted shape: a flat array of ops-log rows (oldest-first on disk). */
1282
+ var RecordingOpsLogBlobSchema = require_dist.array(require_dist.OpsLogEntrySchema);
1283
+ /**
1284
+ * Append one row and persist, evicting the oldest rows so the ring never grows
1285
+ * past `maxEntries`. Validates at the boundary. Returns the stored row. Rows are
1286
+ * kept oldest-first on disk (the tail is newest); {@link listOpsLog} reverses.
1287
+ */
1288
+ async function appendOpsLog(state, entry, maxEntries = 500) {
1289
+ const validated = require_dist.OpsLogEntrySchema.parse(entry);
1290
+ const next = [...await state.get(), validated];
1291
+ const bounded = next.length > maxEntries ? next.slice(next.length - maxEntries) : next;
1292
+ await state.set(bounded);
1293
+ return validated;
1294
+ }
1295
+ /**
1296
+ * List ops-log rows newest-first, optionally scoped to one device, capped at
1297
+ * `limit` (default {@link OPS_LOG_DEFAULT_LIMIT}).
1298
+ */
1299
+ async function listOpsLog(state, query) {
1300
+ const rows = await state.get();
1301
+ return (query.deviceId === void 0 ? rows : rows.filter((r) => r.deviceId === query.deviceId)).toSorted((a, b) => b.at - a.at).slice(0, query.limit ?? 200);
1302
+ }
1303
+ /**
1304
+ * Build the recorder's ops-log sink. `append` stamps `domain:'recording'`,
1305
+ * `nodeId`, `id` and `at`, then persists best-effort — a failed write is logged,
1306
+ * never thrown, so it cannot fail the operation being audited.
1307
+ */
1308
+ function createRecordingOpsLogSink(deps) {
1309
+ return {
1310
+ append: async (input) => {
1311
+ const entry = {
1312
+ id: deps.newId(),
1313
+ at: deps.now(),
1314
+ domain: "recording",
1315
+ op: input.op,
1316
+ reason: input.reason,
1317
+ deviceId: input.deviceId,
1318
+ nodeId: deps.nodeId,
1319
+ itemsAffected: input.itemsAffected,
1320
+ bytesReclaimed: input.bytesReclaimed,
1321
+ detail: input.detail ?? null,
1322
+ actor: input.actor ?? "operator"
1323
+ };
1324
+ try {
1325
+ await appendOpsLog(deps.state, entry, deps.maxEntries);
1326
+ } catch (err) {
1327
+ deps.logger.warn("recorder: ops-log append failed (best-effort)", {
1328
+ tags: { deviceId: input.deviceId ?? void 0 },
1329
+ meta: {
1330
+ op: input.op,
1331
+ error: err instanceof Error ? err.message : String(err)
1332
+ }
1333
+ });
1334
+ }
1335
+ },
1336
+ list: (query) => listOpsLog(deps.state, query)
1337
+ };
1338
+ }
1339
+ //#endregion
1211
1340
  //#region src/recorder/addon/export-ffmpeg-args.ts
1212
1341
  /** Widest [min,max] speed a single ffmpeg `atempo` filter accepts. Outside this
1213
1342
  * band audio is dropped rather than chained (kept simple + predictable). */
@@ -3582,12 +3711,20 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3582
3711
  }),
3583
3712
  brokerCall: makeBrokerCall(brokerHandle)
3584
3713
  });
3714
+ const opsLog = createRecordingOpsLogSink({
3715
+ state: this.opsLogStore(),
3716
+ nodeId: this.nodeId,
3717
+ now: () => Date.now(),
3718
+ newId: () => (0, node_crypto.randomUUID)(),
3719
+ logger: this.ctx.logger.child("ops-log")
3720
+ });
3585
3721
  const recordingDeps = {
3586
3722
  index: this.index,
3587
3723
  segmentStore: this.segmentStore,
3588
3724
  nodeId: this.nodeId,
3589
3725
  logger: this.ctx.logger,
3590
3726
  configStore: this.configStore(),
3727
+ opsLog,
3591
3728
  locations: () => this.resolvedLocations,
3592
3729
  refreshLocations: () => this.refreshLocations(),
3593
3730
  capacity: (root) => this.locationCapacity(root),
@@ -3926,6 +4063,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3926
4063
  exportsStore() {
3927
4064
  return this.state(RECORDING_EXPORTS_KEY, RecordingExportsBlobSchema, {});
3928
4065
  }
4066
+ /** Durable handle over the bounded recordings ops-log ring. */
4067
+ opsLogStore() {
4068
+ return this.state(RECORDING_OPS_LOG_KEY, RecordingOpsLogBlobSchema, []);
4069
+ }
3929
4070
  /** Retention deps shared by the sweep and the download-complete hook. */
3930
4071
  exportJanitorDeps() {
3931
4072
  return {