@camstack/addon-post-analysis 1.2.97 → 1.2.99
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.
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DyXoKV7Z.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Caz0R4UZ.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-CkEakaQn.mjs → hostInit-By6c47y7.mjs} +1 -1
- package/dist/pipeline-analytics/index.js +322 -31
- package/dist/pipeline-analytics/index.mjs +322 -31
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -12098,6 +12098,18 @@ var NcOutbox = class {
|
|
|
12098
12098
|
maxAttempts;
|
|
12099
12099
|
drainBatchSize;
|
|
12100
12100
|
drainInFlight = false;
|
|
12101
|
+
/**
|
|
12102
|
+
* Has {@link load} landed?
|
|
12103
|
+
*
|
|
12104
|
+
* The load moved OFF the boot critical path (see
|
|
12105
|
+
* `NotificationCenter.runBootBackfill`), so for the first seconds of a boot
|
|
12106
|
+
* the centre evaluates and enqueues with an EMPTY `knownIds` — and
|
|
12107
|
+
* `knownIds` is the dedup ledger. Until this flips, {@link enqueue} asks the
|
|
12108
|
+
* store itself instead. False is therefore not "the load failed", it is
|
|
12109
|
+
* "the horizon is not authoritative yet"; a load that FAILED leaves it false
|
|
12110
|
+
* forever, which keeps the per-enqueue guard on, which is exactly right.
|
|
12111
|
+
*/
|
|
12112
|
+
horizonLoaded = false;
|
|
12101
12113
|
constructor(deps) {
|
|
12102
12114
|
this.store = deps.store;
|
|
12103
12115
|
this.logger = deps.logger;
|
|
@@ -12119,7 +12131,26 @@ var NcOutbox = class {
|
|
|
12119
12131
|
columns: [...NC_META_COLUMNS]
|
|
12120
12132
|
});
|
|
12121
12133
|
}
|
|
12122
|
-
/**
|
|
12134
|
+
/**
|
|
12135
|
+
* Hydrate pending rows + the dedup id horizon. Best-effort.
|
|
12136
|
+
*
|
|
12137
|
+
* **Runs OFF the boot critical path** (2026-08-17: 743 rows over the UDS
|
|
12138
|
+
* store client, ~50 s, inside the `initialize()` that publishes the runner's
|
|
12139
|
+
* capability manifest). So it lands against a centre that has already been
|
|
12140
|
+
* evaluating, enqueueing and DELIVERING for as long as the query took — and
|
|
12141
|
+
* the rows it is holding are a snapshot taken before all of that.
|
|
12142
|
+
*
|
|
12143
|
+
* That is why an id this process already knows is SKIPPED rather than
|
|
12144
|
+
* applied. `knownIds` is only ever written by an in-process {@link enqueue}
|
|
12145
|
+
* or by this method, so an id already in it at apply time is one whose live
|
|
12146
|
+
* state (pending, or absent because it went terminal) is strictly newer than
|
|
12147
|
+
* the snapshot. Applying the snapshot over it would put a row that has
|
|
12148
|
+
* already been DELIVERED back into `pending`, and the operator would receive
|
|
12149
|
+
* the same notification twice.
|
|
12150
|
+
*
|
|
12151
|
+
* Nothing is lost by the same token: this method only ADDS to `pending`, so
|
|
12152
|
+
* a row enqueued while the query was in flight is untouched.
|
|
12153
|
+
*/
|
|
12123
12154
|
async load() {
|
|
12124
12155
|
try {
|
|
12125
12156
|
const rows = await this.store.query.query({
|
|
@@ -12129,19 +12160,30 @@ var NcOutbox = class {
|
|
|
12129
12160
|
limit: 1e5
|
|
12130
12161
|
}
|
|
12131
12162
|
});
|
|
12163
|
+
let superseded = 0;
|
|
12132
12164
|
for (const row of rows) {
|
|
12165
|
+
if (this.knownIds.has(row.id)) {
|
|
12166
|
+
superseded += 1;
|
|
12167
|
+
continue;
|
|
12168
|
+
}
|
|
12133
12169
|
this.knownIds.add(row.id);
|
|
12134
12170
|
const entry = rowToEntry$1(row.id, row.data);
|
|
12135
12171
|
if (entry !== null && entry.status === "pending") this.pending.set(entry.id, entry);
|
|
12136
12172
|
}
|
|
12173
|
+
this.horizonLoaded = true;
|
|
12137
12174
|
this.logger.info("notification outbox loaded", { meta: {
|
|
12138
12175
|
pending: this.pending.size,
|
|
12139
|
-
known: this.knownIds.size
|
|
12176
|
+
known: this.knownIds.size,
|
|
12177
|
+
superseded
|
|
12140
12178
|
} });
|
|
12141
12179
|
} catch (err) {
|
|
12142
12180
|
this.logger.warn("notification outbox load failed", { meta: { error: String(err) } });
|
|
12143
12181
|
}
|
|
12144
12182
|
}
|
|
12183
|
+
/** Is the dedup horizon authoritative? See {@link horizonLoaded}. */
|
|
12184
|
+
hydrated() {
|
|
12185
|
+
return this.horizonLoaded;
|
|
12186
|
+
}
|
|
12145
12187
|
pendingCount() {
|
|
12146
12188
|
return this.pending.size;
|
|
12147
12189
|
}
|
|
@@ -12156,6 +12198,18 @@ var NcOutbox = class {
|
|
|
12156
12198
|
for (const input of inputs) {
|
|
12157
12199
|
const id = outboxEntryId(input);
|
|
12158
12200
|
if (this.knownIds.has(id)) continue;
|
|
12201
|
+
if (!this.horizonLoaded && await this.rowExists(id)) {
|
|
12202
|
+
this.knownIds.add(id);
|
|
12203
|
+
this.logger.info("outbox enqueue deduped against the store — the horizon is still cold", {
|
|
12204
|
+
tags: { deviceId: input.deviceId },
|
|
12205
|
+
meta: {
|
|
12206
|
+
id,
|
|
12207
|
+
ruleId: input.ruleId,
|
|
12208
|
+
targetId: input.targetId
|
|
12209
|
+
}
|
|
12210
|
+
});
|
|
12211
|
+
continue;
|
|
12212
|
+
}
|
|
12159
12213
|
const entry = {
|
|
12160
12214
|
id,
|
|
12161
12215
|
ruleId: input.ruleId,
|
|
@@ -12428,6 +12482,35 @@ var NcOutbox = class {
|
|
|
12428
12482
|
this.pending.set(entry.id, retry);
|
|
12429
12483
|
await this.mutate(retry);
|
|
12430
12484
|
}
|
|
12485
|
+
/**
|
|
12486
|
+
* Does a row already exist under this dedup id?
|
|
12487
|
+
*
|
|
12488
|
+
* Asked ONLY while {@link horizonLoaded} is false. {@link persist} writes
|
|
12489
|
+
* with `set` — an UPSERT keyed by the dedup id — so an enqueue that ran
|
|
12490
|
+
* before the horizon landed would rewrite a row that had already been
|
|
12491
|
+
* DELIVERED back to `pending`, and the drain would send it again. That is
|
|
12492
|
+
* the D-3 `maxPerTrack: 1` guarantee, and it cannot be allowed to depend on
|
|
12493
|
+
* whether a 743-row query had finished.
|
|
12494
|
+
*
|
|
12495
|
+
* A read that FAILS answers `false`, i.e. the intent is enqueued. Duplicate
|
|
12496
|
+
* over lost: a duplicate notification is an annoyance the operator can see,
|
|
12497
|
+
* and a dropped one is a notification nobody ever knows was owed.
|
|
12498
|
+
*/
|
|
12499
|
+
async rowExists(id) {
|
|
12500
|
+
try {
|
|
12501
|
+
const row = await this.store.get.query({
|
|
12502
|
+
collection: NC_OUTBOX_COLLECTION,
|
|
12503
|
+
key: id
|
|
12504
|
+
});
|
|
12505
|
+
return row !== null && row !== void 0;
|
|
12506
|
+
} catch (err) {
|
|
12507
|
+
this.logger.debug("outbox cold-horizon dedup read failed", { meta: {
|
|
12508
|
+
id,
|
|
12509
|
+
error: String(err)
|
|
12510
|
+
} });
|
|
12511
|
+
return false;
|
|
12512
|
+
}
|
|
12513
|
+
}
|
|
12431
12514
|
async persist(entry) {
|
|
12432
12515
|
await this.store.set.mutate({
|
|
12433
12516
|
collection: NC_OUTBOX_COLLECTION,
|
|
@@ -12955,7 +13038,20 @@ var NcRuleStore = class {
|
|
|
12955
13038
|
* The operator's words stay on disk forever.
|
|
12956
13039
|
*/
|
|
12957
13040
|
async load() {
|
|
12958
|
-
|
|
13041
|
+
await this.ledger.load();
|
|
13042
|
+
this.logger.debug("notification rules loaded", { meta: { rules: this.ledger.size } });
|
|
13043
|
+
}
|
|
13044
|
+
/**
|
|
13045
|
+
* The identity NAME→ID migration, split out of {@link load} on 2026-08-19:
|
|
13046
|
+
* `readIdentityIds` reaches the identity gallery, and at boot that single
|
|
13047
|
+
* read cost 119 s — 74 s more than every other start() read combined —
|
|
13048
|
+
* while buying only an ENRICHMENT: the engine still matches by name (it
|
|
13049
|
+
* keeps that leg precisely for the gallery-cannot-answer case below). It
|
|
13050
|
+
* now runs as a back-fill phase; the rename-immunity ids arrive seconds
|
|
13051
|
+
* later instead of gating the intake.
|
|
13052
|
+
*/
|
|
13053
|
+
async migrateIdentityIds() {
|
|
13054
|
+
const rules = this.ledger.snapshot();
|
|
12959
13055
|
const idsByName = await this.readIdentityIds();
|
|
12960
13056
|
let migrated = 0;
|
|
12961
13057
|
const unresolved = /* @__PURE__ */ new Set();
|
|
@@ -12966,10 +13062,7 @@ var NcRuleStore = class {
|
|
|
12966
13062
|
migrated += 1;
|
|
12967
13063
|
this.ledger.stage(result.rule);
|
|
12968
13064
|
}
|
|
12969
|
-
this.logger.
|
|
12970
|
-
rules: this.ledger.size,
|
|
12971
|
-
...migrated > 0 ? { identitiesResolvedToIds: migrated } : {}
|
|
12972
|
-
} });
|
|
13065
|
+
if (migrated > 0) this.logger.info("notification rules migrated identity names to ids", { meta: { identitiesResolvedToIds: migrated } });
|
|
12973
13066
|
if (unresolved.size > 0) this.logger.info("notification rules name identities the gallery does not know", { meta: { names: [...unresolved].join(", ") } });
|
|
12974
13067
|
}
|
|
12975
13068
|
/**
|
|
@@ -18953,6 +19046,18 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18953
19046
|
drainTicks = 0;
|
|
18954
19047
|
evaluationActive = false;
|
|
18955
19048
|
/**
|
|
19049
|
+
* The armed boot back-fill — see {@link runBootBackfill}. Held so
|
|
19050
|
+
* {@link whenHydrated} can be awaited by an operator surface or a test that
|
|
19051
|
+
* needs the durable state, and so nothing re-arms it.
|
|
19052
|
+
*/
|
|
19053
|
+
hydration = null;
|
|
19054
|
+
/**
|
|
19055
|
+
* The instant `start()` opened the intake. Everything persisted from here on
|
|
19056
|
+
* is evaluated live by THIS process, which is what bounds the boot reconcile
|
|
19057
|
+
* at its late end — see {@link reconcile}.
|
|
19058
|
+
*/
|
|
19059
|
+
intakeOpenedAt = 0;
|
|
19060
|
+
/**
|
|
18956
19061
|
* The tap-through buttons for ONE delivery, each with its own single-use
|
|
18957
19062
|
* token.
|
|
18958
19063
|
*
|
|
@@ -19454,49 +19559,192 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19454
19559
|
/**
|
|
19455
19560
|
* Load rules (every node — the cap provider serves CRUD from any node).
|
|
19456
19561
|
* When `evaluation` is true (the designated post-processing node ONLY),
|
|
19457
|
-
*
|
|
19458
|
-
*
|
|
19562
|
+
* open the intake and arm the drain + rule-reload timers.
|
|
19563
|
+
*
|
|
19564
|
+
* ## `start()` resolving means "the centre is EVALUATING"
|
|
19565
|
+
*
|
|
19566
|
+
* It does NOT mean the durable back-fill has finished. That contract was
|
|
19567
|
+
* written on 2026-08-14 for the two producers (a timelapse render inside
|
|
19568
|
+
* `initialize()` took eight capabilities off the cluster for two render
|
|
19569
|
+
* timeouts) and it applies to the centre itself for the same reason: this
|
|
19570
|
+
* method is awaited by `buildNotificationCenter`, which is awaited by
|
|
19571
|
+
* `pipeline-analytics.onInitialize()`, and `addon-runner` publishes a
|
|
19572
|
+
* child's capability manifest only AFTER `initialize()` returns.
|
|
19573
|
+
*
|
|
19574
|
+
* Measured on the live hub 2026-08-17 at ZERO transport contention, the
|
|
19575
|
+
* `buildNotificationCenter` boot step took **270 s** — all of it durable
|
|
19576
|
+
* back-fill: `hydrateLiveness` (775 subjects), `outbox.load` (743 rows),
|
|
19577
|
+
* and two N+1 per-device refresh loops (`readDeviceStates` and
|
|
19578
|
+
* `readDeviceZoneIds` issue ONE RPC per watched device, serially). For
|
|
19579
|
+
* those 270 s the cluster had no provider for any capability this addon
|
|
19580
|
+
* declares, and the intake below had not been opened either — so nothing
|
|
19581
|
+
* arriving in that window was late, it was never evaluated at all.
|
|
19582
|
+
*
|
|
19583
|
+
* ## What stays here, and why
|
|
19584
|
+
*
|
|
19585
|
+
* Only reads a notification cannot be CORRECT without, and each is one
|
|
19586
|
+
* store query:
|
|
19587
|
+
*
|
|
19588
|
+
* - the TEXT catalog — a notification built before it goes out in the
|
|
19589
|
+
* wrong language;
|
|
19590
|
+
* - the RULES — without them there is nothing to evaluate;
|
|
19591
|
+
* - the SNOOZE windows and the per-camera MUTES — both SUPPRESS, so a cold
|
|
19592
|
+
* one fails OPEN: the operator gets pushes they explicitly silenced;
|
|
19593
|
+
* - the timelapse + summary rule sets, which the producers armed below read.
|
|
19594
|
+
*
|
|
19595
|
+
* Everything else is a mirror the 30 s reload tick re-reads anyway, or a
|
|
19596
|
+
* boot-once hydration whose cold behaviour is already defined and safe —
|
|
19597
|
+
* see {@link runBootBackfill}, which names the direction for each one.
|
|
19459
19598
|
*/
|
|
19460
19599
|
async start(opts) {
|
|
19461
|
-
|
|
19462
|
-
|
|
19463
|
-
|
|
19464
|
-
|
|
19465
|
-
|
|
19466
|
-
|
|
19600
|
+
const t0 = Date.now();
|
|
19601
|
+
const timed = async (phase, run) => {
|
|
19602
|
+
const started = Date.now();
|
|
19603
|
+
await run();
|
|
19604
|
+
const now = Date.now();
|
|
19605
|
+
this.logger.info("notification start phase", { meta: {
|
|
19606
|
+
phase,
|
|
19607
|
+
ms: now - started,
|
|
19608
|
+
sinceStartMs: now - t0
|
|
19609
|
+
} });
|
|
19610
|
+
};
|
|
19611
|
+
await Promise.all([
|
|
19612
|
+
timed("texts", () => this.reloadTexts()),
|
|
19613
|
+
timed("rules", () => this.rules.load()),
|
|
19614
|
+
timed("snoozes", () => this.snoozes.load()),
|
|
19615
|
+
timed("deviceMutes", () => this.deviceMutes.load()),
|
|
19616
|
+
timed("timelapseRules", () => this.timelapseRules.load()),
|
|
19617
|
+
timed("summaryRules", () => this.summaryRules.load())
|
|
19618
|
+
]);
|
|
19467
19619
|
this.refreshOccupancyWatch();
|
|
19468
19620
|
if (!opts.evaluation) return;
|
|
19469
|
-
await this.deviceStates.refresh();
|
|
19470
|
-
await this.deviceDirectory.refresh();
|
|
19471
|
-
await this.hydrateLiveness();
|
|
19472
|
-
await this.zoneOwners.refresh();
|
|
19473
19621
|
this.sceneStates.refresh();
|
|
19622
|
+
this.intakeOpenedAt = this.now();
|
|
19474
19623
|
this.evaluationActive = true;
|
|
19475
|
-
await this.outbox.load();
|
|
19476
|
-
await this.seedCooldowns();
|
|
19477
|
-
await this.outbox.pruneBefore(this.now() - OUTBOX_RETENTION_MS);
|
|
19478
|
-
await this.hydrateOccupancy();
|
|
19479
|
-
await this.reconcile();
|
|
19480
19624
|
this.drainTimer = setInterval(() => {
|
|
19481
19625
|
this.drainTick();
|
|
19482
19626
|
}, this.deps.drainIntervalMs ?? DEFAULT_DRAIN_INTERVAL_MS);
|
|
19483
19627
|
this.reloadTimer = setInterval(() => {
|
|
19484
19628
|
this.reloadRules();
|
|
19485
19629
|
}, this.deps.ruleReloadIntervalMs ?? DEFAULT_RULE_RELOAD_INTERVAL_MS);
|
|
19486
|
-
|
|
19630
|
+
const timelapseScheduler = this.timelapseScheduler;
|
|
19631
|
+
if (timelapseScheduler !== null) await timed("timelapseProducer", () => timelapseScheduler.start()).catch((err) => {
|
|
19487
19632
|
this.logger.warn("timelapse producer did not start", { meta: { error: String(err) } });
|
|
19488
19633
|
});
|
|
19489
|
-
|
|
19634
|
+
const summaryProducer = this.summaryProducer;
|
|
19635
|
+
if (summaryProducer !== null) await timed("summaryProducer", () => summaryProducer.start()).catch((err) => {
|
|
19490
19636
|
this.logger.warn("summary producer did not start", { meta: { error: String(err) } });
|
|
19491
19637
|
});
|
|
19638
|
+
this.armBootBackfill();
|
|
19492
19639
|
this.logger.info("notification center started", { meta: {
|
|
19640
|
+
ms: Date.now() - t0,
|
|
19493
19641
|
rules: this.rules.list().length,
|
|
19494
|
-
pendingOutbox: this.outbox.pendingCount(),
|
|
19495
19642
|
timelapseRules: this.timelapseRules.list().length,
|
|
19496
19643
|
timelapseProducer: this.timelapseScheduler !== null,
|
|
19497
19644
|
summaryRules: this.summaryRules.list().length,
|
|
19498
19645
|
summaryProducer: this.summaryProducer !== null,
|
|
19499
|
-
summaryAi: this.summaryAi !== null
|
|
19646
|
+
summaryAi: this.summaryAi !== null,
|
|
19647
|
+
backfill: "armed"
|
|
19648
|
+
} });
|
|
19649
|
+
}
|
|
19650
|
+
/**
|
|
19651
|
+
* The durable back-fill, finished.
|
|
19652
|
+
*
|
|
19653
|
+
* Resolves immediately when nothing was armed (a non-evaluation node, or a
|
|
19654
|
+
* centre that was never started). Never rejects — every phase owns its own
|
|
19655
|
+
* failure. Exists so a caller that genuinely needs the hydrated state — a
|
|
19656
|
+
* test asserting the reseed, an operator surface reporting readiness — can
|
|
19657
|
+
* ask for it instead of sleeping.
|
|
19658
|
+
*/
|
|
19659
|
+
whenHydrated() {
|
|
19660
|
+
return this.hydration ?? Promise.resolve();
|
|
19661
|
+
}
|
|
19662
|
+
/**
|
|
19663
|
+
* ONE drain pass, awaited — the interval's own tick, called directly.
|
|
19664
|
+
*
|
|
19665
|
+
* The same code path the timer drives, deliberately: a caller that flushes
|
|
19666
|
+
* the queue through a parallel implementation is testing the parallel
|
|
19667
|
+
* implementation.
|
|
19668
|
+
*/
|
|
19669
|
+
async drainNow() {
|
|
19670
|
+
await this.drainTick();
|
|
19671
|
+
}
|
|
19672
|
+
armBootBackfill() {
|
|
19673
|
+
if (this.hydration !== null) return;
|
|
19674
|
+
this.hydration = this.runBootBackfill().catch((err) => {
|
|
19675
|
+
this.logger.warn("notification back-fill could not be armed", { meta: { error: String(err) } });
|
|
19676
|
+
});
|
|
19677
|
+
}
|
|
19678
|
+
/**
|
|
19679
|
+
* The boot back-fill: everything `start()` used to await.
|
|
19680
|
+
*
|
|
19681
|
+
* Ordered by what a cold mirror COSTS, most expensive first — this is a
|
|
19682
|
+
* sequence, not a `Promise.all`, because these reads share one UDS store
|
|
19683
|
+
* client and racing them is how a boot becomes a queue again.
|
|
19684
|
+
*
|
|
19685
|
+
* 1. the device directory + the LIVENESS ledger. Cold costs a FLOOD (D130:
|
|
19686
|
+
* one "camera online" per camera on the installation, 2026-08-13), so it
|
|
19687
|
+
* goes first. The ledger's own contract holds in the window: an empty
|
|
19688
|
+
* mirror SEEDS silently rather than notifying (D49, fail toward
|
|
19689
|
+
* discard), and device liveness is additionally held for 5 minutes by
|
|
19690
|
+
* `DeviceLivenessHoldoff` before it can notify at all — which is far
|
|
19691
|
+
* longer than this phase.
|
|
19692
|
+
* 2. the OCCUPANCY state. Cold costs a re-announced edge (the cold-baseline
|
|
19693
|
+
* bug, `occupancy-cold-seed.spec.ts`).
|
|
19694
|
+
* 3. the OUTBOX horizon, then the COOLDOWN seed. Both cost a REPEAT while
|
|
19695
|
+
* cold — a dedup id nobody remembers, a cooldown nobody recovered — and
|
|
19696
|
+
* the horizon goes first because it is what makes every phase after it
|
|
19697
|
+
* (the reconcile above all) idempotent. The outbox's own cold window is
|
|
19698
|
+
* made safe structurally — see `NcOutbox.load` / `rowExists`.
|
|
19699
|
+
* 4. the ZONE ownership. Cold costs a WIDER verdict (a zone-scoped rule
|
|
19700
|
+
* evaluates camera-wide), so it precedes the gates that fail closed.
|
|
19701
|
+
* 5. the DEVICE STATES. Cold costs silence — `undefined` never matches — so
|
|
19702
|
+
* it is the safest of the lot and goes after the N+1 above it.
|
|
19703
|
+
* 6. the RECONCILE, which replays records persisted across the crash gap.
|
|
19704
|
+
* After the outbox on purpose: the replay is idempotent only through the
|
|
19705
|
+
* dedup horizon.
|
|
19706
|
+
* 7. the retention prune. Pure hygiene, nothing reads its result.
|
|
19707
|
+
*
|
|
19708
|
+
* Every phase reports its own ms. The 2026-08-17 boot could not say which of
|
|
19709
|
+
* these owned the 270 s because none of them said anything, and the addon's
|
|
19710
|
+
* own per-step timing stopped at `buildNotificationCenter`.
|
|
19711
|
+
*/
|
|
19712
|
+
async runBootBackfill() {
|
|
19713
|
+
const t0 = Date.now();
|
|
19714
|
+
const phase = async (name, run) => {
|
|
19715
|
+
if (!this.evaluationActive) return false;
|
|
19716
|
+
const at = Date.now();
|
|
19717
|
+
try {
|
|
19718
|
+
await run();
|
|
19719
|
+
} catch (err) {
|
|
19720
|
+
this.logger.warn("notification back-fill phase failed", { meta: {
|
|
19721
|
+
phase: name,
|
|
19722
|
+
error: String(err)
|
|
19723
|
+
} });
|
|
19724
|
+
}
|
|
19725
|
+
this.logger.info("notification back-fill phase", { meta: {
|
|
19726
|
+
phase: name,
|
|
19727
|
+
ms: Date.now() - at,
|
|
19728
|
+
sinceStartMs: Date.now() - t0
|
|
19729
|
+
} });
|
|
19730
|
+
return true;
|
|
19731
|
+
};
|
|
19732
|
+
if (!await phase("deviceDirectory", () => this.deviceDirectory.refresh())) return;
|
|
19733
|
+
if (!await phase("liveness", () => this.hydrateLiveness())) return;
|
|
19734
|
+
if (!await phase("occupancy", () => this.hydrateOccupancy())) return;
|
|
19735
|
+
if (!await phase("outbox", () => this.outbox.load())) return;
|
|
19736
|
+
if (!await phase("cooldowns", () => this.seedCooldowns())) return;
|
|
19737
|
+
if (!await phase("zoneOwners", () => this.zoneOwners.refresh())) return;
|
|
19738
|
+
if (!await phase("deviceStates", () => this.deviceStates.refresh())) return;
|
|
19739
|
+
if (!await phase("ruleIdentityIds", () => this.rules.migrateIdentityIds())) return;
|
|
19740
|
+
if (!await phase("reconcile", () => this.reconcile())) return;
|
|
19741
|
+
if (!await phase("prune", async () => {
|
|
19742
|
+
await this.outbox.pruneBefore(this.now() - OUTBOX_RETENTION_MS);
|
|
19743
|
+
})) return;
|
|
19744
|
+
this.logger.info("notification center back-fill complete", { meta: {
|
|
19745
|
+
ms: Date.now() - t0,
|
|
19746
|
+
pendingOutbox: this.outbox.pendingCount(),
|
|
19747
|
+
dedupHorizon: this.outbox.hydrated()
|
|
19500
19748
|
} });
|
|
19501
19749
|
}
|
|
19502
19750
|
async stop() {
|
|
@@ -19514,6 +19762,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19514
19762
|
this.addonUpdates.dispose();
|
|
19515
19763
|
this.livenessHoldoff.dispose();
|
|
19516
19764
|
this.evaluationActive = false;
|
|
19765
|
+
this.hydration = null;
|
|
19517
19766
|
}
|
|
19518
19767
|
/**
|
|
19519
19768
|
* Consume ONE event. THE entry point of the notification path — every
|
|
@@ -21696,14 +21945,31 @@ var NotificationCenter = class NotificationCenter {
|
|
|
21696
21945
|
});
|
|
21697
21946
|
}
|
|
21698
21947
|
}
|
|
21699
|
-
/**
|
|
21948
|
+
/**
|
|
21949
|
+
* Boot crash-gap reconcile — see the module docstring.
|
|
21950
|
+
*
|
|
21951
|
+
* **Bounded at BOTH ends.** `since` closes the gap the previous process left;
|
|
21952
|
+
* {@link intakeOpenedAt} closes the one this one would otherwise open. The
|
|
21953
|
+
* reconcile used to run before the intake existed, so "a record newer than
|
|
21954
|
+
* the watermark" and "a record this process has not evaluated" were the same
|
|
21955
|
+
* set. They are not any more: the reconcile is a back-fill phase now, and
|
|
21956
|
+
* every record persisted since `start()` opened the intake has ALREADY been
|
|
21957
|
+
* evaluated in-process — the exact sentence `drainTick` uses to justify
|
|
21958
|
+
* advancing the watermark to `now`.
|
|
21959
|
+
*
|
|
21960
|
+
* Replaying one of those is not merely redundant. The outbox dedup id makes
|
|
21961
|
+
* the DELIVERY idempotent, but a rule's `onTrigger` sequence is actuated
|
|
21962
|
+
* before the cooldown gate and before any dedup (see `runRuleActions`), so a
|
|
21963
|
+
* replay would open the gate, arm the alarm or sound the siren a second time.
|
|
21964
|
+
*/
|
|
21700
21965
|
async reconcile() {
|
|
21701
21966
|
const now = this.now();
|
|
21702
21967
|
const watermark = await this.outbox.getWatermark();
|
|
21703
21968
|
const windowStart = now - (this.deps.reconcileWindowMs ?? DEFAULT_RECONCILE_WINDOW_MS);
|
|
21704
21969
|
const since = Math.max(windowStart, (watermark ?? 0) - RECONCILE_OVERLAP_MS);
|
|
21970
|
+
const until = this.intakeOpenedAt;
|
|
21705
21971
|
try {
|
|
21706
|
-
const ordered = [...await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT)].sort((a, b) => a.timestamp - b.timestamp);
|
|
21972
|
+
const ordered = [...await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT)].filter((e) => e.timestamp < until).sort((a, b) => a.timestamp - b.timestamp);
|
|
21707
21973
|
for (const event of ordered) {
|
|
21708
21974
|
const phase = packagePhaseOf(event);
|
|
21709
21975
|
this.consumeEvent(asReconcile(phase !== null ? incomingFromPackageEvent(event, phase) : incomingFromObjectEvent(event)));
|
|
@@ -51880,7 +52146,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
51880
52146
|
return out;
|
|
51881
52147
|
};
|
|
51882
52148
|
await step("declareCollections", () => this.declareCollections(api));
|
|
51883
|
-
|
|
52149
|
+
this.armAlarmPanelDeclaration(api);
|
|
51884
52150
|
const logger = this.ctx.logger;
|
|
51885
52151
|
const storage = await step("resolveMediaStorage", () => this.resolveMediaStorage(logger));
|
|
51886
52152
|
const stores = await step("buildStores", () => this.buildStores(api, logger, storage));
|
|
@@ -52023,8 +52289,33 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
52023
52289
|
* name over the operator's rename. What is left is the declaration and the
|
|
52024
52290
|
* ports.
|
|
52025
52291
|
*/
|
|
52292
|
+
/**
|
|
52293
|
+
* Arm the declaration as BACKGROUND work and return immediately.
|
|
52294
|
+
*
|
|
52295
|
+
* `initialize()` is on the critical path of the whole capability graph — the
|
|
52296
|
+
* runner publishes an addon's manifest only after it returns — so an await
|
|
52297
|
+
* that can outlive a boot is an outage with a fixed blast radius
|
|
52298
|
+
* (docs/architecture/addon-lifecycle.md). This one could not merely outlive a
|
|
52299
|
+
* boot, it could not possibly succeed during one: the first port call is
|
|
52300
|
+
* `integrations.getByAddonId`, a hub-CORE namespace served by `$core-caps`,
|
|
52301
|
+
* which `main.ts` registers only AFTER `app.init()` returns, i.e. after the
|
|
52302
|
+
* whole addon boot. Core caps are excluded from the boot-window retry, so the
|
|
52303
|
+
* call sat in the 60 s UDS timeout — measured at 61 s of DETERMINISTIC delay
|
|
52304
|
+
* on every boot of this addon (D167).
|
|
52305
|
+
*
|
|
52306
|
+
* And the wait bought nothing. The retry ladder is the real path: on the live
|
|
52307
|
+
* boot of 2026-08-19 the panel was declared at t0+204 s with `attempts=1`,
|
|
52308
|
+
* i.e. on the ladder's FIRST tick, after the doomed boot attempt had already
|
|
52309
|
+
* been paid for.
|
|
52310
|
+
*/
|
|
52311
|
+
armAlarmPanelDeclaration(api) {
|
|
52312
|
+
this.declareAlarmPanel(api).catch((err) => {
|
|
52313
|
+
this.ctx.logger.warn("alarm panel declaration could not be armed — the Alarm tab stays empty until this addon restarts", { meta: { error: require_dist.errMsg(err) } });
|
|
52314
|
+
});
|
|
52315
|
+
}
|
|
52026
52316
|
async declareAlarmPanel(api) {
|
|
52027
52317
|
if (await this.attemptAlarmPanelDeclaration(api)) return;
|
|
52318
|
+
if (this.shuttingDown) return;
|
|
52028
52319
|
this.alarmPanelConvergence = startAlarmPanelConvergence({
|
|
52029
52320
|
attempt: () => this.attemptAlarmPanelDeclaration(api),
|
|
52030
52321
|
logger: this.ctx.logger.child("alarm")
|
|
@@ -12091,6 +12091,18 @@ var NcOutbox = class {
|
|
|
12091
12091
|
maxAttempts;
|
|
12092
12092
|
drainBatchSize;
|
|
12093
12093
|
drainInFlight = false;
|
|
12094
|
+
/**
|
|
12095
|
+
* Has {@link load} landed?
|
|
12096
|
+
*
|
|
12097
|
+
* The load moved OFF the boot critical path (see
|
|
12098
|
+
* `NotificationCenter.runBootBackfill`), so for the first seconds of a boot
|
|
12099
|
+
* the centre evaluates and enqueues with an EMPTY `knownIds` — and
|
|
12100
|
+
* `knownIds` is the dedup ledger. Until this flips, {@link enqueue} asks the
|
|
12101
|
+
* store itself instead. False is therefore not "the load failed", it is
|
|
12102
|
+
* "the horizon is not authoritative yet"; a load that FAILED leaves it false
|
|
12103
|
+
* forever, which keeps the per-enqueue guard on, which is exactly right.
|
|
12104
|
+
*/
|
|
12105
|
+
horizonLoaded = false;
|
|
12094
12106
|
constructor(deps) {
|
|
12095
12107
|
this.store = deps.store;
|
|
12096
12108
|
this.logger = deps.logger;
|
|
@@ -12112,7 +12124,26 @@ var NcOutbox = class {
|
|
|
12112
12124
|
columns: [...NC_META_COLUMNS]
|
|
12113
12125
|
});
|
|
12114
12126
|
}
|
|
12115
|
-
/**
|
|
12127
|
+
/**
|
|
12128
|
+
* Hydrate pending rows + the dedup id horizon. Best-effort.
|
|
12129
|
+
*
|
|
12130
|
+
* **Runs OFF the boot critical path** (2026-08-17: 743 rows over the UDS
|
|
12131
|
+
* store client, ~50 s, inside the `initialize()` that publishes the runner's
|
|
12132
|
+
* capability manifest). So it lands against a centre that has already been
|
|
12133
|
+
* evaluating, enqueueing and DELIVERING for as long as the query took — and
|
|
12134
|
+
* the rows it is holding are a snapshot taken before all of that.
|
|
12135
|
+
*
|
|
12136
|
+
* That is why an id this process already knows is SKIPPED rather than
|
|
12137
|
+
* applied. `knownIds` is only ever written by an in-process {@link enqueue}
|
|
12138
|
+
* or by this method, so an id already in it at apply time is one whose live
|
|
12139
|
+
* state (pending, or absent because it went terminal) is strictly newer than
|
|
12140
|
+
* the snapshot. Applying the snapshot over it would put a row that has
|
|
12141
|
+
* already been DELIVERED back into `pending`, and the operator would receive
|
|
12142
|
+
* the same notification twice.
|
|
12143
|
+
*
|
|
12144
|
+
* Nothing is lost by the same token: this method only ADDS to `pending`, so
|
|
12145
|
+
* a row enqueued while the query was in flight is untouched.
|
|
12146
|
+
*/
|
|
12116
12147
|
async load() {
|
|
12117
12148
|
try {
|
|
12118
12149
|
const rows = await this.store.query.query({
|
|
@@ -12122,19 +12153,30 @@ var NcOutbox = class {
|
|
|
12122
12153
|
limit: 1e5
|
|
12123
12154
|
}
|
|
12124
12155
|
});
|
|
12156
|
+
let superseded = 0;
|
|
12125
12157
|
for (const row of rows) {
|
|
12158
|
+
if (this.knownIds.has(row.id)) {
|
|
12159
|
+
superseded += 1;
|
|
12160
|
+
continue;
|
|
12161
|
+
}
|
|
12126
12162
|
this.knownIds.add(row.id);
|
|
12127
12163
|
const entry = rowToEntry$1(row.id, row.data);
|
|
12128
12164
|
if (entry !== null && entry.status === "pending") this.pending.set(entry.id, entry);
|
|
12129
12165
|
}
|
|
12166
|
+
this.horizonLoaded = true;
|
|
12130
12167
|
this.logger.info("notification outbox loaded", { meta: {
|
|
12131
12168
|
pending: this.pending.size,
|
|
12132
|
-
known: this.knownIds.size
|
|
12169
|
+
known: this.knownIds.size,
|
|
12170
|
+
superseded
|
|
12133
12171
|
} });
|
|
12134
12172
|
} catch (err) {
|
|
12135
12173
|
this.logger.warn("notification outbox load failed", { meta: { error: String(err) } });
|
|
12136
12174
|
}
|
|
12137
12175
|
}
|
|
12176
|
+
/** Is the dedup horizon authoritative? See {@link horizonLoaded}. */
|
|
12177
|
+
hydrated() {
|
|
12178
|
+
return this.horizonLoaded;
|
|
12179
|
+
}
|
|
12138
12180
|
pendingCount() {
|
|
12139
12181
|
return this.pending.size;
|
|
12140
12182
|
}
|
|
@@ -12149,6 +12191,18 @@ var NcOutbox = class {
|
|
|
12149
12191
|
for (const input of inputs) {
|
|
12150
12192
|
const id = outboxEntryId(input);
|
|
12151
12193
|
if (this.knownIds.has(id)) continue;
|
|
12194
|
+
if (!this.horizonLoaded && await this.rowExists(id)) {
|
|
12195
|
+
this.knownIds.add(id);
|
|
12196
|
+
this.logger.info("outbox enqueue deduped against the store — the horizon is still cold", {
|
|
12197
|
+
tags: { deviceId: input.deviceId },
|
|
12198
|
+
meta: {
|
|
12199
|
+
id,
|
|
12200
|
+
ruleId: input.ruleId,
|
|
12201
|
+
targetId: input.targetId
|
|
12202
|
+
}
|
|
12203
|
+
});
|
|
12204
|
+
continue;
|
|
12205
|
+
}
|
|
12152
12206
|
const entry = {
|
|
12153
12207
|
id,
|
|
12154
12208
|
ruleId: input.ruleId,
|
|
@@ -12421,6 +12475,35 @@ var NcOutbox = class {
|
|
|
12421
12475
|
this.pending.set(entry.id, retry);
|
|
12422
12476
|
await this.mutate(retry);
|
|
12423
12477
|
}
|
|
12478
|
+
/**
|
|
12479
|
+
* Does a row already exist under this dedup id?
|
|
12480
|
+
*
|
|
12481
|
+
* Asked ONLY while {@link horizonLoaded} is false. {@link persist} writes
|
|
12482
|
+
* with `set` — an UPSERT keyed by the dedup id — so an enqueue that ran
|
|
12483
|
+
* before the horizon landed would rewrite a row that had already been
|
|
12484
|
+
* DELIVERED back to `pending`, and the drain would send it again. That is
|
|
12485
|
+
* the D-3 `maxPerTrack: 1` guarantee, and it cannot be allowed to depend on
|
|
12486
|
+
* whether a 743-row query had finished.
|
|
12487
|
+
*
|
|
12488
|
+
* A read that FAILS answers `false`, i.e. the intent is enqueued. Duplicate
|
|
12489
|
+
* over lost: a duplicate notification is an annoyance the operator can see,
|
|
12490
|
+
* and a dropped one is a notification nobody ever knows was owed.
|
|
12491
|
+
*/
|
|
12492
|
+
async rowExists(id) {
|
|
12493
|
+
try {
|
|
12494
|
+
const row = await this.store.get.query({
|
|
12495
|
+
collection: NC_OUTBOX_COLLECTION,
|
|
12496
|
+
key: id
|
|
12497
|
+
});
|
|
12498
|
+
return row !== null && row !== void 0;
|
|
12499
|
+
} catch (err) {
|
|
12500
|
+
this.logger.debug("outbox cold-horizon dedup read failed", { meta: {
|
|
12501
|
+
id,
|
|
12502
|
+
error: String(err)
|
|
12503
|
+
} });
|
|
12504
|
+
return false;
|
|
12505
|
+
}
|
|
12506
|
+
}
|
|
12424
12507
|
async persist(entry) {
|
|
12425
12508
|
await this.store.set.mutate({
|
|
12426
12509
|
collection: NC_OUTBOX_COLLECTION,
|
|
@@ -12948,7 +13031,20 @@ var NcRuleStore = class {
|
|
|
12948
13031
|
* The operator's words stay on disk forever.
|
|
12949
13032
|
*/
|
|
12950
13033
|
async load() {
|
|
12951
|
-
|
|
13034
|
+
await this.ledger.load();
|
|
13035
|
+
this.logger.debug("notification rules loaded", { meta: { rules: this.ledger.size } });
|
|
13036
|
+
}
|
|
13037
|
+
/**
|
|
13038
|
+
* The identity NAME→ID migration, split out of {@link load} on 2026-08-19:
|
|
13039
|
+
* `readIdentityIds` reaches the identity gallery, and at boot that single
|
|
13040
|
+
* read cost 119 s — 74 s more than every other start() read combined —
|
|
13041
|
+
* while buying only an ENRICHMENT: the engine still matches by name (it
|
|
13042
|
+
* keeps that leg precisely for the gallery-cannot-answer case below). It
|
|
13043
|
+
* now runs as a back-fill phase; the rename-immunity ids arrive seconds
|
|
13044
|
+
* later instead of gating the intake.
|
|
13045
|
+
*/
|
|
13046
|
+
async migrateIdentityIds() {
|
|
13047
|
+
const rules = this.ledger.snapshot();
|
|
12952
13048
|
const idsByName = await this.readIdentityIds();
|
|
12953
13049
|
let migrated = 0;
|
|
12954
13050
|
const unresolved = /* @__PURE__ */ new Set();
|
|
@@ -12959,10 +13055,7 @@ var NcRuleStore = class {
|
|
|
12959
13055
|
migrated += 1;
|
|
12960
13056
|
this.ledger.stage(result.rule);
|
|
12961
13057
|
}
|
|
12962
|
-
this.logger.
|
|
12963
|
-
rules: this.ledger.size,
|
|
12964
|
-
...migrated > 0 ? { identitiesResolvedToIds: migrated } : {}
|
|
12965
|
-
} });
|
|
13058
|
+
if (migrated > 0) this.logger.info("notification rules migrated identity names to ids", { meta: { identitiesResolvedToIds: migrated } });
|
|
12966
13059
|
if (unresolved.size > 0) this.logger.info("notification rules name identities the gallery does not know", { meta: { names: [...unresolved].join(", ") } });
|
|
12967
13060
|
}
|
|
12968
13061
|
/**
|
|
@@ -18931,6 +19024,18 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18931
19024
|
drainTicks = 0;
|
|
18932
19025
|
evaluationActive = false;
|
|
18933
19026
|
/**
|
|
19027
|
+
* The armed boot back-fill — see {@link runBootBackfill}. Held so
|
|
19028
|
+
* {@link whenHydrated} can be awaited by an operator surface or a test that
|
|
19029
|
+
* needs the durable state, and so nothing re-arms it.
|
|
19030
|
+
*/
|
|
19031
|
+
hydration = null;
|
|
19032
|
+
/**
|
|
19033
|
+
* The instant `start()` opened the intake. Everything persisted from here on
|
|
19034
|
+
* is evaluated live by THIS process, which is what bounds the boot reconcile
|
|
19035
|
+
* at its late end — see {@link reconcile}.
|
|
19036
|
+
*/
|
|
19037
|
+
intakeOpenedAt = 0;
|
|
19038
|
+
/**
|
|
18934
19039
|
* The tap-through buttons for ONE delivery, each with its own single-use
|
|
18935
19040
|
* token.
|
|
18936
19041
|
*
|
|
@@ -19432,49 +19537,192 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19432
19537
|
/**
|
|
19433
19538
|
* Load rules (every node — the cap provider serves CRUD from any node).
|
|
19434
19539
|
* When `evaluation` is true (the designated post-processing node ONLY),
|
|
19435
|
-
*
|
|
19436
|
-
*
|
|
19540
|
+
* open the intake and arm the drain + rule-reload timers.
|
|
19541
|
+
*
|
|
19542
|
+
* ## `start()` resolving means "the centre is EVALUATING"
|
|
19543
|
+
*
|
|
19544
|
+
* It does NOT mean the durable back-fill has finished. That contract was
|
|
19545
|
+
* written on 2026-08-14 for the two producers (a timelapse render inside
|
|
19546
|
+
* `initialize()` took eight capabilities off the cluster for two render
|
|
19547
|
+
* timeouts) and it applies to the centre itself for the same reason: this
|
|
19548
|
+
* method is awaited by `buildNotificationCenter`, which is awaited by
|
|
19549
|
+
* `pipeline-analytics.onInitialize()`, and `addon-runner` publishes a
|
|
19550
|
+
* child's capability manifest only AFTER `initialize()` returns.
|
|
19551
|
+
*
|
|
19552
|
+
* Measured on the live hub 2026-08-17 at ZERO transport contention, the
|
|
19553
|
+
* `buildNotificationCenter` boot step took **270 s** — all of it durable
|
|
19554
|
+
* back-fill: `hydrateLiveness` (775 subjects), `outbox.load` (743 rows),
|
|
19555
|
+
* and two N+1 per-device refresh loops (`readDeviceStates` and
|
|
19556
|
+
* `readDeviceZoneIds` issue ONE RPC per watched device, serially). For
|
|
19557
|
+
* those 270 s the cluster had no provider for any capability this addon
|
|
19558
|
+
* declares, and the intake below had not been opened either — so nothing
|
|
19559
|
+
* arriving in that window was late, it was never evaluated at all.
|
|
19560
|
+
*
|
|
19561
|
+
* ## What stays here, and why
|
|
19562
|
+
*
|
|
19563
|
+
* Only reads a notification cannot be CORRECT without, and each is one
|
|
19564
|
+
* store query:
|
|
19565
|
+
*
|
|
19566
|
+
* - the TEXT catalog — a notification built before it goes out in the
|
|
19567
|
+
* wrong language;
|
|
19568
|
+
* - the RULES — without them there is nothing to evaluate;
|
|
19569
|
+
* - the SNOOZE windows and the per-camera MUTES — both SUPPRESS, so a cold
|
|
19570
|
+
* one fails OPEN: the operator gets pushes they explicitly silenced;
|
|
19571
|
+
* - the timelapse + summary rule sets, which the producers armed below read.
|
|
19572
|
+
*
|
|
19573
|
+
* Everything else is a mirror the 30 s reload tick re-reads anyway, or a
|
|
19574
|
+
* boot-once hydration whose cold behaviour is already defined and safe —
|
|
19575
|
+
* see {@link runBootBackfill}, which names the direction for each one.
|
|
19437
19576
|
*/
|
|
19438
19577
|
async start(opts) {
|
|
19439
|
-
|
|
19440
|
-
|
|
19441
|
-
|
|
19442
|
-
|
|
19443
|
-
|
|
19444
|
-
|
|
19578
|
+
const t0 = Date.now();
|
|
19579
|
+
const timed = async (phase, run) => {
|
|
19580
|
+
const started = Date.now();
|
|
19581
|
+
await run();
|
|
19582
|
+
const now = Date.now();
|
|
19583
|
+
this.logger.info("notification start phase", { meta: {
|
|
19584
|
+
phase,
|
|
19585
|
+
ms: now - started,
|
|
19586
|
+
sinceStartMs: now - t0
|
|
19587
|
+
} });
|
|
19588
|
+
};
|
|
19589
|
+
await Promise.all([
|
|
19590
|
+
timed("texts", () => this.reloadTexts()),
|
|
19591
|
+
timed("rules", () => this.rules.load()),
|
|
19592
|
+
timed("snoozes", () => this.snoozes.load()),
|
|
19593
|
+
timed("deviceMutes", () => this.deviceMutes.load()),
|
|
19594
|
+
timed("timelapseRules", () => this.timelapseRules.load()),
|
|
19595
|
+
timed("summaryRules", () => this.summaryRules.load())
|
|
19596
|
+
]);
|
|
19445
19597
|
this.refreshOccupancyWatch();
|
|
19446
19598
|
if (!opts.evaluation) return;
|
|
19447
|
-
await this.deviceStates.refresh();
|
|
19448
|
-
await this.deviceDirectory.refresh();
|
|
19449
|
-
await this.hydrateLiveness();
|
|
19450
|
-
await this.zoneOwners.refresh();
|
|
19451
19599
|
this.sceneStates.refresh();
|
|
19600
|
+
this.intakeOpenedAt = this.now();
|
|
19452
19601
|
this.evaluationActive = true;
|
|
19453
|
-
await this.outbox.load();
|
|
19454
|
-
await this.seedCooldowns();
|
|
19455
|
-
await this.outbox.pruneBefore(this.now() - OUTBOX_RETENTION_MS);
|
|
19456
|
-
await this.hydrateOccupancy();
|
|
19457
|
-
await this.reconcile();
|
|
19458
19602
|
this.drainTimer = setInterval(() => {
|
|
19459
19603
|
this.drainTick();
|
|
19460
19604
|
}, this.deps.drainIntervalMs ?? DEFAULT_DRAIN_INTERVAL_MS);
|
|
19461
19605
|
this.reloadTimer = setInterval(() => {
|
|
19462
19606
|
this.reloadRules();
|
|
19463
19607
|
}, this.deps.ruleReloadIntervalMs ?? DEFAULT_RULE_RELOAD_INTERVAL_MS);
|
|
19464
|
-
|
|
19608
|
+
const timelapseScheduler = this.timelapseScheduler;
|
|
19609
|
+
if (timelapseScheduler !== null) await timed("timelapseProducer", () => timelapseScheduler.start()).catch((err) => {
|
|
19465
19610
|
this.logger.warn("timelapse producer did not start", { meta: { error: String(err) } });
|
|
19466
19611
|
});
|
|
19467
|
-
|
|
19612
|
+
const summaryProducer = this.summaryProducer;
|
|
19613
|
+
if (summaryProducer !== null) await timed("summaryProducer", () => summaryProducer.start()).catch((err) => {
|
|
19468
19614
|
this.logger.warn("summary producer did not start", { meta: { error: String(err) } });
|
|
19469
19615
|
});
|
|
19616
|
+
this.armBootBackfill();
|
|
19470
19617
|
this.logger.info("notification center started", { meta: {
|
|
19618
|
+
ms: Date.now() - t0,
|
|
19471
19619
|
rules: this.rules.list().length,
|
|
19472
|
-
pendingOutbox: this.outbox.pendingCount(),
|
|
19473
19620
|
timelapseRules: this.timelapseRules.list().length,
|
|
19474
19621
|
timelapseProducer: this.timelapseScheduler !== null,
|
|
19475
19622
|
summaryRules: this.summaryRules.list().length,
|
|
19476
19623
|
summaryProducer: this.summaryProducer !== null,
|
|
19477
|
-
summaryAi: this.summaryAi !== null
|
|
19624
|
+
summaryAi: this.summaryAi !== null,
|
|
19625
|
+
backfill: "armed"
|
|
19626
|
+
} });
|
|
19627
|
+
}
|
|
19628
|
+
/**
|
|
19629
|
+
* The durable back-fill, finished.
|
|
19630
|
+
*
|
|
19631
|
+
* Resolves immediately when nothing was armed (a non-evaluation node, or a
|
|
19632
|
+
* centre that was never started). Never rejects — every phase owns its own
|
|
19633
|
+
* failure. Exists so a caller that genuinely needs the hydrated state — a
|
|
19634
|
+
* test asserting the reseed, an operator surface reporting readiness — can
|
|
19635
|
+
* ask for it instead of sleeping.
|
|
19636
|
+
*/
|
|
19637
|
+
whenHydrated() {
|
|
19638
|
+
return this.hydration ?? Promise.resolve();
|
|
19639
|
+
}
|
|
19640
|
+
/**
|
|
19641
|
+
* ONE drain pass, awaited — the interval's own tick, called directly.
|
|
19642
|
+
*
|
|
19643
|
+
* The same code path the timer drives, deliberately: a caller that flushes
|
|
19644
|
+
* the queue through a parallel implementation is testing the parallel
|
|
19645
|
+
* implementation.
|
|
19646
|
+
*/
|
|
19647
|
+
async drainNow() {
|
|
19648
|
+
await this.drainTick();
|
|
19649
|
+
}
|
|
19650
|
+
armBootBackfill() {
|
|
19651
|
+
if (this.hydration !== null) return;
|
|
19652
|
+
this.hydration = this.runBootBackfill().catch((err) => {
|
|
19653
|
+
this.logger.warn("notification back-fill could not be armed", { meta: { error: String(err) } });
|
|
19654
|
+
});
|
|
19655
|
+
}
|
|
19656
|
+
/**
|
|
19657
|
+
* The boot back-fill: everything `start()` used to await.
|
|
19658
|
+
*
|
|
19659
|
+
* Ordered by what a cold mirror COSTS, most expensive first — this is a
|
|
19660
|
+
* sequence, not a `Promise.all`, because these reads share one UDS store
|
|
19661
|
+
* client and racing them is how a boot becomes a queue again.
|
|
19662
|
+
*
|
|
19663
|
+
* 1. the device directory + the LIVENESS ledger. Cold costs a FLOOD (D130:
|
|
19664
|
+
* one "camera online" per camera on the installation, 2026-08-13), so it
|
|
19665
|
+
* goes first. The ledger's own contract holds in the window: an empty
|
|
19666
|
+
* mirror SEEDS silently rather than notifying (D49, fail toward
|
|
19667
|
+
* discard), and device liveness is additionally held for 5 minutes by
|
|
19668
|
+
* `DeviceLivenessHoldoff` before it can notify at all — which is far
|
|
19669
|
+
* longer than this phase.
|
|
19670
|
+
* 2. the OCCUPANCY state. Cold costs a re-announced edge (the cold-baseline
|
|
19671
|
+
* bug, `occupancy-cold-seed.spec.ts`).
|
|
19672
|
+
* 3. the OUTBOX horizon, then the COOLDOWN seed. Both cost a REPEAT while
|
|
19673
|
+
* cold — a dedup id nobody remembers, a cooldown nobody recovered — and
|
|
19674
|
+
* the horizon goes first because it is what makes every phase after it
|
|
19675
|
+
* (the reconcile above all) idempotent. The outbox's own cold window is
|
|
19676
|
+
* made safe structurally — see `NcOutbox.load` / `rowExists`.
|
|
19677
|
+
* 4. the ZONE ownership. Cold costs a WIDER verdict (a zone-scoped rule
|
|
19678
|
+
* evaluates camera-wide), so it precedes the gates that fail closed.
|
|
19679
|
+
* 5. the DEVICE STATES. Cold costs silence — `undefined` never matches — so
|
|
19680
|
+
* it is the safest of the lot and goes after the N+1 above it.
|
|
19681
|
+
* 6. the RECONCILE, which replays records persisted across the crash gap.
|
|
19682
|
+
* After the outbox on purpose: the replay is idempotent only through the
|
|
19683
|
+
* dedup horizon.
|
|
19684
|
+
* 7. the retention prune. Pure hygiene, nothing reads its result.
|
|
19685
|
+
*
|
|
19686
|
+
* Every phase reports its own ms. The 2026-08-17 boot could not say which of
|
|
19687
|
+
* these owned the 270 s because none of them said anything, and the addon's
|
|
19688
|
+
* own per-step timing stopped at `buildNotificationCenter`.
|
|
19689
|
+
*/
|
|
19690
|
+
async runBootBackfill() {
|
|
19691
|
+
const t0 = Date.now();
|
|
19692
|
+
const phase = async (name, run) => {
|
|
19693
|
+
if (!this.evaluationActive) return false;
|
|
19694
|
+
const at = Date.now();
|
|
19695
|
+
try {
|
|
19696
|
+
await run();
|
|
19697
|
+
} catch (err) {
|
|
19698
|
+
this.logger.warn("notification back-fill phase failed", { meta: {
|
|
19699
|
+
phase: name,
|
|
19700
|
+
error: String(err)
|
|
19701
|
+
} });
|
|
19702
|
+
}
|
|
19703
|
+
this.logger.info("notification back-fill phase", { meta: {
|
|
19704
|
+
phase: name,
|
|
19705
|
+
ms: Date.now() - at,
|
|
19706
|
+
sinceStartMs: Date.now() - t0
|
|
19707
|
+
} });
|
|
19708
|
+
return true;
|
|
19709
|
+
};
|
|
19710
|
+
if (!await phase("deviceDirectory", () => this.deviceDirectory.refresh())) return;
|
|
19711
|
+
if (!await phase("liveness", () => this.hydrateLiveness())) return;
|
|
19712
|
+
if (!await phase("occupancy", () => this.hydrateOccupancy())) return;
|
|
19713
|
+
if (!await phase("outbox", () => this.outbox.load())) return;
|
|
19714
|
+
if (!await phase("cooldowns", () => this.seedCooldowns())) return;
|
|
19715
|
+
if (!await phase("zoneOwners", () => this.zoneOwners.refresh())) return;
|
|
19716
|
+
if (!await phase("deviceStates", () => this.deviceStates.refresh())) return;
|
|
19717
|
+
if (!await phase("ruleIdentityIds", () => this.rules.migrateIdentityIds())) return;
|
|
19718
|
+
if (!await phase("reconcile", () => this.reconcile())) return;
|
|
19719
|
+
if (!await phase("prune", async () => {
|
|
19720
|
+
await this.outbox.pruneBefore(this.now() - OUTBOX_RETENTION_MS);
|
|
19721
|
+
})) return;
|
|
19722
|
+
this.logger.info("notification center back-fill complete", { meta: {
|
|
19723
|
+
ms: Date.now() - t0,
|
|
19724
|
+
pendingOutbox: this.outbox.pendingCount(),
|
|
19725
|
+
dedupHorizon: this.outbox.hydrated()
|
|
19478
19726
|
} });
|
|
19479
19727
|
}
|
|
19480
19728
|
async stop() {
|
|
@@ -19492,6 +19740,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19492
19740
|
this.addonUpdates.dispose();
|
|
19493
19741
|
this.livenessHoldoff.dispose();
|
|
19494
19742
|
this.evaluationActive = false;
|
|
19743
|
+
this.hydration = null;
|
|
19495
19744
|
}
|
|
19496
19745
|
/**
|
|
19497
19746
|
* Consume ONE event. THE entry point of the notification path — every
|
|
@@ -21674,14 +21923,31 @@ var NotificationCenter = class NotificationCenter {
|
|
|
21674
21923
|
});
|
|
21675
21924
|
}
|
|
21676
21925
|
}
|
|
21677
|
-
/**
|
|
21926
|
+
/**
|
|
21927
|
+
* Boot crash-gap reconcile — see the module docstring.
|
|
21928
|
+
*
|
|
21929
|
+
* **Bounded at BOTH ends.** `since` closes the gap the previous process left;
|
|
21930
|
+
* {@link intakeOpenedAt} closes the one this one would otherwise open. The
|
|
21931
|
+
* reconcile used to run before the intake existed, so "a record newer than
|
|
21932
|
+
* the watermark" and "a record this process has not evaluated" were the same
|
|
21933
|
+
* set. They are not any more: the reconcile is a back-fill phase now, and
|
|
21934
|
+
* every record persisted since `start()` opened the intake has ALREADY been
|
|
21935
|
+
* evaluated in-process — the exact sentence `drainTick` uses to justify
|
|
21936
|
+
* advancing the watermark to `now`.
|
|
21937
|
+
*
|
|
21938
|
+
* Replaying one of those is not merely redundant. The outbox dedup id makes
|
|
21939
|
+
* the DELIVERY idempotent, but a rule's `onTrigger` sequence is actuated
|
|
21940
|
+
* before the cooldown gate and before any dedup (see `runRuleActions`), so a
|
|
21941
|
+
* replay would open the gate, arm the alarm or sound the siren a second time.
|
|
21942
|
+
*/
|
|
21678
21943
|
async reconcile() {
|
|
21679
21944
|
const now = this.now();
|
|
21680
21945
|
const watermark = await this.outbox.getWatermark();
|
|
21681
21946
|
const windowStart = now - (this.deps.reconcileWindowMs ?? DEFAULT_RECONCILE_WINDOW_MS);
|
|
21682
21947
|
const since = Math.max(windowStart, (watermark ?? 0) - RECONCILE_OVERLAP_MS);
|
|
21948
|
+
const until = this.intakeOpenedAt;
|
|
21683
21949
|
try {
|
|
21684
|
-
const ordered = [...await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT)].sort((a, b) => a.timestamp - b.timestamp);
|
|
21950
|
+
const ordered = [...await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT)].filter((e) => e.timestamp < until).sort((a, b) => a.timestamp - b.timestamp);
|
|
21685
21951
|
for (const event of ordered) {
|
|
21686
21952
|
const phase = packagePhaseOf(event);
|
|
21687
21953
|
this.consumeEvent(asReconcile(phase !== null ? incomingFromPackageEvent(event, phase) : incomingFromObjectEvent(event)));
|
|
@@ -51820,7 +52086,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
51820
52086
|
return out;
|
|
51821
52087
|
};
|
|
51822
52088
|
await step("declareCollections", () => this.declareCollections(api));
|
|
51823
|
-
|
|
52089
|
+
this.armAlarmPanelDeclaration(api);
|
|
51824
52090
|
const logger = this.ctx.logger;
|
|
51825
52091
|
const storage = await step("resolveMediaStorage", () => this.resolveMediaStorage(logger));
|
|
51826
52092
|
const stores = await step("buildStores", () => this.buildStores(api, logger, storage));
|
|
@@ -51963,8 +52229,33 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
51963
52229
|
* name over the operator's rename. What is left is the declaration and the
|
|
51964
52230
|
* ports.
|
|
51965
52231
|
*/
|
|
52232
|
+
/**
|
|
52233
|
+
* Arm the declaration as BACKGROUND work and return immediately.
|
|
52234
|
+
*
|
|
52235
|
+
* `initialize()` is on the critical path of the whole capability graph — the
|
|
52236
|
+
* runner publishes an addon's manifest only after it returns — so an await
|
|
52237
|
+
* that can outlive a boot is an outage with a fixed blast radius
|
|
52238
|
+
* (docs/architecture/addon-lifecycle.md). This one could not merely outlive a
|
|
52239
|
+
* boot, it could not possibly succeed during one: the first port call is
|
|
52240
|
+
* `integrations.getByAddonId`, a hub-CORE namespace served by `$core-caps`,
|
|
52241
|
+
* which `main.ts` registers only AFTER `app.init()` returns, i.e. after the
|
|
52242
|
+
* whole addon boot. Core caps are excluded from the boot-window retry, so the
|
|
52243
|
+
* call sat in the 60 s UDS timeout — measured at 61 s of DETERMINISTIC delay
|
|
52244
|
+
* on every boot of this addon (D167).
|
|
52245
|
+
*
|
|
52246
|
+
* And the wait bought nothing. The retry ladder is the real path: on the live
|
|
52247
|
+
* boot of 2026-08-19 the panel was declared at t0+204 s with `attempts=1`,
|
|
52248
|
+
* i.e. on the ladder's FIRST tick, after the doomed boot attempt had already
|
|
52249
|
+
* been paid for.
|
|
52250
|
+
*/
|
|
52251
|
+
armAlarmPanelDeclaration(api) {
|
|
52252
|
+
this.declareAlarmPanel(api).catch((err) => {
|
|
52253
|
+
this.ctx.logger.warn("alarm panel declaration could not be armed — the Alarm tab stays empty until this addon restarts", { meta: { error: errMsg(err) } });
|
|
52254
|
+
});
|
|
52255
|
+
}
|
|
51966
52256
|
async declareAlarmPanel(api) {
|
|
51967
52257
|
if (await this.attemptAlarmPanelDeclaration(api)) return;
|
|
52258
|
+
if (this.shuttingDown) return;
|
|
51968
52259
|
this.alarmPanelConvergence = startAlarmPanelConvergence({
|
|
51969
52260
|
attempt: () => this.attemptAlarmPanelDeclaration(api),
|
|
51970
52261
|
logger: this.ctx.logger.child("alarm")
|
|
@@ -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-
|
|
33
|
+
return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Caz0R4UZ.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.
|
|
3
|
+
"version": "1.2.99",
|
|
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",
|