@camstack/addon-pipeline-orchestrator 1.1.49 → 1.1.50

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/index.mjs CHANGED
@@ -6466,6 +6466,19 @@ function scopeKey(scope) {
6466
6466
  case "device": return `device:${scope.deviceId}`;
6467
6467
  }
6468
6468
  }
6469
+ /**
6470
+ * Producer transition order within ONE generation: `starting → ready →
6471
+ * down`. Used by `hydrate` to accept only forward moves for
6472
+ * same-generation snapshot records (a snapshot can be ahead of a lost
6473
+ * delta, never behind it, within one producer generation).
6474
+ */
6475
+ function stateRank(state) {
6476
+ switch (state) {
6477
+ case "starting": return 0;
6478
+ case "ready": return 1;
6479
+ case "down": return 2;
6480
+ }
6481
+ }
6469
6482
  function scopesEqual(a, b) {
6470
6483
  if (a.type !== b.type) return false;
6471
6484
  if (a.type === "global" || b.type === "global") return true;
@@ -6516,43 +6529,70 @@ var ReadinessRegistry = class {
6516
6529
  return Array.from(this.snapshot.values());
6517
6530
  }
6518
6531
  /**
6519
- * Hydrate the snapshot from an authoritative source. Entries already
6520
- * present locally are skipped live deltas (received via the event
6521
- * bus subscription) always take precedence over the snapshot. For
6522
- * each newly hydrated entry, a one-shot transition is dispatched to
6523
- * matching subscriptions so pending `awaitReady` callers unblock
6524
- * without having to wait for a fresh event.
6532
+ * Hydrate the snapshot from an authoritative source a RECONCILE, not
6533
+ * an add-only merge (D8: readiness events are telemetry and may be
6534
+ * lost; the on-reconnect/periodic snapshot re-pull is the repair path,
6535
+ * so it MUST be able to advance a stale record the 2026-07-17
6536
+ * "still awaiting platform-probe forever" wedge was this method
6537
+ * skipping every already-seen key).
6525
6538
  *
6526
- * Local `epoch` is reset to 1 per entry consumer-side epoch is
6527
- * derived from observed generation transitions, so this mirrors the
6528
- * value that would have been assigned had the consumer observed the
6529
- * first `ready` event directly.
6539
+ * - **Unseen keys** are added (epoch 1 mirrors the value the
6540
+ * consumer would have derived from the first observed `ready`).
6541
+ * - **Existing keys** are UPDATED when the authoritative record
6542
+ * carries a different generation (producer restart / synthetic-down
6543
+ * superseded — epoch bumps on a new-generation `ready`, mirroring
6544
+ * `ingest`), or a FORWARD state move within the same generation
6545
+ * (`starting → ready → down`; within one producer generation a
6546
+ * snapshot can only be ahead of a lost delta, never behind it).
6547
+ * - **Never a same-generation regress** — a snapshot pulled just
6548
+ * before a live delta landed must not un-ready a record (the next
6549
+ * reconcile round converges it anyway).
6550
+ * - **Never a record this process is authoritative for** — node-scoped
6551
+ * records for our own nodeId, and device/global records we emitted,
6552
+ * keep local truth (an authority's stale copy of OUR record must
6553
+ * not regress us).
6554
+ *
6555
+ * Every applied entry dispatches a one-shot transition to matching
6556
+ * subscriptions so pending `awaitReady` callers unblock without having
6557
+ * to wait for a fresh event.
6530
6558
  */
6531
6559
  hydrate(records) {
6532
6560
  const now = this.now();
6533
6561
  for (const record of records) {
6534
6562
  const key = readinessKey(record.capName, record.scope);
6535
- if (this.snapshot.has(key)) continue;
6563
+ const prev = this.snapshot.get(key) ?? null;
6564
+ let epoch;
6565
+ let durationInPrevState;
6566
+ if (prev !== null) {
6567
+ if (this.isLocallyAuthoritative(prev)) continue;
6568
+ const sameGeneration = prev.generation === record.generation;
6569
+ if (sameGeneration && stateRank(record.state) <= stateRank(prev.state)) continue;
6570
+ epoch = !sameGeneration && record.state === "ready" ? prev.epoch + 1 : prev.epoch;
6571
+ durationInPrevState = Math.max(0, now - prev.lastChange);
6572
+ } else {
6573
+ epoch = 1;
6574
+ durationInPrevState = 0;
6575
+ }
6536
6576
  const hydrated = {
6537
6577
  capName: record.capName,
6538
6578
  scope: record.scope,
6539
6579
  state: record.state,
6540
6580
  generation: record.generation,
6541
- epoch: 1,
6581
+ epoch,
6542
6582
  lastChange: now,
6543
6583
  sourceNodeId: record.sourceNodeId
6544
6584
  };
6545
6585
  this.snapshot.set(key, hydrated);
6546
- if (this.logger) this.logger.debug(`readiness: ${record.capName} (${scopeKey(record.scope)}) → ${record.state} (hydrated, gen=${record.generation.slice(0, 6)})`);
6586
+ if (this.logger) this.logger.debug(`readiness: ${record.capName} (${scopeKey(record.scope)}) → ${record.state} (hydrated${prev !== null ? " update" : ""}, gen=${record.generation.slice(0, 6)})`);
6547
6587
  const transition = {
6548
6588
  capName: record.capName,
6549
6589
  scope: record.scope,
6550
6590
  state: record.state,
6551
- epoch: 1,
6591
+ epoch,
6552
6592
  generation: record.generation,
6553
6593
  sourceNodeId: "hydrated",
6554
6594
  ts: now,
6555
- durationInPrevState: 0
6595
+ durationInPrevState
6556
6596
  };
6557
6597
  for (const sub of this.subscriptions) {
6558
6598
  if (sub.capName !== record.capName) continue;
@@ -6565,6 +6605,17 @@ var ReadinessRegistry = class {
6565
6605
  }
6566
6606
  }
6567
6607
  }
6608
+ /**
6609
+ * True when THIS process is the origin authority for `record` — a
6610
+ * snapshot pulled from elsewhere must never overwrite it:
6611
+ * - node-scoped records whose `scope.nodeId` is our own node id
6612
+ * (this process — or a child bridged onto our bus — emits them);
6613
+ * - device/global-scoped records whose latest transition WE emitted.
6614
+ */
6615
+ isLocallyAuthoritative(record) {
6616
+ if (record.scope.type === "node") return record.scope.nodeId === this.sourceNodeId;
6617
+ return record.sourceNodeId === this.sourceNodeId;
6618
+ }
6568
6619
  /** Shallow copy of the full snapshot — mainly for diagnostics/tests. */
6569
6620
  getAll() {
6570
6621
  return new Map(this.snapshot);
@@ -7651,6 +7702,24 @@ var RecordingRetentionSchema = object({
7651
7702
  maxSizeGb: number().min(0).optional()
7652
7703
  });
7653
7704
  /**
7705
+ * Scrub-thumbnail fidelity preset — the single per-camera selector bundling the
7706
+ * sprite tile RESOLUTION + JPEG QUALITY the recorder packs timeline-scrub
7707
+ * previews at. Five graduated steps; absent on a config = `standard` (the
7708
+ * shipped default, matching `sheet-geometry`/`sheet-composer`).
7709
+ *
7710
+ * Existing sheets are IMMUTABLE — a changed preset applies to NEW windows only.
7711
+ * Each window's index sidecar carries its own tile dims, so a camera whose
7712
+ * preset changed over time renders every historical window at the dims it was
7713
+ * written with.
7714
+ */
7715
+ var ScrubThumbnailPresetSchema = _enum([
7716
+ "minimal",
7717
+ "low",
7718
+ "standard",
7719
+ "high",
7720
+ "max"
7721
+ ]);
7722
+ /**
7654
7723
  * The full per-camera recording intent — the wire shape of a RecordingTarget.
7655
7724
  *
7656
7725
  * `mode` is the authoritative storage choice; `schedule`/`triggers`/`pre`/`post`
@@ -7687,7 +7756,14 @@ var RecordingConfigSchema = object({
7687
7756
  * derived into bands once via `migrateConfigToBands`.
7688
7757
  */
7689
7758
  bands: array(RecordingBandSchema).optional(),
7690
- retention: RecordingRetentionSchema.optional()
7759
+ retention: RecordingRetentionSchema.optional(),
7760
+ /**
7761
+ * Per-camera scrub-thumbnail fidelity preset (resolution + JPEG quality for
7762
+ * timeline-scrub sprite previews). Absent = `standard`. Applies to NEW
7763
+ * windows only — existing sheets are immutable, and each window's index
7764
+ * carries its own tile dims so mixed-preset history renders correctly.
7765
+ */
7766
+ scrubThumbnails: ScrubThumbnailPresetSchema.optional()
7691
7767
  });
7692
7768
  /**
7693
7769
  * `StorageLocationType` — an addon-declared id that identifies the *kind* of
@@ -30490,6 +30566,27 @@ var PIPELINE_EXECUTOR_CAP = "pipeline-executor";
30490
30566
  var PipelineSettingsStore = class PipelineSettingsStore {
30491
30567
  deps;
30492
30568
  static CATALOG_CACHE_TTL_MS = 3e4;
30569
+ /**
30570
+ * One reconcile round for {@link awaitExecutorCatalog}: the BOUNDED
30571
+ * registry wait between two authoritative provider probes. Matches the
30572
+ * registry's own "still awaiting" diagnostic cadence.
30573
+ */
30574
+ static EXECUTOR_RECONCILE_ROUND_MS = 3e4;
30575
+ /**
30576
+ * Pacing between provider probes when the registry claims `ready` but
30577
+ * the provider is not answering yet (stale registry record) — without
30578
+ * it that combination would spin hot.
30579
+ */
30580
+ static EXECUTOR_READY_PROBE_PACE_MS = 2e3;
30581
+ /**
30582
+ * Round bound for {@link seedAgentSettingsFromCatalog}: seeding is
30583
+ * invoked from event handlers (`handleDetectionPipelineReadiness`) that
30584
+ * must not accumulate unbounded loops across provider flaps — on
30585
+ * exhaustion it returns `null` and the periodic pending-retry sweep /
30586
+ * the next readiness epoch retries. `waitForAgentAndCatalog` keeps the
30587
+ * UNBOUNDED reconcile (cameras are never dispatched with empty steps).
30588
+ */
30589
+ static SEED_MAX_RECONCILE_ROUNDS = 4;
30493
30590
  constructor(deps) {
30494
30591
  this.deps = deps;
30495
30592
  }
@@ -30756,11 +30853,7 @@ var PipelineSettingsStore = class PipelineSettingsStore {
30756
30853
  * absent.
30757
30854
  */
30758
30855
  async seedAgentSettingsFromCatalog(nodeId) {
30759
- await this.deps.acquireCapability(PIPELINE_EXECUTOR_CAP, {
30760
- type: "node",
30761
- nodeId
30762
- });
30763
- const catalog = await this.getCatalogForAgent(nodeId);
30856
+ const catalog = await this.awaitExecutorCatalog(nodeId, { maxRounds: PipelineSettingsStore.SEED_MAX_RECONCILE_ROUNDS });
30764
30857
  if (!catalog) return null;
30765
30858
  const existing = (await this.readAgentSettingsMap())[nodeId];
30766
30859
  const nextAddonDefaults = seedAgentAddonDefaults(existing?.addonDefaults ?? {}, catalog);
@@ -30864,15 +30957,64 @@ var PipelineSettingsStore = class PipelineSettingsStore {
30864
30957
  return resolved;
30865
30958
  }
30866
30959
  /**
30867
- * Block until `pipeline-executor` is ready on `nodeId` AND
30960
+ * Loss-proof `pipeline-executor` gate (D8 reconcile against the
30961
+ * authority, never wait solely on an event).
30962
+ *
30963
+ * Readiness events are telemetry and MAY be lost across process/node
30964
+ * hops (2026-07-17 incident: the agent's executor came back callable
30965
+ * after an update, its `ready` delta never reached the hub registry,
30966
+ * and the former `acquireCapability(…, Infinity)` gate wedged every
30967
+ * dispatch forever). Each round of this loop therefore:
30968
+ *
30969
+ * 1. Probes the PROVIDER itself — `getCatalogForAgent` is a live
30970
+ * `pipelineExecutor.getSchema` RPC routed to `nodeId` (30s success
30971
+ * cache). The provider answering IS readiness, whatever the local
30972
+ * registry believes.
30973
+ * 2. On a miss, falls back to a BOUNDED registry wait
30974
+ * (`EXECUTOR_RECONCILE_ROUND_MS`) — the event-driven fast path that
30975
+ * keeps the healthy cold-boot case latency-free (the wait resolves
30976
+ * the instant the `ready` delta lands). A lost event costs at most
30977
+ * one round, never forever.
30978
+ *
30979
+ * If the registry claims `ready` but the probe still misses (stale
30980
+ * record for a dead provider), rounds pace at
30981
+ * `EXECUTOR_READY_PROBE_PACE_MS` instead of spinning hot.
30982
+ *
30983
+ * Returns `null` only when `maxRounds` is exhausted (bounded callers)
30984
+ * or the store was disposed mid-wait.
30985
+ */
30986
+ async awaitExecutorCatalog(nodeId, opts = {}) {
30987
+ const maxRounds = opts.maxRounds ?? Number.POSITIVE_INFINITY;
30988
+ const startedAt = Date.now();
30989
+ for (let round = 1; !this.disposed; round++) {
30990
+ const catalog = await this.getCatalogForAgent(nodeId);
30991
+ if (catalog) return catalog;
30992
+ if (round >= maxRounds) return null;
30993
+ if (round > 1) this.deps.logger.warn("executor reconcile: provider not answering yet — waiting one more round", {
30994
+ tags: { nodeId },
30995
+ meta: {
30996
+ round,
30997
+ elapsedMs: Date.now() - startedAt
30998
+ }
30999
+ });
31000
+ const readyPerRegistry = await this.deps.acquireCapability(PIPELINE_EXECUTOR_CAP, {
31001
+ type: "node",
31002
+ nodeId
31003
+ }, { timeoutMs: PipelineSettingsStore.EXECUTOR_RECONCILE_ROUND_MS }).then(() => true, () => false);
31004
+ if (this.disposed) break;
31005
+ if (readyPerRegistry) await sleep$1(PipelineSettingsStore.EXECUTOR_READY_PROBE_PACE_MS);
31006
+ }
31007
+ return null;
31008
+ }
31009
+ /**
31010
+ * Block until `pipeline-executor` answers on `nodeId` AND
30868
31011
  * `getCatalogForAgent` returns a non-null catalog AND `agentSettings`
30869
31012
  * for that node has populated `addonDefaults`.
30870
31013
  *
30871
- * `acquireCapability` defaults to infinite wait nowa single call
30872
- * carries the readiness gate. The thin retry below only handles
30873
- * the secondary case where the cap is "ready" but a derived call
30874
- * (getSchema) still returns null due to Moleculer service-registry
30875
- * propagation lag.
31014
+ * The executor gate is {@link awaitExecutorCatalog}reconcile-driven
31015
+ * and UNBOUNDED here (cameras must never be dispatched with empty
31016
+ * steps), but it converges as soon as the provider answers even when
31017
+ * every readiness event was lost.
30876
31018
  *
30877
31019
  * Smell #20 fix: the retry loop used to be `while (true)` with no exit
30878
31020
  * — a resolve in flight at addon shutdown would spin against a torn-
@@ -30882,16 +31024,9 @@ var PipelineSettingsStore = class PipelineSettingsStore {
30882
31024
  * timing (2s backoff, same retry conditions) is unchanged.
30883
31025
  */
30884
31026
  async waitForAgentAndCatalog(nodeId) {
30885
- await this.deps.acquireCapability(PIPELINE_EXECUTOR_CAP, {
30886
- type: "node",
30887
- nodeId
30888
- });
30889
31027
  while (!this.disposed) {
30890
- const catalog = await this.getCatalogForAgent(nodeId);
30891
- if (!catalog) {
30892
- await sleep$1(2e3);
30893
- continue;
30894
- }
31028
+ const catalog = await this.awaitExecutorCatalog(nodeId);
31029
+ if (!catalog) break;
30895
31030
  let agent = (await this.readAgentSettingsMap())[nodeId];
30896
31031
  if (!agent || Object.keys(agent.addonDefaults ?? {}).length === 0) {
30897
31032
  if (!await this.seedAgentSettingsFromCatalog(nodeId)) {
@@ -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_orchestrator_widgets-BEIKqOjC.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-DV91ueig.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-pipeline-orchestrator",
3
- "version": "1.1.49",
3
+ "version": "1.1.50",
4
4
  "description": "Hub-side camera-to-agent load balancer — tracks runner capacity and dispatches attachCamera calls to the optimal pipeline-runner instance",
5
5
  "keywords": [
6
6
  "camstack",