@camstack/addon-post-analysis 1.2.65 → 1.2.66

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.
@@ -8001,6 +8001,21 @@ var StorageLocationSchema = object({
8001
8001
  nodeId: string().optional(),
8002
8002
  isDefault: boolean().default(false),
8003
8003
  isSystem: boolean().default(false),
8004
+ /**
8005
+ * Operator opt-in: whether consumers that BALANCE across several locations
8006
+ * of a type may write here. Recordings reads it today; event media and
8007
+ * backups are the next consumers, which is why the flag lives on the
8008
+ * location rather than in any one addon's store — nothing has to be
8009
+ * extended to add the next consumer.
8010
+ *
8011
+ * OPTIONAL, and ABSENT MEANS ACTIVE. Every location persisted before the
8012
+ * flag existed reads back with no flag and keeps working exactly as before;
8013
+ * that is the whole compat story, and it is why no migration ships with it.
8014
+ * A newly CREATED sibling is stamped `false` by the orchestrator (creating a
8015
+ * disk must not silently start writing to it); the default of a type is
8016
+ * always stamped `true`.
8017
+ */
8018
+ enabled: boolean().optional(),
8004
8019
  /** COMPUTED at read time by the orchestrator (statfs of the backing volume
8005
8020
  * for node-local locations it can reach) — never persisted, absent when the
8006
8021
  * volume is remote/unreachable. The single capacity truth every UI reads. */
@@ -12540,7 +12555,8 @@ var embeddingEncoderCapability = {
12540
12555
  };
12541
12556
  /**
12542
12557
  * filesystem-browse — per-node capability for browsing the node's local
12543
- * filesystem, sandboxed to operator-configured allowed roots. Used by the
12558
+ * filesystem. Reads are unconfined (whole filesystem, from `/` down); WRITES
12559
+ * are sandboxed to operator-configured allowed roots (D115). Used by the
12544
12560
  * admin "Add filesystem location" flow to pick a node + path. `mode:'per-node'`
12545
12561
  * (one provider per node); the hub calls it with `{nodeId}` so the codegen
12546
12562
  * routes to that exact node (default `nodeIdMode:'routing'`).
@@ -25999,7 +26015,7 @@ method(object({
25999
26015
  auth: "admin"
26000
26016
  });
26001
26017
  /**
26002
- * `recordingExport` cap — render a footage time range into a single downloadable
26018
+ * `recording-export` cap — render a footage time range into a single downloadable
26003
26019
  * MP4 (regular / accelerated / decelerated / timelapse, ± audio), kept for a
26004
26020
  * bounded lifetime with a durable history, auto-expiry, and optional
26005
26021
  * delete-after-download.
@@ -33023,43 +33039,43 @@ Object.freeze({
33023
33039
  access: "create"
33024
33040
  },
33025
33041
  "recordingExport.cancelExport": {
33026
- capName: "recordingExport",
33042
+ capName: "recording-export",
33027
33043
  capScope: "system",
33028
33044
  addonId: null,
33029
33045
  access: "create"
33030
33046
  },
33031
33047
  "recordingExport.createExport": {
33032
- capName: "recordingExport",
33048
+ capName: "recording-export",
33033
33049
  capScope: "system",
33034
33050
  addonId: null,
33035
33051
  access: "create"
33036
33052
  },
33037
33053
  "recordingExport.deleteExport": {
33038
- capName: "recordingExport",
33054
+ capName: "recording-export",
33039
33055
  capScope: "system",
33040
33056
  addonId: null,
33041
33057
  access: "delete"
33042
33058
  },
33043
33059
  "recordingExport.getDownloadUrl": {
33044
- capName: "recordingExport",
33060
+ capName: "recording-export",
33045
33061
  capScope: "system",
33046
33062
  addonId: null,
33047
33063
  access: "view"
33048
33064
  },
33049
33065
  "recordingExport.getExport": {
33050
- capName: "recordingExport",
33066
+ capName: "recording-export",
33051
33067
  capScope: "system",
33052
33068
  addonId: null,
33053
33069
  access: "view"
33054
33070
  },
33055
33071
  "recordingExport.listExports": {
33056
- capName: "recordingExport",
33072
+ capName: "recording-export",
33057
33073
  capScope: "system",
33058
33074
  addonId: null,
33059
33075
  access: "view"
33060
33076
  },
33061
33077
  "recordingExport.readExportBytes": {
33062
- capName: "recordingExport",
33078
+ capName: "recording-export",
33063
33079
  capScope: "system",
33064
33080
  addonId: null,
33065
33081
  access: "view"
@@ -7970,6 +7970,21 @@ var StorageLocationSchema = object({
7970
7970
  nodeId: string().optional(),
7971
7971
  isDefault: boolean().default(false),
7972
7972
  isSystem: boolean().default(false),
7973
+ /**
7974
+ * Operator opt-in: whether consumers that BALANCE across several locations
7975
+ * of a type may write here. Recordings reads it today; event media and
7976
+ * backups are the next consumers, which is why the flag lives on the
7977
+ * location rather than in any one addon's store — nothing has to be
7978
+ * extended to add the next consumer.
7979
+ *
7980
+ * OPTIONAL, and ABSENT MEANS ACTIVE. Every location persisted before the
7981
+ * flag existed reads back with no flag and keeps working exactly as before;
7982
+ * that is the whole compat story, and it is why no migration ships with it.
7983
+ * A newly CREATED sibling is stamped `false` by the orchestrator (creating a
7984
+ * disk must not silently start writing to it); the default of a type is
7985
+ * always stamped `true`.
7986
+ */
7987
+ enabled: boolean().optional(),
7973
7988
  /** COMPUTED at read time by the orchestrator (statfs of the backing volume
7974
7989
  * for node-local locations it can reach) — never persisted, absent when the
7975
7990
  * volume is remote/unreachable. The single capacity truth every UI reads. */
@@ -12509,7 +12524,8 @@ var embeddingEncoderCapability = {
12509
12524
  };
12510
12525
  /**
12511
12526
  * filesystem-browse — per-node capability for browsing the node's local
12512
- * filesystem, sandboxed to operator-configured allowed roots. Used by the
12527
+ * filesystem. Reads are unconfined (whole filesystem, from `/` down); WRITES
12528
+ * are sandboxed to operator-configured allowed roots (D115). Used by the
12513
12529
  * admin "Add filesystem location" flow to pick a node + path. `mode:'per-node'`
12514
12530
  * (one provider per node); the hub calls it with `{nodeId}` so the codegen
12515
12531
  * routes to that exact node (default `nodeIdMode:'routing'`).
@@ -25968,7 +25984,7 @@ method(object({
25968
25984
  auth: "admin"
25969
25985
  });
25970
25986
  /**
25971
- * `recordingExport` cap — render a footage time range into a single downloadable
25987
+ * `recording-export` cap — render a footage time range into a single downloadable
25972
25988
  * MP4 (regular / accelerated / decelerated / timelapse, ± audio), kept for a
25973
25989
  * bounded lifetime with a durable history, auto-expiry, and optional
25974
25990
  * delete-after-download.
@@ -32992,43 +33008,43 @@ Object.freeze({
32992
33008
  access: "create"
32993
33009
  },
32994
33010
  "recordingExport.cancelExport": {
32995
- capName: "recordingExport",
33011
+ capName: "recording-export",
32996
33012
  capScope: "system",
32997
33013
  addonId: null,
32998
33014
  access: "create"
32999
33015
  },
33000
33016
  "recordingExport.createExport": {
33001
- capName: "recordingExport",
33017
+ capName: "recording-export",
33002
33018
  capScope: "system",
33003
33019
  addonId: null,
33004
33020
  access: "create"
33005
33021
  },
33006
33022
  "recordingExport.deleteExport": {
33007
- capName: "recordingExport",
33023
+ capName: "recording-export",
33008
33024
  capScope: "system",
33009
33025
  addonId: null,
33010
33026
  access: "delete"
33011
33027
  },
33012
33028
  "recordingExport.getDownloadUrl": {
33013
- capName: "recordingExport",
33029
+ capName: "recording-export",
33014
33030
  capScope: "system",
33015
33031
  addonId: null,
33016
33032
  access: "view"
33017
33033
  },
33018
33034
  "recordingExport.getExport": {
33019
- capName: "recordingExport",
33035
+ capName: "recording-export",
33020
33036
  capScope: "system",
33021
33037
  addonId: null,
33022
33038
  access: "view"
33023
33039
  },
33024
33040
  "recordingExport.listExports": {
33025
- capName: "recordingExport",
33041
+ capName: "recording-export",
33026
33042
  capScope: "system",
33027
33043
  addonId: null,
33028
33044
  access: "view"
33029
33045
  },
33030
33046
  "recordingExport.readExportBytes": {
33031
- capName: "recordingExport",
33047
+ capName: "recording-export",
33032
33048
  capScope: "system",
33033
33049
  addonId: null,
33034
33050
  access: "view"
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DTClXZLm.js");
5
+ const require_dist = require("../dist-DKsqvmSl.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_fs$1 = require_dist.__toESM(node_fs, 1);
8
8
  node_fs = require_dist.__toESM(node_fs);
@@ -1,4 +1,4 @@
1
- import { I as hfModelUrl, N as embeddingEncoderCapability, Y as BaseAddon } from "../dist-4dhFDogy.mjs";
1
+ import { I as hfModelUrl, N as embeddingEncoderCapability, Y as BaseAddon } from "../dist-DPzTTt9R.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import * as fs from "node:fs";
4
4
  import "node:fs";
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.61",
21
+ version: "1.2.63",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_pipeline_analytics_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.2.61",
39
+ version: "1.2.63",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DTClXZLm.js");
5
+ const require_dist = require("../dist-DKsqvmSl.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -5346,8 +5346,20 @@ function applyTemplatePatch(merged, template) {
5346
5346
  const { template: _cleared, ...withoutTemplate } = merged;
5347
5347
  return withoutTemplate;
5348
5348
  }
5349
+ /** Narrowing guard — the persisted `rule` column is `unknown` until parsed. */
5350
+ function isRecord$1(value) {
5351
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5352
+ }
5349
5353
  var TimelapseStore = class {
5350
5354
  byId = /* @__PURE__ */ new Map();
5355
+ /**
5356
+ * Birth stamps synthesised for LEGACY rows (see {@link withCreatedAt}), kept
5357
+ * so a repeated `load()` re-uses the FIRST one. Without it the synthesised
5358
+ * `createdAt` would advance to "now" on every refresh, and the scheduler's
5359
+ * eligibility floor — which is `max(stamp, createdAt)` — would creep past
5360
+ * every window such a rule was ever offered, silently producing nothing.
5361
+ */
5362
+ synthesisedCreatedAt = /* @__PURE__ */ new Map();
5351
5363
  store;
5352
5364
  logger;
5353
5365
  now;
@@ -5379,8 +5391,11 @@ var TimelapseStore = class {
5379
5391
  });
5380
5392
  this.byId.clear();
5381
5393
  let skipped = 0;
5394
+ let synthesised = 0;
5382
5395
  for (const row of rows) {
5383
- const parsed = require_dist.TimelapseRuleSchema.safeParse(row.data["rule"]);
5396
+ const raw = this.withCreatedAt(row.id, row.data["rule"]);
5397
+ if (raw !== row.data["rule"]) synthesised += 1;
5398
+ const parsed = require_dist.TimelapseRuleSchema.safeParse(raw);
5384
5399
  if (!parsed.success) {
5385
5400
  skipped += 1;
5386
5401
  continue;
@@ -5389,12 +5404,40 @@ var TimelapseStore = class {
5389
5404
  }
5390
5405
  this.logger.debug("timelapse rules loaded", { meta: {
5391
5406
  rules: this.byId.size,
5392
- ...skipped > 0 ? { skippedInvalid: skipped } : {}
5407
+ ...skipped > 0 ? { skippedInvalid: skipped } : {},
5408
+ ...synthesised > 0 ? { synthesisedCreatedAt: synthesised } : {}
5393
5409
  } });
5394
5410
  } catch (err) {
5395
5411
  this.logger.warn("timelapse rules load failed", { meta: { error: String(err) } });
5396
5412
  }
5397
5413
  }
5414
+ /**
5415
+ * Give a persisted blob a `createdAt` if it has none, WITHOUT persisting
5416
+ * anything: the rule reads as born at this load.
5417
+ *
5418
+ * `createdAt` has always been required by `TimelapseRuleSchema`, so a row
5419
+ * without one is a pre-schema artefact — and before this it was dropped
5420
+ * outright at load (a silently vanished rule). Reading it as "born now" is
5421
+ * the conservative choice in BOTH directions that matter: the rule survives,
5422
+ * and the scheduler's eligibility floor stops it back-filling a window that
5423
+ * closed years before anyone looked at it. The cost is one skipped window if
5424
+ * a legacy rule is loaded between a close and its settle — a bounded miss,
5425
+ * against an unbounded retry of a window no footage can satisfy.
5426
+ *
5427
+ * The synthesised value is remembered per rule id so a later refresh does not
5428
+ * move the floor, and the first `update`/`markGenerated` writes it through —
5429
+ * after which the row is no longer legacy.
5430
+ */
5431
+ withCreatedAt(rowId, raw) {
5432
+ if (!isRecord$1(raw)) return raw;
5433
+ if (typeof raw["createdAt"] === "number") return raw;
5434
+ const stamped = this.synthesisedCreatedAt.get(rowId) ?? this.now();
5435
+ this.synthesisedCreatedAt.set(rowId, stamped);
5436
+ return {
5437
+ ...raw,
5438
+ createdAt: stamped
5439
+ };
5440
+ }
5398
5441
  /** Every rule, newest-first (admin path). */
5399
5442
  list() {
5400
5443
  return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
@@ -6027,6 +6070,35 @@ function findLastClosedWindow(input) {
6027
6070
  };
6028
6071
  return null;
6029
6072
  }
6073
+ /**
6074
+ * The earliest window CLOSE a (rule, device) pair may ever be offered.
6075
+ *
6076
+ * `max(this device's last generation, the rule's birth)`:
6077
+ * - the per-device stamp is the re-generation guard — a window already
6078
+ * produced for THIS camera is done (and a sibling camera's success says
6079
+ * nothing about it, hence `readTimelapseGeneratedAt` and not the rule-wide
6080
+ * field);
6081
+ * - `createdAt` is what stops a brand-new rule mining the past. Without it an
6082
+ * unstamped pair is indistinguishable from one the hub was down for, and
6083
+ * the back-fill happily reaches back a full lookback for footage no rule was
6084
+ * ever recording against.
6085
+ *
6086
+ * The comparison is against the window's CLOSE, matching the stamp guard: a
6087
+ * rule created mid-window still gets that window (its footage exists), a rule
6088
+ * created after the close never does.
6089
+ *
6090
+ * RULE-LEVEL, deliberately. `TimelapseRule.deviceIds` is a plain array that a
6091
+ * patch replaces wholesale, so there is no per-device add time to floor
6092
+ * against; a camera added to an old rule may therefore be offered the one
6093
+ * window that closed before it joined. That over-produces at most a single
6094
+ * video, whereas the defect this function exists to stop is unbounded — an
6095
+ * every-minute retry of a window nothing can satisfy. If a per-device add time
6096
+ * is ever persisted, raise the floor to `max(…, addedAt)` here and nowhere
6097
+ * else.
6098
+ */
6099
+ function timelapseEligibilityFloor(rule, deviceId) {
6100
+ return Math.max(require_dist.readTimelapseGeneratedAt(rule, deviceId), rule.createdAt);
6101
+ }
6030
6102
  var TimelapseScheduler = class {
6031
6103
  deps;
6032
6104
  timer = null;
@@ -6064,6 +6136,7 @@ var TimelapseScheduler = class {
6064
6136
  produced: 0,
6065
6137
  waiting: 0,
6066
6138
  alreadyDone: 0,
6139
+ beforeFloor: 0,
6067
6140
  failed: 0,
6068
6141
  idle: 0
6069
6142
  };
@@ -6072,6 +6145,7 @@ var TimelapseScheduler = class {
6072
6145
  produced: 0,
6073
6146
  waiting: 0,
6074
6147
  alreadyDone: 0,
6148
+ beforeFloor: 0,
6075
6149
  failed: 0,
6076
6150
  idle: 0
6077
6151
  };
@@ -6096,6 +6170,19 @@ var TimelapseScheduler = class {
6096
6170
  report.alreadyDone += 1;
6097
6171
  continue;
6098
6172
  }
6173
+ if (timelapseEligibilityFloor(rule, deviceId) >= window.endMs) {
6174
+ report.beforeFloor += 1;
6175
+ this.deps.logger.debug("timelapse window predates the rule — not attempted", {
6176
+ tags: { deviceId },
6177
+ meta: {
6178
+ ruleId: rule.id,
6179
+ windowStart: window.startMs,
6180
+ windowEnd: window.endMs,
6181
+ createdAt: rule.createdAt
6182
+ }
6183
+ });
6184
+ continue;
6185
+ }
6099
6186
  if ((await this.produce(rule, deviceId, window, { stamp: true })).produced > 0) report.produced += 1;
6100
6187
  else report.failed += 1;
6101
6188
  }
@@ -6113,6 +6200,11 @@ var TimelapseScheduler = class {
6113
6200
  * test that stops short of the notifier does not test the thing that breaks.
6114
6201
  * It deliberately does NOT stamp: pressing Test at noon must not cancel
6115
6202
  * tonight's scheduled run.
6203
+ *
6204
+ * It also ignores the eligibility floor. The floor exists to stop an
6205
+ * UNATTENDED loop mining the past; an operator who presses Test on a rule
6206
+ * created an hour ago is asking exactly that question, and gets the honest
6207
+ * answer (`no footage …`) once, not every minute.
6116
6208
  */
6117
6209
  async runNow(input) {
6118
6210
  const rule = this.deps.store.get(input.ruleId);
@@ -1,4 +1,4 @@
1
- import { $ as isDeviceScopedCap, A as customAction, B as pipelineAnalyticsCapability, C as TimelapseRuleSchema, D as audioMetricsCapability, E as alarmPanelCapability, F as faceGalleryCapability, G as vectorDimFromBase64, H as readDeviceStateFrom, J as errMsg, K as videoclipsCapability, L as isScheduleActive, M as deriveRecordingMode, O as buildEventKindDescriptor, P as encodeVectorBase64, Q as hydrateSchema, R as kebabToCamel, S as TimelapseRulePatchSchema, T as addonWidgetsSourceCapability, U as readTimelapseGeneratedAt, V as plateGalleryCapability, W as subKindsOf, X as DeviceType, Y as BaseAddon, Z as createEvent, _ as NcTaxonomySchema, a as EVENT_PAD_MS, at as literal, b as RetrainStatusSchema, c as NC_CONDITION_CATALOG, ct as record, d as NcRuleInputSchema, dt as EventCategory, et as nodePin, f as NcRulePatchSchema, g as NcSnoozeSuppressedSchema, h as NcSnoozeSchema, i as EVENT_KIND_BY_CAP, it as boolean, j as defineCustomActions, k as cosineSimilarity, l as NC_TAXONOMY, lt as string, m as NcSnoozeInputSchema, n as DEFAULT_EVENT_COLOR, nt as _enum, o as LabelAttributionSchema, ot as number, p as NcRuleSchema, q as zoneAnalyticsCapability, r as DeclaredDevices, rt as array, s as MACRO_LABELS, st as object, t as BaseDevice, tt as sleep, u as NcConditionDescriptorSchema, ut as unknown, v as OpsLogEntrySchema, w as TrackSourceSchema, x as TimelapseRuleInputSchema, y as RECORDING_EXPORT_MAX_READ_BYTES, z as notificationRulesCapability } from "../dist-4dhFDogy.mjs";
1
+ import { $ as isDeviceScopedCap, A as customAction, B as pipelineAnalyticsCapability, C as TimelapseRuleSchema, D as audioMetricsCapability, E as alarmPanelCapability, F as faceGalleryCapability, G as vectorDimFromBase64, H as readDeviceStateFrom, J as errMsg, K as videoclipsCapability, L as isScheduleActive, M as deriveRecordingMode, O as buildEventKindDescriptor, P as encodeVectorBase64, Q as hydrateSchema, R as kebabToCamel, S as TimelapseRulePatchSchema, T as addonWidgetsSourceCapability, U as readTimelapseGeneratedAt, V as plateGalleryCapability, W as subKindsOf, X as DeviceType, Y as BaseAddon, Z as createEvent, _ as NcTaxonomySchema, a as EVENT_PAD_MS, at as literal, b as RetrainStatusSchema, c as NC_CONDITION_CATALOG, ct as record, d as NcRuleInputSchema, dt as EventCategory, et as nodePin, f as NcRulePatchSchema, g as NcSnoozeSuppressedSchema, h as NcSnoozeSchema, i as EVENT_KIND_BY_CAP, it as boolean, j as defineCustomActions, k as cosineSimilarity, l as NC_TAXONOMY, lt as string, m as NcSnoozeInputSchema, n as DEFAULT_EVENT_COLOR, nt as _enum, o as LabelAttributionSchema, ot as number, p as NcRuleSchema, q as zoneAnalyticsCapability, r as DeclaredDevices, rt as array, s as MACRO_LABELS, st as object, t as BaseDevice, tt as sleep, u as NcConditionDescriptorSchema, ut as unknown, v as OpsLogEntrySchema, w as TrackSourceSchema, x as TimelapseRuleInputSchema, y as RECORDING_EXPORT_MAX_READ_BYTES, z as notificationRulesCapability } from "../dist-DPzTTt9R.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -5333,8 +5333,20 @@ function applyTemplatePatch(merged, template) {
5333
5333
  const { template: _cleared, ...withoutTemplate } = merged;
5334
5334
  return withoutTemplate;
5335
5335
  }
5336
+ /** Narrowing guard — the persisted `rule` column is `unknown` until parsed. */
5337
+ function isRecord$1(value) {
5338
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5339
+ }
5336
5340
  var TimelapseStore = class {
5337
5341
  byId = /* @__PURE__ */ new Map();
5342
+ /**
5343
+ * Birth stamps synthesised for LEGACY rows (see {@link withCreatedAt}), kept
5344
+ * so a repeated `load()` re-uses the FIRST one. Without it the synthesised
5345
+ * `createdAt` would advance to "now" on every refresh, and the scheduler's
5346
+ * eligibility floor — which is `max(stamp, createdAt)` — would creep past
5347
+ * every window such a rule was ever offered, silently producing nothing.
5348
+ */
5349
+ synthesisedCreatedAt = /* @__PURE__ */ new Map();
5338
5350
  store;
5339
5351
  logger;
5340
5352
  now;
@@ -5366,8 +5378,11 @@ var TimelapseStore = class {
5366
5378
  });
5367
5379
  this.byId.clear();
5368
5380
  let skipped = 0;
5381
+ let synthesised = 0;
5369
5382
  for (const row of rows) {
5370
- const parsed = TimelapseRuleSchema.safeParse(row.data["rule"]);
5383
+ const raw = this.withCreatedAt(row.id, row.data["rule"]);
5384
+ if (raw !== row.data["rule"]) synthesised += 1;
5385
+ const parsed = TimelapseRuleSchema.safeParse(raw);
5371
5386
  if (!parsed.success) {
5372
5387
  skipped += 1;
5373
5388
  continue;
@@ -5376,12 +5391,40 @@ var TimelapseStore = class {
5376
5391
  }
5377
5392
  this.logger.debug("timelapse rules loaded", { meta: {
5378
5393
  rules: this.byId.size,
5379
- ...skipped > 0 ? { skippedInvalid: skipped } : {}
5394
+ ...skipped > 0 ? { skippedInvalid: skipped } : {},
5395
+ ...synthesised > 0 ? { synthesisedCreatedAt: synthesised } : {}
5380
5396
  } });
5381
5397
  } catch (err) {
5382
5398
  this.logger.warn("timelapse rules load failed", { meta: { error: String(err) } });
5383
5399
  }
5384
5400
  }
5401
+ /**
5402
+ * Give a persisted blob a `createdAt` if it has none, WITHOUT persisting
5403
+ * anything: the rule reads as born at this load.
5404
+ *
5405
+ * `createdAt` has always been required by `TimelapseRuleSchema`, so a row
5406
+ * without one is a pre-schema artefact — and before this it was dropped
5407
+ * outright at load (a silently vanished rule). Reading it as "born now" is
5408
+ * the conservative choice in BOTH directions that matter: the rule survives,
5409
+ * and the scheduler's eligibility floor stops it back-filling a window that
5410
+ * closed years before anyone looked at it. The cost is one skipped window if
5411
+ * a legacy rule is loaded between a close and its settle — a bounded miss,
5412
+ * against an unbounded retry of a window no footage can satisfy.
5413
+ *
5414
+ * The synthesised value is remembered per rule id so a later refresh does not
5415
+ * move the floor, and the first `update`/`markGenerated` writes it through —
5416
+ * after which the row is no longer legacy.
5417
+ */
5418
+ withCreatedAt(rowId, raw) {
5419
+ if (!isRecord$1(raw)) return raw;
5420
+ if (typeof raw["createdAt"] === "number") return raw;
5421
+ const stamped = this.synthesisedCreatedAt.get(rowId) ?? this.now();
5422
+ this.synthesisedCreatedAt.set(rowId, stamped);
5423
+ return {
5424
+ ...raw,
5425
+ createdAt: stamped
5426
+ };
5427
+ }
5385
5428
  /** Every rule, newest-first (admin path). */
5386
5429
  list() {
5387
5430
  return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
@@ -6014,6 +6057,35 @@ function findLastClosedWindow(input) {
6014
6057
  };
6015
6058
  return null;
6016
6059
  }
6060
+ /**
6061
+ * The earliest window CLOSE a (rule, device) pair may ever be offered.
6062
+ *
6063
+ * `max(this device's last generation, the rule's birth)`:
6064
+ * - the per-device stamp is the re-generation guard — a window already
6065
+ * produced for THIS camera is done (and a sibling camera's success says
6066
+ * nothing about it, hence `readTimelapseGeneratedAt` and not the rule-wide
6067
+ * field);
6068
+ * - `createdAt` is what stops a brand-new rule mining the past. Without it an
6069
+ * unstamped pair is indistinguishable from one the hub was down for, and
6070
+ * the back-fill happily reaches back a full lookback for footage no rule was
6071
+ * ever recording against.
6072
+ *
6073
+ * The comparison is against the window's CLOSE, matching the stamp guard: a
6074
+ * rule created mid-window still gets that window (its footage exists), a rule
6075
+ * created after the close never does.
6076
+ *
6077
+ * RULE-LEVEL, deliberately. `TimelapseRule.deviceIds` is a plain array that a
6078
+ * patch replaces wholesale, so there is no per-device add time to floor
6079
+ * against; a camera added to an old rule may therefore be offered the one
6080
+ * window that closed before it joined. That over-produces at most a single
6081
+ * video, whereas the defect this function exists to stop is unbounded — an
6082
+ * every-minute retry of a window nothing can satisfy. If a per-device add time
6083
+ * is ever persisted, raise the floor to `max(…, addedAt)` here and nowhere
6084
+ * else.
6085
+ */
6086
+ function timelapseEligibilityFloor(rule, deviceId) {
6087
+ return Math.max(readTimelapseGeneratedAt(rule, deviceId), rule.createdAt);
6088
+ }
6017
6089
  var TimelapseScheduler = class {
6018
6090
  deps;
6019
6091
  timer = null;
@@ -6051,6 +6123,7 @@ var TimelapseScheduler = class {
6051
6123
  produced: 0,
6052
6124
  waiting: 0,
6053
6125
  alreadyDone: 0,
6126
+ beforeFloor: 0,
6054
6127
  failed: 0,
6055
6128
  idle: 0
6056
6129
  };
@@ -6059,6 +6132,7 @@ var TimelapseScheduler = class {
6059
6132
  produced: 0,
6060
6133
  waiting: 0,
6061
6134
  alreadyDone: 0,
6135
+ beforeFloor: 0,
6062
6136
  failed: 0,
6063
6137
  idle: 0
6064
6138
  };
@@ -6083,6 +6157,19 @@ var TimelapseScheduler = class {
6083
6157
  report.alreadyDone += 1;
6084
6158
  continue;
6085
6159
  }
6160
+ if (timelapseEligibilityFloor(rule, deviceId) >= window.endMs) {
6161
+ report.beforeFloor += 1;
6162
+ this.deps.logger.debug("timelapse window predates the rule — not attempted", {
6163
+ tags: { deviceId },
6164
+ meta: {
6165
+ ruleId: rule.id,
6166
+ windowStart: window.startMs,
6167
+ windowEnd: window.endMs,
6168
+ createdAt: rule.createdAt
6169
+ }
6170
+ });
6171
+ continue;
6172
+ }
6086
6173
  if ((await this.produce(rule, deviceId, window, { stamp: true })).produced > 0) report.produced += 1;
6087
6174
  else report.failed += 1;
6088
6175
  }
@@ -6100,6 +6187,11 @@ var TimelapseScheduler = class {
6100
6187
  * test that stops short of the notifier does not test the thing that breaks.
6101
6188
  * It deliberately does NOT stamp: pressing Test at noon must not cancel
6102
6189
  * tonight's scheduled run.
6190
+ *
6191
+ * It also ignores the eligibility floor. The floor exists to stop an
6192
+ * UNATTENDED loop mining the past; an operator who presses Test on a rule
6193
+ * created an hour ago is asking exactly that question, and gets the honest
6194
+ * answer (`no footage …`) once, not every minute.
6103
6195
  */
6104
6196
  async runNow(input) {
6105
6197
  const rule = this.deps.store.get(input.ruleId);
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Ck9Z2cZR.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-BdNH8u64.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.65",
3
+ "version": "1.2.66",
4
4
  "description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",