@camstack/addon-pipeline-orchestrator 1.2.257 → 1.2.259

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
@@ -5363,7 +5363,7 @@ var ZodIssueCode = {
5363
5363
  var ZodFirstPartyTypeKind;
5364
5364
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5365
5365
  //#endregion
5366
- //#region ../types/dist/sleep-BaRu0bS5.mjs
5366
+ //#region ../types/dist/sleep-DC-wdyeS.mjs
5367
5367
  /**
5368
5368
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5369
5369
  * window to float samples (D455).
@@ -6164,6 +6164,40 @@ var BaseAddon = class {
6164
6164
  const node = normalizeNodeId(nodeId ?? this._ctx?.kernel?.localNodeId);
6165
6165
  return keys.size > 0 ? projectStore(raw, keys, node) : raw;
6166
6166
  }
6167
+ /**
6168
+ * Whether ONE global key of this addon's store was written, read three-way
6169
+ * (D651). For a question a hydrated view cannot answer: `getGlobalSettings`
6170
+ * reports `value ?? default`, so "the operator chose the default" and "nobody
6171
+ * chose" look the same, and a failed read looks like both. A `perNode` key is
6172
+ * not supported here: its stored name is scoped.
6173
+ */
6174
+ async readStoredGlobalKey(key) {
6175
+ const read = this._ctx?.settings?.readAddonStoreResult;
6176
+ if (read === void 0) return {
6177
+ kind: "unreadable",
6178
+ reason: "three-way-read-unavailable"
6179
+ };
6180
+ let result;
6181
+ try {
6182
+ result = await read.call(this._ctx?.settings);
6183
+ } catch (err) {
6184
+ return {
6185
+ kind: "unreadable",
6186
+ reason: "store-read-failed",
6187
+ error: err instanceof Error ? err.message : String(err)
6188
+ };
6189
+ }
6190
+ if (result.kind === "failed") return {
6191
+ kind: "unreadable",
6192
+ reason: "store-read-failed",
6193
+ error: result.error
6194
+ };
6195
+ if (result.kind === "missing" || !Object.prototype.hasOwnProperty.call(result.value, key)) return { kind: "absent" };
6196
+ return {
6197
+ kind: "present",
6198
+ value: result.value[key]
6199
+ };
6200
+ }
6167
6201
  async updateGlobalSettings(patch, nodeId) {
6168
6202
  const keys = this.perNodeKeys();
6169
6203
  const localNode = normalizeNodeId(this._ctx?.kernel?.localNodeId);
@@ -10291,6 +10325,43 @@ var LogChannelWindowSchema = object({
10291
10325
  /** `null` = every camera. A non-empty list narrows to those numeric ids. */
10292
10326
  deviceIds: array(number().int()).readonly().nullable()
10293
10327
  });
10328
+ var ClipModelMetaSchema = object({
10329
+ /** The paired text encoder's catalog id (the embedding-encoder's text catalog). */
10330
+ textModelId: string().min(1),
10331
+ /** Output dimension of BOTH towers. A vector of any other length is refused. */
10332
+ embeddingDim: number().int().positive(),
10333
+ /**
10334
+ * The `vector-store` index this model's vectors live in. ONE feature space
10335
+ * per index: two models share an index only when their vectors are ranked
10336
+ * against each other, which for CLIP means never across families. MobileCLIP
10337
+ * S1 and S2 share `object-clip` for history (separated by the row's
10338
+ * `modelId`); any other model gets its own.
10339
+ */
10340
+ vectorIndex: string().regex(/^object-clip(-[a-z0-9][a-z0-9-]*)?$/),
10341
+ /**
10342
+ * The text encoder's tokenizer, a declared SIBLING file of its onnx. The name
10343
+ * must be unique per tokenizer: every model's siblings land in ONE flat
10344
+ * models directory, so two different tokenizers under the same name would
10345
+ * silently overwrite each other (whichever model downloaded last wins, and
10346
+ * the other one tokenises with the wrong vocabulary).
10347
+ */
10348
+ tokenizerFile: string().min(1),
10349
+ /** Token window the text graph takes (SigLIP2: 64; CLIP BPE: 77). */
10350
+ contextLength: number().int().positive(),
10351
+ /** Id the tokenizer pads with. */
10352
+ padId: number().int().nonnegative(),
10353
+ /**
10354
+ * The search cosine floor at `strictness: 'loose'` — calibrated per model,
10355
+ * because cosine bands are a property of the model: 0.2 keeps 96% of matched
10356
+ * COCO caption-image pairs on MobileCLIP S1 and 2.7% on SigLIP2.
10357
+ */
10358
+ searchMinScore: number().min(0).max(1)
10359
+ });
10360
+ var ClipSearchStrictnessSchema = _enum([
10361
+ "loose",
10362
+ "balanced",
10363
+ "strict"
10364
+ ]);
10294
10365
  var MODEL_FORMATS = [
10295
10366
  "onnx",
10296
10367
  "coreml",
@@ -10494,7 +10565,14 @@ var ModelCatalogEntrySchema = object({
10494
10565
  * applies (Frigate / COCO public catalog). Set on a custom model whose raw
10495
10566
  * labels already ARE the CamStack macros (Scrypted identity map).
10496
10567
  */
10497
- classMap: DetectionCatalogClassMapSchema.optional()
10568
+ classMap: DetectionCatalogClassMapSchema.optional(),
10569
+ /**
10570
+ * The model's CLIP feature-space contract (text encoder, dimension, vector
10571
+ * index, token window, search floor) — see `clip-model.ts`. Present ONLY on a
10572
+ * CLIP image encoder; its absence is what "not a CLIP model" means to the
10573
+ * embedding encoder and the semantic-search store (D649).
10574
+ */
10575
+ clip: ClipModelMetaSchema.optional()
10498
10576
  });
10499
10577
  var ConvertTargetSchema = discriminatedUnion("format", [object({
10500
10578
  format: literal("openvino"),
@@ -14769,6 +14847,27 @@ var LinkedDeviceSchema = object({
14769
14847
  features: array(string()),
14770
14848
  producesTrackedEvents: boolean().optional()
14771
14849
  });
14850
+ /**
14851
+ * The `linked-devices` extension slot's editable value (D648): the operator's
14852
+ * pick list, exactly what `_linkedDeviceIds` persists. Tree children are NOT
14853
+ * in it — they are always linked and are not a pick.
14854
+ *
14855
+ * `trackedDeviceIds` (D655) is the tracked-event opt-in, exactly what
14856
+ * `_linkedDeviceTrackedIds` persists: which linked devices materialize a
14857
+ * synthetic tracked event on the camera. A read always carries it AS STORED
14858
+ * (an absent key reads as `[]`, which is what the resolver makes of it —
14859
+ * NONE). On a write it is optional: absent leaves the opt-in untouched, so a
14860
+ * card built before D655 cannot erase it by saving the pick list. A present
14861
+ * list REPLACES the opt-in; each id must be a pick, a device the camera links
14862
+ * by structure (the row's `impliedSources`: tree children, a picked
14863
+ * container's child) or already stored — so what was read can always be
14864
+ * written back unchanged, and a NEW opt-in outside the linked set is refused.
14865
+ */
14866
+ var LinkedDevicesSlotValueSchema = object({
14867
+ deviceIds: array(number().int().positive()),
14868
+ trackedDeviceIds: array(number().int().positive()).optional()
14869
+ });
14870
+ DeviceType.Camera;
14772
14871
  /** One camera's resolved linked set, tagged with the camera it belongs to.
14773
14872
  * The batch answer needs the tag; the single-device answer already has it
14774
14873
  * from the input, which is why `getLinkedDevices` keeps the untagged shape. */
@@ -15294,16 +15393,101 @@ var EmbeddingResultSchema = object({
15294
15393
  embedding: array(number()),
15295
15394
  inferenceMs: number()
15296
15395
  });
15396
+ /** The two CLIP towers the encoder runs; a crash budget is kept per tower. */
15397
+ var EmbeddingTowerSchema = _enum(["image", "text"]);
15398
+ /**
15399
+ * A tower in the terminal `failed` state (D649, D6): it crashed or failed to
15400
+ * load `crashes` times within `windowMs`, and is refused by name until an
15401
+ * operator `resetFailedModels` or a model change — never on a timer.
15402
+ *
15403
+ * `nodeId` is the node whose tower failed. The state lives in THAT node's
15404
+ * encoder process (in memory — a runner respawn clears it), and one node's
15405
+ * answer says nothing about another's.
15406
+ */
15407
+ var EmbeddingFailedModelSchema = object({
15408
+ modelId: string(),
15409
+ tower: EmbeddingTowerSchema,
15410
+ nodeId: string(),
15411
+ crashes: number(),
15412
+ windowMs: number(),
15413
+ sinceMs: number(),
15414
+ lastReason: string()
15415
+ });
15416
+ /**
15417
+ * A model whose REQUIREMENTS can never be met on this node (D649 round 5):
15418
+ * an id the catalog does not know, a model URL answering 404/410, a declared
15419
+ * sibling (the tokenizer) that is not there. After `attempts` failed builds
15420
+ * it is terminally `requirements-failed`, refused by name until an operator
15421
+ * `resetFailedModels` or a model change — the same exits as a crashed tower.
15422
+ */
15423
+ var EmbeddingFailedRequirementSchema = object({
15424
+ modelId: string(),
15425
+ tower: EmbeddingTowerSchema,
15426
+ nodeId: string(),
15427
+ attempts: number(),
15428
+ sinceMs: number(),
15429
+ lastReason: string()
15430
+ });
15297
15431
  var EmbeddingInfoSchema = object({
15298
15432
  modelId: string(),
15299
15433
  embeddingDim: number(),
15300
- ready: boolean()
15301
- });
15434
+ ready: boolean(),
15435
+ /**
15436
+ * The node that answered. Absent only from an encoder older than D649's
15437
+ * crash budget — whose `failedModels` is then UNKNOWN (D315), never "none".
15438
+ */
15439
+ nodeId: string().optional(),
15440
+ /**
15441
+ * Towers in `failed` on the answering node, each row naming it. `undefined`
15442
+ * means the node could not say (pre-budget encoder); a node that has the
15443
+ * budget always answers an array, empty included.
15444
+ */
15445
+ failedModels: array(EmbeddingFailedModelSchema).optional(),
15446
+ /** Models terminally `requirements-failed` on the answering node; same reading as above. */
15447
+ failedRequirements: array(EmbeddingFailedRequirementSchema).optional()
15448
+ });
15449
+ /**
15450
+ * The THREE codes an encoder refusal can carry (D649, D6). Same shape as
15451
+ * `VECTOR_INDEX_DIM_CONFLICT`: the code rides in the MESSAGE, because an
15452
+ * Error's own properties do not survive the UDS/tRPC hop and its message does,
15453
+ * and both sides go through the helpers below — the encoder builds the
15454
+ * refusal, the live detail plane classifies on it to count refusals per
15455
+ * camera instead of logging one warning per track. The bracketed token
15456
+ * carries the code, the model, the tower and the node so a consumer never
15457
+ * parses prose.
15458
+ *
15459
+ * - `EMBEDDING_MODEL_FAILED`: the tower crashed / failed to load past the crash
15460
+ * budget — terminal until `resetFailedModels` or a model change.
15461
+ * - `EMBEDDING_REQUIREMENTS_UNAVAILABLE`: files or runtime not there right now
15462
+ * (a download or Python that failed, or the cooldown after one) — the next
15463
+ * request past the cooldown tries again.
15464
+ * - `EMBEDDING_REQUIREMENTS_FAILED`: the requirements can never be met (unknown
15465
+ * id, 404/410, missing sibling) and the bounded attempts are spent —
15466
+ * terminal, same exits as a crashed tower.
15467
+ */
15468
+ var EMBEDDING_MODEL_FAILED = "embedding-model-failed";
15469
+ var EMBEDDING_REQUIREMENTS_UNAVAILABLE = "embedding-requirements-unavailable";
15470
+ var EMBEDDING_REQUIREMENTS_FAILED = "embedding-requirements-failed";
15471
+ _enum([
15472
+ EMBEDDING_MODEL_FAILED,
15473
+ EMBEDDING_REQUIREMENTS_UNAVAILABLE,
15474
+ EMBEDDING_REQUIREMENTS_FAILED
15475
+ ]);
15476
+ new RegExp(`\\[(${EMBEDDING_MODEL_FAILED}|${EMBEDDING_REQUIREMENTS_UNAVAILABLE}|${EMBEDDING_REQUIREMENTS_FAILED}) model=([^\\s\\]]+) tower=(image|text) node=([^\\s\\]]+)\\]`);
15302
15477
  method(object({
15303
15478
  crop: _instanceof(Uint8Array),
15304
15479
  width: number(),
15305
15480
  height: number()
15306
- }), EmbeddingResultSchema, { auth: "admin" }), method(object({ text: string() }), EmbeddingResultSchema, { auth: "admin" }), method(_void(), EmbeddingInfoSchema, { auth: "admin" });
15481
+ }), EmbeddingResultSchema, { auth: "admin" }), method(object({
15482
+ text: string(),
15483
+ modelId: string().min(1).optional()
15484
+ }), EmbeddingResultSchema, { auth: "admin" }), method(_void(), EmbeddingInfoSchema, { auth: "admin" }), method(object({ modelId: string().min(1).optional() }), object({
15485
+ cleared: array(string()),
15486
+ nodeId: string()
15487
+ }), {
15488
+ kind: "mutation",
15489
+ auth: "admin"
15490
+ });
15307
15491
  /**
15308
15492
  * `failure-contribution` — the capability an addon reports its OWN losses
15309
15493
  * through, per camera, with the denominator attached. It stores nothing.
@@ -18709,7 +18893,12 @@ var NcMediaPolicySchema = object({
18709
18893
  * the degrade engine, which drops the video and keeps the still.
18710
18894
  */
18711
18895
  clip: boolean().optional(),
18712
- /** Seconds of footage BEFORE / AFTER the event instant. Defaults 3 / 7. */
18896
+ /**
18897
+ * Seconds of footage BEFORE / AFTER the event instant. Absent =
18898
+ * {@link NC_CLIP_PRE_ROLL_DEFAULT_SEC} / {@link NC_CLIP_POST_ROLL_DEFAULT_SEC}
18899
+ * (4 / 6), resolved by the Notification Center at dispatch — deliberately not
18900
+ * a Zod default, so a rule that never chose keeps following the hub's.
18901
+ */
18713
18902
  clipPreRollSec: number().int().min(0).max(30).optional(),
18714
18903
  clipPostRollSec: number().int().min(0).max(30).optional(),
18715
18904
  /**
@@ -20087,6 +20276,163 @@ var OauthIntegrationDescriptorSchema = object({
20087
20276
  });
20088
20277
  method(_void(), OauthIntegrationDescriptorSchema, { auth: "admin" });
20089
20278
  /**
20279
+ * A model choice has a SCOPE, and a scope change has a workflow.
20280
+ *
20281
+ * ## The two scopes
20282
+ *
20283
+ * Almost every pipeline step's model is a **node** question: which build runs
20284
+ * fastest on THIS accelerator. Two nodes may legitimately answer differently —
20285
+ * a Coral runs the tflite SSD, a Mac runs the coreml YOLO — because the answers
20286
+ * are compared against nothing. They produce boxes, and a box is a box.
20287
+ *
20288
+ * Two steps are not like that. `face-embedding` and `clip-embedding` produce
20289
+ * **vectors that land in ONE shared index** (`identity_samples`,
20290
+ * `vec_object_clip`) and are ranked against each other by cosine similarity.
20291
+ * Cosine similarity between two encoders is not a weak signal, it is a
20292
+ * meaningless number — and nothing inside a vector can tell you it happened.
20293
+ * So for those two steps the model is a **cluster** question with exactly one
20294
+ * answer, stored in exactly one row.
20295
+ *
20296
+ * That is the same argument [D52](../../../../docs/decisions/adr-0052.md) makes
20297
+ * for the crop convention (`detail-crop.ts`), one level up: the crop decides
20298
+ * WHAT pixels the encoder sees, this decides WHICH encoder sees them. Both are
20299
+ * cluster-wide for the same reason and neither may become per-node.
20300
+ *
20301
+ * ## Why the registry lives here and not in the step catalog
20302
+ *
20303
+ * The scope itself is declared on the step, in
20304
+ * `detection-pipeline/registry/step-definitions.ts` (`StepDefinition.modelScope`)
20305
+ * — that is where a new step declares what kind of choice its model is. But the
20306
+ * catalog lives inside `addon-pipeline`, and three packages that must NOT
20307
+ * import an addon need to know the set: the orchestrator (which renders and
20308
+ * stores the row), post-analysis (which pins the row for a re-embed pass) and
20309
+ * admin-ui (which mirrors it). So the set is mirrored here, and
20310
+ * `scripts/check-cluster-model-scope.ts` fails the build when the two disagree.
20311
+ * A hand-copied key that stops matching is exactly how a setting silently stops
20312
+ * arriving while both sides still look correct.
20313
+ *
20314
+ * ## The defaults ARE today's behaviour
20315
+ *
20316
+ * {@link DEFAULT_CLUSTER_STEP_MODELS} reproduces the catalog `defaultModelId`
20317
+ * of both steps. Declaring the scope therefore changes nothing on the day it
20318
+ * ships: the cluster row starts out saying what every node was already doing.
20319
+ * Changing it is legitimate — that is what the knob is for — but it invalidates
20320
+ * the stored vectors of that surface, and must be followed by a re-embed pass
20321
+ * (`face-reembed-pass.ts`, `embedding-rebuild.ts`).
20322
+ */
20323
+ /**
20324
+ * Why the orchestrator cannot say which model the operator chose for a
20325
+ * cluster-scoped step (D651).
20326
+ */
20327
+ var CLUSTER_MODEL_CHOICE_UNREADABLE_REASONS = [
20328
+ "owner-not-ready",
20329
+ "store-read-failed",
20330
+ "three-way-read-unavailable",
20331
+ "invalid-stored",
20332
+ "not-cluster-scoped"
20333
+ ];
20334
+ /**
20335
+ * The cluster-scoped steps. Mirrored from the catalog's `modelScope: 'cluster'`
20336
+ * declarations and guarded by `scripts/check-cluster-model-scope.ts`.
20337
+ */
20338
+ var CLUSTER_MODEL_SCOPED_STEPS = [{
20339
+ stepId: "face-embedding",
20340
+ label: "Face recognition model",
20341
+ defaultModelId: "auraface-r100",
20342
+ indexName: "the enrolled face gallery",
20343
+ options: [
20344
+ {
20345
+ id: "auraface-r100",
20346
+ label: "AuraFace R100"
20347
+ },
20348
+ {
20349
+ id: "arcface-r100",
20350
+ label: "ArcFace (ResNet34)"
20351
+ },
20352
+ {
20353
+ id: "inception-resnet-v1",
20354
+ label: "Inception ResNet V1"
20355
+ }
20356
+ ]
20357
+ }, {
20358
+ stepId: "clip-embedding",
20359
+ label: "Semantic search model",
20360
+ defaultModelId: "mobileclip-s1",
20361
+ indexName: "the object semantic-search index",
20362
+ options: [
20363
+ {
20364
+ id: "mobileclip-s1",
20365
+ label: "MobileCLIP S1"
20366
+ },
20367
+ {
20368
+ id: "mobileclip-s2",
20369
+ label: "MobileCLIP S2"
20370
+ },
20371
+ {
20372
+ id: "siglip2-b16-224",
20373
+ label: "SigLIP2 B/16 (Apache-2.0)"
20374
+ }
20375
+ ]
20376
+ }];
20377
+ /**
20378
+ * Store identity of the cluster row inside `pipeline-orchestrator`'s GLOBAL
20379
+ * (cluster-wide) settings — the same owner, and for the same reason, as the
20380
+ * detail-crop convention: hub-resident and neutral between the addons that
20381
+ * read it.
20382
+ */
20383
+ var CLUSTER_MODEL_SECTION_ID = "cluster-step-models";
20384
+ /**
20385
+ * The settings key holding one step's cluster model choice.
20386
+ *
20387
+ * Step-qualified so the keys stay unique across the addon's whole schema, which
20388
+ * is what lets {@link pickClusterStepModels} walk every section instead of
20389
+ * trusting the section id.
20390
+ */
20391
+ function clusterModelSettingKey(stepId) {
20392
+ return `clusterModel:${stepId}`;
20393
+ }
20394
+ Object.freeze(Object.fromEntries(CLUSTER_MODEL_SCOPED_STEPS.map((s) => [s.stepId, s.defaultModelId])));
20395
+ function interpretStoredClusterModel(raw) {
20396
+ if (raw === void 0 || raw === null || raw === "") return { kind: "absent" };
20397
+ if (typeof raw === "string") return {
20398
+ kind: "chosen",
20399
+ modelId: raw
20400
+ };
20401
+ return {
20402
+ kind: "invalid",
20403
+ value: raw
20404
+ };
20405
+ }
20406
+ var CLUSTER_STEP_SETTING_FIELDS = [{
20407
+ stepId: "face-embedding",
20408
+ key: "minLandmarkFaceSize",
20409
+ label: "Min face size for recognition (detection px)",
20410
+ description: "Refuse to embed a face smaller than this IN THE DETECTION FRAME. Applies to every node — the enrolled gallery is one index, and two admission floors would fill it from two policies. 0 disables the gate.",
20411
+ type: "slider",
20412
+ min: 0,
20413
+ max: 64,
20414
+ step: 2,
20415
+ default: 24
20416
+ }];
20417
+ function clusterStepSettingKey(stepId, fieldKey) {
20418
+ return `clusterStepSetting:${stepId}:${fieldKey}`;
20419
+ }
20420
+ var ClusterSettingNumberSchema = number().finite();
20421
+ function readClusterStepSettings(config) {
20422
+ const out = {};
20423
+ for (const field of CLUSTER_STEP_SETTING_FIELDS) {
20424
+ const parsed = ClusterSettingNumberSchema.safeParse(config[clusterStepSettingKey(field.stepId, field.key)]);
20425
+ const value = parsed.success ? parsed.data : field.default;
20426
+ const existing = out[field.stepId] ?? {};
20427
+ out[field.stepId] = {
20428
+ ...existing,
20429
+ [field.key]: value
20430
+ };
20431
+ }
20432
+ return out;
20433
+ }
20434
+ readClusterStepSettings({});
20435
+ /**
20090
20436
  * pipeline-analytics — device-scoped wrapper cap. Refines raw
20091
20437
  * per-frame detections emitted by the pipeline runner into tracked
20092
20438
  * objects, per-kind event collections (motion / object / audio), and
@@ -20902,7 +21248,8 @@ var MediaFileKindEnum = _enum([
20902
21248
  "thumbnailSmall",
20903
21249
  "confirmationCrop",
20904
21250
  "debugCrop",
20905
- "sceneFrame"
21251
+ "sceneFrame",
21252
+ "lastSceneFrame"
20906
21253
  ]);
20907
21254
  /**
20908
21255
  * One media row ON THE WIRE: what it is, how big it is, and WHERE ITS BYTES
@@ -21454,7 +21801,23 @@ var SearchObjectEventsInput = object({
21454
21801
  until: number().optional(),
21455
21802
  classFilter: string().optional(),
21456
21803
  limit: number().default(50),
21457
- minScore: number().min(0).max(1).default(.2)
21804
+ /**
21805
+ * An ABSOLUTE cosine floor. Optional since D649: a floor is a property of the
21806
+ * model (0.2 kept 96% of matched pairs on MobileCLIP S1 and 2.7% on SigLIP2),
21807
+ * so an absent one resolves to the searched model's own `searchMinScore`
21808
+ * scaled by `strictness`. A number sent here is honoured verbatim.
21809
+ */
21810
+ minScore: number().min(0).max(1).optional(),
21811
+ /** Model-relative floor — see `resolveClipSearchMinScore`. Default `loose`. */
21812
+ strictness: ClipSearchStrictnessSchema.optional(),
21813
+ /**
21814
+ * Search THIS CLIP model's index instead of the cluster row's. Every index a
21815
+ * model ever wrote stays searchable until it is wiped (the MobileCLIP index
21816
+ * through a SigLIP2 cutover and its rollback window). An id with no CLIP
21817
+ * metadata is refused; an id other than the cluster row's is ADMIN-only
21818
+ * (`model-override-requires-admin`).
21819
+ */
21820
+ modelId: string().min(1).optional()
21458
21821
  });
21459
21822
  var TrackCascadeCountsSchema = object({
21460
21823
  /** Persisted track roots deleted (authoritative). */
@@ -21589,7 +21952,41 @@ var RebuildObjectEmbeddingsInput = object({
21589
21952
  * comparable, so wiping and rebuilding is the only way to be sure every vector
21590
21953
  * means the same thing.
21591
21954
  */
21592
- var WipeObjectEmbeddingsResultSchema = object({ deleted: number() });
21955
+ var WipeObjectEmbeddingsResultSchema = object({
21956
+ deleted: number(),
21957
+ /**
21958
+ * CLIP indexes that could NOT be emptied (unreachable, or refused at their
21959
+ * dimension) — not a clean slate. Additive and optional (D649).
21960
+ */
21961
+ failedIndexes: array(string()).optional()
21962
+ });
21963
+ /**
21964
+ * Why a rebuild did not start, BY NAME — ONE vocabulary for both gates in front
21965
+ * of a pass (D649 over D651). A bare `started: false` read the same for "the
21966
+ * stores are booting" and "the cluster row names a model with no CLIP
21967
+ * metadata", which need different fixes.
21968
+ * - `stores-unavailable` — the analytics stores or the API are not up yet.
21969
+ * - the owner's confirmation of the cluster model (D651, `ClusterPinConfirmer`):
21970
+ * a pass that rewrites an index needs two agreeing reads of the OWNER's
21971
+ * answer (`pipelineOrchestrator.getClusterModelChoice`) — a pass that cannot
21972
+ * name its model does not run (D54):
21973
+ * - the owner's own `unreadable` reasons (`owner-not-ready`,
21974
+ * `store-read-failed`, `three-way-read-unavailable`, `invalid-stored`,
21975
+ * `not-cluster-scoped`);
21976
+ * - `method-unavailable` — the orchestrator predates the method;
21977
+ * - `read-failed` — the call failed or answered something unparseable;
21978
+ * - `reads-disagree` — the two reads named different choices.
21979
+ * - `not-a-clip-model` — the CONFIRMED id has no CLIP metadata, so there is no
21980
+ * index to fill and no dimension to check against (D649).
21981
+ */
21982
+ var RebuildObjectEmbeddingsRefusalSchema = _enum([
21983
+ "stores-unavailable",
21984
+ ...CLUSTER_MODEL_CHOICE_UNREADABLE_REASONS,
21985
+ "method-unavailable",
21986
+ "read-failed",
21987
+ "reads-disagree",
21988
+ "not-a-clip-model"
21989
+ ]);
21593
21990
  /**
21594
21991
  * Acknowledgement that a rebuild STARTED.
21595
21992
  *
@@ -21602,7 +21999,12 @@ var WipeObjectEmbeddingsResultSchema = object({ deleted: number() });
21602
21999
  var RebuildObjectEmbeddingsResultSchema = object({
21603
22000
  started: boolean(),
21604
22001
  /** True when a pass was already running; the new request is ignored. */
21605
- alreadyRunning: boolean()
22002
+ alreadyRunning: boolean(),
22003
+ /** Present when the pass refused to start — see `RebuildObjectEmbeddingsRefusalSchema`. */
22004
+ refusal: RebuildObjectEmbeddingsRefusalSchema.optional(),
22005
+ /** The CLIP model the pass pinned, and the index it fills (when it started). */
22006
+ modelId: string().optional(),
22007
+ index: string().optional()
21606
22008
  });
21607
22009
  var RebuildStatusSchema = object({
21608
22010
  running: boolean(),
@@ -21636,7 +22038,11 @@ var RebuildStatusSchema = object({
21636
22038
  startedAtMs: number().nullable(),
21637
22039
  finishedAtMs: number().nullable(),
21638
22040
  /** Present when the pass ended by throwing. */
21639
- error: string().nullable()
22041
+ error: string().nullable(),
22042
+ /** The CLIP model the current / last pass pinned (D54), `null` before any. */
22043
+ modelId: string().nullable(),
22044
+ /** The index that pass fills — `ClipModelMeta.vectorIndex` of `modelId` (D649). */
22045
+ index: string().nullable()
21640
22046
  });
21641
22047
  /**
21642
22048
  * Acknowledgement that a debug-media reclaim STARTED.
@@ -21957,7 +22363,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
21957
22363
  deviceId: number(),
21958
22364
  ownerKind: MediaPresenceOwnerKindSchema,
21959
22365
  ownerIds: array(string()).max(5e3)
21960
- }), array(string()).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
22366
+ }), array(string()).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly(), { caller: "required" }), method(object({}), WipeObjectEmbeddingsResultSchema, {
21961
22367
  kind: "mutation",
21962
22368
  auth: "admin"
21963
22369
  }), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
@@ -22576,7 +22982,14 @@ var pipelineExecutorCapability = {
22576
22982
  */
22577
22983
  inferCached: method(object({
22578
22984
  stepId: string(),
22579
- frameId: number().int()
22985
+ frameId: number().int(),
22986
+ /**
22987
+ * The model to time — REQUIRED (D658). Without it the pool answered the
22988
+ * step's ACTIVE variant (the first loaded after boot), so a benchmark
22989
+ * of model B silently timed model A. Callers pass the `rootModelId`
22990
+ * their warmup `runPipeline` reported: the model that actually ran.
22991
+ */
22992
+ modelId: string().min(1)
22580
22993
  }), record(string(), unknown()), { kind: "mutation" }),
22581
22994
  /**
22582
22995
  * Release a cached frame from the Python pool's memory.
@@ -23228,6 +23641,17 @@ var RunnerFrameSourceSchema = discriminatedUnion("kind", [object({ kind: literal
23228
23641
  hubHostnameOverride: string().optional()
23229
23642
  })]).describe("Per-camera frame-source mode for the runner (P2c)");
23230
23643
  /**
23644
+ * What ONE step runs with on ONE roster device for THIS camera (D650): the
23645
+ * device's per-(node,device) base with the camera's override for that device
23646
+ * on top — the same `override ?? base` merge the orchestrator applies to the
23647
+ * elected device (`applyDeviceProvisioning`). Absent `modelId` ⇒ the executor
23648
+ * resolves the device format's default, exactly as on the elected device.
23649
+ */
23650
+ var RunnerJumpStepProvisioningSchema = object({
23651
+ modelId: string().optional(),
23652
+ settings: record(string(), unknown()).optional()
23653
+ });
23654
+ /**
23231
23655
  * One ENABLED inference device on the runner's node, as the step-tree
23232
23656
  * device-jump resolver sees it (phase 1). The orchestrator populates this
23233
23657
  * roster on the attach payload whenever the camera is elected onto a specific
@@ -23239,7 +23663,16 @@ var RunnerFrameSourceSchema = discriminatedUnion("kind", [object({ kind: literal
23239
23663
  var RunnerInferenceDeviceSchema = object({
23240
23664
  deviceKey: string(),
23241
23665
  weight: number().positive().default(1),
23242
- maxSessions: number().int().positive().nullable().default(null)
23666
+ maxSessions: number().int().positive().nullable().default(null),
23667
+ /**
23668
+ * Per-step provisioning for this camera on THIS device (D650), keyed by step
23669
+ * id. A step the runner jumps here runs with these fields, never with the
23670
+ * elected device's: `modelId` / `settings` are REPLACED on the jumped subtree
23671
+ * (a step absent from the map runs with neither — the target format default).
23672
+ * Absent field ⇒ an orchestrator that predates D650: the jumped step keeps
23673
+ * the elected device's provisioning, as before.
23674
+ */
23675
+ steps: record(string(), RunnerJumpStepProvisioningSchema).optional()
23243
23676
  });
23244
23677
  /**
23245
23678
  * Camera assignment payload sent by `addon-pipeline-orchestrator` to a
@@ -24315,6 +24748,28 @@ var NodeInferenceDeviceSchema = object({
24315
24748
  */
24316
24749
  exclusion: InferenceDeviceExclusionReasonSchema.nullable()
24317
24750
  });
24751
+ /**
24752
+ * The operator's model choice for one cluster-scoped step, answered by the
24753
+ * addon that OWNS the `clusterModel:*` settings (D651). Three answers, never
24754
+ * folded: a written choice, a key nobody wrote (the cluster runs the default
24755
+ * BY ABSENCE), or no answer. A caller that rewrites an index acts only on the
24756
+ * first two, read twice and agreeing.
24757
+ */
24758
+ var ClusterModelChoiceSchema = discriminatedUnion("kind", [
24759
+ object({
24760
+ kind: literal("stored"),
24761
+ modelId: string()
24762
+ }),
24763
+ object({
24764
+ kind: literal("absent"),
24765
+ defaultModelId: string()
24766
+ }),
24767
+ object({
24768
+ kind: literal("unreadable"),
24769
+ reason: _enum(CLUSTER_MODEL_CHOICE_UNREADABLE_REASONS),
24770
+ error: string().optional()
24771
+ })
24772
+ ]);
24318
24773
  var NodeInferenceDevicesSchema = object({
24319
24774
  nodeId: string(),
24320
24775
  /** False when the node's platform-probe was unreachable (no live device set). */
@@ -24411,9 +24866,19 @@ var pipelineOrchestratorCapability = {
24411
24866
  * Trigger a full rebalance pass. Iterates over all assigned cameras,
24412
24867
  * recomputes the optimal agent for each one based on current load +
24413
24868
  * hardware affinity, and migrates cameras whose current agent is no
24414
- * longer optimal. Pinned cameras are never migrated.
24869
+ * longer optimal. Pinned cameras are never migrated. `skipped` names the
24870
+ * cameras the pass wanted to move and could not, with why — a target node
24871
+ * that has not answered its catalog / device health yet, or a device that
24872
+ * cannot run the camera's root (D650).
24415
24873
  */
24416
- rebalance: method(_void(), object({ migrated: number() }), {
24874
+ rebalance: method(_void(), object({
24875
+ migrated: number(),
24876
+ skipped: array(object({
24877
+ deviceId: number(),
24878
+ targetNodeId: string(),
24879
+ reason: string()
24880
+ })).default([])
24881
+ }), {
24417
24882
  kind: "mutation",
24418
24883
  auth: "admin"
24419
24884
  }),
@@ -24423,6 +24888,13 @@ var pipelineOrchestratorCapability = {
24423
24888
  getPipelineAssignment: method(object({ deviceId: number() }), PipelineAssignmentSchema.nullable()),
24424
24889
  /** Get per-agent load summaries used by the load balancer + dashboards. */
24425
24890
  getAgentLoad: method(_void(), array(AgentLoadSummarySchema).readonly()),
24891
+ /**
24892
+ * The operator's STORED model choice for one cluster-scoped step (D651).
24893
+ * Read three-way from this addon's own store, only once its boot has
24894
+ * settled. Unlike `addonSettings.getGlobalSettings`, it never answers a
24895
+ * default as though someone chose it.
24896
+ */
24897
+ getClusterModelChoice: method(object({ stepId: string() }), ClusterModelChoiceSchema),
24426
24898
  /** Aggregate global metrics across the whole detection cluster. */
24427
24899
  getGlobalMetrics: method(_void(), GlobalMetricsSchema),
24428
24900
  /**
@@ -26975,6 +27447,177 @@ DeviceType.Camera, method(object({
26975
27447
  auth: "protected"
26976
27448
  });
26977
27449
  /**
27450
+ * LA tabella "quale booleano di questo tipo di device conta come ALTO", e il
27451
+ * valutatore puro del suo FRONTE.
27452
+ *
27453
+ * Viveva dentro il builtin virtual-doorbell
27454
+ * (`@camstack/system` — `builtins/doorbell/trigger-engine.ts`) e i suoi
27455
+ * predicati erano privati al modulo. Il recorder ne ha bisogno per il trigger
27456
+ * `RecordingTriggers.sensorDeviceIds`: copiarla avrebbe creato la SECONDA
27457
+ * tabella, che diverge alla prima cap aggiunta e il cui sintomo — "il sensore
27458
+ * fa suonare il campanello ma non registra" — è esattamente D62. Quindi si
27459
+ * SPOSTA qui e il doorbell la ri-esporta.
27460
+ *
27461
+ * ⚠ NON è `DEVICE_STATE_READERS` (`catalogs/device-state-vocabulary.ts`), e le
27462
+ * due non vanno unificate: quella risponde a "qual è la PAROLA di stato per una
27463
+ * regola" (e include `presence`, `cover`, `alarm-panel`), questa a "qual è il
27464
+ * booleano il cui FRONTE conta". Vocabolari deliberatamente diversi.
27465
+ */
27466
+ /**
27467
+ * Known binary / switch source caps → the boolean slice field whose
27468
+ * false→true rise counts as ACTIVE. Every entry is "fire on active".
27469
+ * Sensors whose "active" reading is not a plain boolean (presence's string
27470
+ * state, connectivity's connected flag) are deliberately excluded — a
27471
+ * reconnect is not a doorbell press, and it is not a recording either.
27472
+ */
27473
+ var SOURCE_CAP_ACTIVE_FIELD = {
27474
+ contact: "entryOpen",
27475
+ binary: "on",
27476
+ switch: "on",
27477
+ motion: "detected",
27478
+ flood: "flooded",
27479
+ gas: "detected",
27480
+ smoke: "detected",
27481
+ "carbon-monoxide": "detected",
27482
+ vibration: "detected",
27483
+ tamper: "tampered"
27484
+ };
27485
+ object({
27486
+ /** Ms epoch of the last press. null = never observed since this provider started. */
27487
+ lastPressedAt: number().nullable(),
27488
+ /** Counter since provider start. Resets on reboot. Useful for metrics/debug. */
27489
+ pressCountSinceStart: number()
27490
+ });
27491
+ object({
27492
+ deviceId: number(),
27493
+ timestamp: number()
27494
+ });
27495
+ /**
27496
+ * The `doorbell-source` extension slot's editable value (D648): the camera's
27497
+ * virtual-doorbell trigger devices, exactly what the wrapper's
27498
+ * `doorbellSources` persists (as `{ deviceId }` records).
27499
+ */
27500
+ var DoorbellSourceSlotValueSchema = object({ deviceIds: array(number().int().positive()) });
27501
+ DeviceType.Camera;
27502
+ DeviceType.Button, DeviceType.Camera;
27503
+ var ExtensionSlotIdSchema = _enum(["doorbell-source", "linked-devices"]);
27504
+ DeviceType.Camera, DeviceType.Camera;
27505
+ /** A slot's editable value, discriminated on `slot`. */
27506
+ var ExtensionSlotValueSchema = discriminatedUnion("slot", [DoorbellSourceSlotValueSchema.extend({ slot: literal("doorbell-source") }), LinkedDevicesSlotValueSchema.extend({ slot: literal("linked-devices") })]);
27507
+ DeviceType.Camera;
27508
+ var ExtensionSourceAvailabilitySchema = discriminatedUnion("state", [object({ state: literal("ok") }), object({
27509
+ state: literal("unreachable"),
27510
+ reason: string()
27511
+ })]);
27512
+ /** One provider of slots — the D554 source-listing row. */
27513
+ var ExtensionSourceSchema = object({
27514
+ /** The routing prefix of every `slotRef` this provider serves. */
27515
+ source: string(),
27516
+ label: string(),
27517
+ /** Stamped by the dispatcher from the registry (D557). */
27518
+ addonId: string().optional(),
27519
+ availability: ExtensionSourceAvailabilitySchema
27520
+ });
27521
+ /**
27522
+ * How one picked source stands. `missing` — no such device (deleted, or an id
27523
+ * that never existed); `foreign` — a device the slot's owner cannot read from
27524
+ * (slice 2's cross-owner graft source); `unavailable` — it exists but is not a
27525
+ * valid source now (a container, which is never a pick). A source in any of
27526
+ * these states is SHOWN, never silently dropped or re-pointed.
27527
+ */
27528
+ var ExtensionSourceHealthSchema = _enum([
27529
+ "ok",
27530
+ "missing",
27531
+ "foreign",
27532
+ "unavailable"
27533
+ ]);
27534
+ var ExtensionSlotSourceSchema = object({
27535
+ deviceId: number().int(),
27536
+ /** The device's name, or null when it could not be resolved (`missing`). */
27537
+ name: string().nullable(),
27538
+ health: ExtensionSourceHealthSchema,
27539
+ detail: string().optional()
27540
+ });
27541
+ /**
27542
+ * A DIFFERENT authority that gates whether the slot's sources do anything —
27543
+ * reported read-only, named, never written through this contract (D62). The
27544
+ * virtual doorbell's sources ring only while the wrapper is bound to the
27545
+ * camera, and that binding belongs to `deviceManager.setWrapperActive`.
27546
+ * `unknown` means the gate could not be read; it is not "inactive" (D315).
27547
+ */
27548
+ var ExtensionSlotActivationSchema = object({
27549
+ state: _enum([
27550
+ "active",
27551
+ "inactive",
27552
+ "unknown"
27553
+ ]),
27554
+ authority: string(),
27555
+ detail: string().optional()
27556
+ });
27557
+ var ExtensionSlotRowSchema = discriminatedUnion("state", [object({
27558
+ state: literal("ok"),
27559
+ /** `<source>:<slotId>` — the routing key for `listCandidates` / `setSlot`. */
27560
+ slotRef: string(),
27561
+ slot: ExtensionSlotIdSchema,
27562
+ /** Names the store written (D62), e.g. `virtual-doorbell:doorbellSources`. */
27563
+ authority: string(),
27564
+ /** graft: its switch. association: null — removing every source is "off". */
27565
+ enabled: boolean().nullable(),
27566
+ /** graft: the cap registered on the host. association: null. */
27567
+ projects: string().nullable(),
27568
+ activation: ExtensionSlotActivationSchema.nullable(),
27569
+ value: ExtensionSlotValueSchema,
27570
+ sources: array(ExtensionSlotSourceSchema),
27571
+ /**
27572
+ * Devices the owner links to the host ON ITS OWN, by structure, and that
27573
+ * are therefore not in `value` (D655): a camera's device-tree children, and
27574
+ * the child a picked container stands for (D356 — a container is never a
27575
+ * pick, its children are). Read-only; the editor shows them and may offer
27576
+ * per-device options on them (the linked-devices tracked-event opt-in).
27577
+ * Absent when the owner links nothing beyond the picks, and from an owner
27578
+ * older than D655.
27579
+ */
27580
+ impliedSources: array(ExtensionSlotSourceSchema).optional()
27581
+ }), object({
27582
+ state: literal("unreachable"),
27583
+ addonId: string(),
27584
+ reason: string()
27585
+ })]);
27586
+ var ExtensionCandidatesSchema = object({ candidates: array(object({
27587
+ deviceId: number().int(),
27588
+ name: string(),
27589
+ type: string(),
27590
+ location: string().nullable(),
27591
+ /** The container a child belongs to — "Spotlight 2" alone does not say which. */
27592
+ parentName: string().nullable(),
27593
+ /** Pre-marked by the owner's suggestion rule. NEVER saved on its own (D356). */
27594
+ suggested: boolean(),
27595
+ /** Why it is suggested ("same location"), null when it is not. */
27596
+ reason: string().nullable()
27597
+ })) });
27598
+ /**
27599
+ * What a write did. `none` — applied in place. `host` / `parent` — the owner
27600
+ * reloaded the host (or, for an accessory child, its parent) because the
27601
+ * write changed what the host REGISTERS (a graft switched on or off, slice 2).
27602
+ */
27603
+ var ExtensionSetResultSchema = object({
27604
+ applied: literal(true),
27605
+ reload: _enum([
27606
+ "none",
27607
+ "host",
27608
+ "parent"
27609
+ ])
27610
+ });
27611
+ var ExtensionSlotRefInputSchema = object({
27612
+ deviceId: number().int(),
27613
+ slotRef: string().min(1)
27614
+ });
27615
+ var ExtensionSetSlotInputSchema = ExtensionSlotRefInputSchema.extend({ value: ExtensionSlotValueSchema });
27616
+ method(object({ deviceId: number().int() }), array(ExtensionSourceSchema)), method(object({ deviceId: number().int() }), array(ExtensionSlotRowSchema)), method(ExtensionSlotRefInputSchema, ExtensionCandidatesSchema), method(ExtensionSetSlotInputSchema, ExtensionSetResultSchema, {
27617
+ kind: "mutation",
27618
+ auth: "admin"
27619
+ });
27620
+ /**
26978
27621
  * Optional client-side hints sent at session creation to help the provider
26979
27622
  * pick the best native source. All fields optional — a viewer that knows
26980
27623
  * nothing still gets a sane default. (Relocated from the retired `webrtc`
@@ -28763,17 +29406,6 @@ object({
28763
29406
  deviceId: number(),
28764
29407
  status: DeviceStatusSchema
28765
29408
  });
28766
- object({
28767
- /** Ms epoch of the last press. null = never observed since this provider started. */
28768
- lastPressedAt: number().nullable(),
28769
- /** Counter since provider start. Resets on reboot. Useful for metrics/debug. */
28770
- pressCountSinceStart: number()
28771
- });
28772
- object({
28773
- deviceId: number(),
28774
- timestamp: number()
28775
- });
28776
- DeviceType.Button, DeviceType.Camera;
28777
29409
  /**
28778
29410
  * Enum-state sensor — a string value picked from a finite option set.
28779
29411
  * Drives HA `sensor` entries with `state_class: enum` (HVAC action
@@ -35626,42 +36258,6 @@ var zoneRulesCapability = {
35626
36258
  durability: "restored"
35627
36259
  };
35628
36260
  /**
35629
- * LA tabella "quale booleano di questo tipo di device conta come ALTO", e il
35630
- * valutatore puro del suo FRONTE.
35631
- *
35632
- * Viveva dentro il builtin virtual-doorbell
35633
- * (`@camstack/system` — `builtins/doorbell/trigger-engine.ts`) e i suoi
35634
- * predicati erano privati al modulo. Il recorder ne ha bisogno per il trigger
35635
- * `RecordingTriggers.sensorDeviceIds`: copiarla avrebbe creato la SECONDA
35636
- * tabella, che diverge alla prima cap aggiunta e il cui sintomo — "il sensore
35637
- * fa suonare il campanello ma non registra" — è esattamente D62. Quindi si
35638
- * SPOSTA qui e il doorbell la ri-esporta.
35639
- *
35640
- * ⚠ NON è `DEVICE_STATE_READERS` (`catalogs/device-state-vocabulary.ts`), e le
35641
- * due non vanno unificate: quella risponde a "qual è la PAROLA di stato per una
35642
- * regola" (e include `presence`, `cover`, `alarm-panel`), questa a "qual è il
35643
- * booleano il cui FRONTE conta". Vocabolari deliberatamente diversi.
35644
- */
35645
- /**
35646
- * Known binary / switch source caps → the boolean slice field whose
35647
- * false→true rise counts as ACTIVE. Every entry is "fire on active".
35648
- * Sensors whose "active" reading is not a plain boolean (presence's string
35649
- * state, connectivity's connected flag) are deliberately excluded — a
35650
- * reconnect is not a doorbell press, and it is not a recording either.
35651
- */
35652
- var SOURCE_CAP_ACTIVE_FIELD = {
35653
- contact: "entryOpen",
35654
- binary: "on",
35655
- switch: "on",
35656
- motion: "detected",
35657
- flood: "flooded",
35658
- gas: "detected",
35659
- smoke: "detected",
35660
- "carbon-monoxide": "detected",
35661
- vibration: "detected",
35662
- tamper: "tampered"
35663
- };
35664
- /**
35665
36261
  * Accessory device helpers — shared across drivers.
35666
36262
  *
35667
36263
  * Many vendor-specific drivers register accessory child devices on
@@ -38489,6 +39085,30 @@ Object.freeze({
38489
39085
  addonId: null,
38490
39086
  access: "create"
38491
39087
  },
39088
+ "deviceExtension.listCandidates": {
39089
+ capName: "device-extension",
39090
+ capScope: "device",
39091
+ addonId: null,
39092
+ access: "view"
39093
+ },
39094
+ "deviceExtension.listSlots": {
39095
+ capName: "device-extension",
39096
+ capScope: "device",
39097
+ addonId: null,
39098
+ access: "view"
39099
+ },
39100
+ "deviceExtension.listSources": {
39101
+ capName: "device-extension",
39102
+ capScope: "device",
39103
+ addonId: null,
39104
+ access: "view"
39105
+ },
39106
+ "deviceExtension.setSlot": {
39107
+ capName: "device-extension",
39108
+ capScope: "device",
39109
+ addonId: null,
39110
+ access: "create"
39111
+ },
38492
39112
  "deviceManager.addLocation": {
38493
39113
  capName: "device-manager",
38494
39114
  capScope: "system",
@@ -38561,12 +39181,6 @@ Object.freeze({
38561
39181
  addonId: null,
38562
39182
  access: "create"
38563
39183
  },
38564
- "deviceManager.applyDeviceSettingsPatch": {
38565
- capName: "device-manager",
38566
- capScope: "system",
38567
- addonId: null,
38568
- access: "create"
38569
- },
38570
39184
  "deviceManager.applyInitialMeta": {
38571
39185
  capName: "device-manager",
38572
39186
  capScope: "system",
@@ -38669,12 +39283,6 @@ Object.freeze({
38669
39283
  addonId: null,
38670
39284
  access: "view"
38671
39285
  },
38672
- "deviceManager.getDeviceLiveContribution": {
38673
- capName: "device-manager",
38674
- capScope: "system",
38675
- addonId: null,
38676
- access: "view"
38677
- },
38678
39286
  "deviceManager.getDeviceLiveInfoAggregate": {
38679
39287
  capName: "device-manager",
38680
39288
  capScope: "system",
@@ -38687,12 +39295,6 @@ Object.freeze({
38687
39295
  addonId: null,
38688
39296
  access: "view"
38689
39297
  },
38690
- "deviceManager.getDeviceSettingsContribution": {
38691
- capName: "device-manager",
38692
- capScope: "system",
38693
- addonId: null,
38694
- access: "view"
38695
- },
38696
39298
  "deviceManager.getDeviceStatusAggregate": {
38697
39299
  capName: "device-manager",
38698
39300
  capScope: "system",
@@ -39197,24 +39799,6 @@ Object.freeze({
39197
39799
  addonId: null,
39198
39800
  access: "view"
39199
39801
  },
39200
- "doorbell.applyDeviceSettingsPatch": {
39201
- capName: "doorbell",
39202
- capScope: "device",
39203
- addonId: null,
39204
- access: "create"
39205
- },
39206
- "doorbell.getDeviceLiveContribution": {
39207
- capName: "doorbell",
39208
- capScope: "device",
39209
- addonId: null,
39210
- access: "view"
39211
- },
39212
- "doorbell.getDeviceSettingsContribution": {
39213
- capName: "doorbell",
39214
- capScope: "device",
39215
- addonId: null,
39216
- access: "view"
39217
- },
39218
39802
  "doorbell.getStatus": {
39219
39803
  capName: "doorbell",
39220
39804
  capScope: "device",
@@ -39239,6 +39823,12 @@ Object.freeze({
39239
39823
  addonId: null,
39240
39824
  access: "view"
39241
39825
  },
39826
+ "embeddingEncoder.resetFailedModels": {
39827
+ capName: "embedding-encoder",
39828
+ capScope: "system",
39829
+ addonId: null,
39830
+ access: "delete"
39831
+ },
39242
39832
  "enumSensor.getStatus": {
39243
39833
  capName: "enum-sensor",
39244
39834
  capScope: "device",
@@ -41573,6 +42163,12 @@ Object.freeze({
41573
42163
  addonId: null,
41574
42164
  access: "view"
41575
42165
  },
42166
+ "pipelineOrchestrator.getClusterModelChoice": {
42167
+ capName: "pipeline-orchestrator",
42168
+ capScope: "system",
42169
+ addonId: null,
42170
+ access: "view"
42171
+ },
41576
42172
  "pipelineOrchestrator.getDeviceLiveContribution": {
41577
42173
  capName: "pipeline-orchestrator",
41578
42174
  capScope: "system",
@@ -44398,6 +44994,26 @@ Object.freeze({
44398
44994
  form: "single",
44399
44995
  optional: false
44400
44996
  }],
44997
+ "deviceExtension.listCandidates": [{
44998
+ name: "deviceId",
44999
+ form: "single",
45000
+ optional: false
45001
+ }],
45002
+ "deviceExtension.listSlots": [{
45003
+ name: "deviceId",
45004
+ form: "single",
45005
+ optional: false
45006
+ }],
45007
+ "deviceExtension.listSources": [{
45008
+ name: "deviceId",
45009
+ form: "single",
45010
+ optional: false
45011
+ }],
45012
+ "deviceExtension.setSlot": [{
45013
+ name: "deviceId",
45014
+ form: "single",
45015
+ optional: false
45016
+ }],
44401
45017
  "deviceManager.adoptionRelease": [{
44402
45018
  name: "camDeviceId",
44403
45019
  form: "single",
@@ -46857,135 +47473,21 @@ TimelapseRuleInputSchema.extend({
46857
47473
  createdAt: number(),
46858
47474
  updatedAt: number()
46859
47475
  });
47476
+ /** True when `model` ships a build for `format`. */
47477
+ function hasBuild(model, format) {
47478
+ return Boolean(model.formats[format]);
47479
+ }
46860
47480
  /**
46861
- * A model choice has a SCOPE, and a scope change has a workflow.
46862
- *
46863
- * ## The two scopes
46864
- *
46865
- * Almost every pipeline step's model is a **node** question: which build runs
46866
- * fastest on THIS accelerator. Two nodes may legitimately answer differently —
46867
- * a Coral runs the tflite SSD, a Mac runs the coreml YOLO — because the answers
46868
- * are compared against nothing. They produce boxes, and a box is a box.
46869
- *
46870
- * Two steps are not like that. `face-embedding` and `clip-embedding` produce
46871
- * **vectors that land in ONE shared index** (`identity_samples`,
46872
- * `vec_object_clip`) and are ranked against each other by cosine similarity.
46873
- * Cosine similarity between two encoders is not a weak signal, it is a
46874
- * meaningless number — and nothing inside a vector can tell you it happened.
46875
- * So for those two steps the model is a **cluster** question with exactly one
46876
- * answer, stored in exactly one row.
46877
- *
46878
- * That is the same argument [D52](../../../../docs/decisions/adr-0052.md) makes
46879
- * for the crop convention (`detail-crop.ts`), one level up: the crop decides
46880
- * WHAT pixels the encoder sees, this decides WHICH encoder sees them. Both are
46881
- * cluster-wide for the same reason and neither may become per-node.
46882
- *
46883
- * ## Why the registry lives here and not in the step catalog
46884
- *
46885
- * The scope itself is declared on the step, in
46886
- * `detection-pipeline/registry/step-definitions.ts` (`StepDefinition.modelScope`)
46887
- * — that is where a new step declares what kind of choice its model is. But the
46888
- * catalog lives inside `addon-pipeline`, and three packages that must NOT
46889
- * import an addon need to know the set: the orchestrator (which renders and
46890
- * stores the row), post-analysis (which pins the row for a re-embed pass) and
46891
- * admin-ui (which mirrors it). So the set is mirrored here, and
46892
- * `scripts/check-cluster-model-scope.ts` fails the build when the two disagree.
46893
- * A hand-copied key that stops matching is exactly how a setting silently stops
46894
- * arriving while both sides still look correct.
46895
- *
46896
- * ## The defaults ARE today's behaviour
46897
- *
46898
- * {@link DEFAULT_CLUSTER_STEP_MODELS} reproduces the catalog `defaultModelId`
46899
- * of both steps. Declaring the scope therefore changes nothing on the day it
46900
- * ships: the cluster row starts out saying what every node was already doing.
46901
- * Changing it is legitimate — that is what the knob is for — but it invalidates
46902
- * the stored vectors of that surface, and must be followed by a re-embed pass
46903
- * (`face-reembed-pass.ts`, `embedding-rebuild.ts`).
46904
- */
46905
- /**
46906
- * The cluster-scoped steps. Mirrored from the catalog's `modelScope: 'cluster'`
46907
- * declarations and guarded by `scripts/check-cluster-model-scope.ts`.
46908
- */
46909
- var CLUSTER_MODEL_SCOPED_STEPS = [{
46910
- stepId: "face-embedding",
46911
- label: "Face recognition model",
46912
- defaultModelId: "arcface-r100",
46913
- indexName: "the enrolled face gallery",
46914
- options: [
46915
- {
46916
- id: "arcface-r100",
46917
- label: "ArcFace (ResNet34)"
46918
- },
46919
- {
46920
- id: "auraface-r100",
46921
- label: "AuraFace R100"
46922
- },
46923
- {
46924
- id: "inception-resnet-v1",
46925
- label: "Inception ResNet V1"
46926
- }
46927
- ]
46928
- }, {
46929
- stepId: "clip-embedding",
46930
- label: "Semantic search model",
46931
- defaultModelId: "mobileclip-s1",
46932
- indexName: "the object semantic-search index",
46933
- options: [{
46934
- id: "mobileclip-s1",
46935
- label: "MobileCLIP S1"
46936
- }, {
46937
- id: "mobileclip-s2",
46938
- label: "MobileCLIP S2"
46939
- }]
46940
- }];
46941
- /**
46942
- * Store identity of the cluster row inside `pipeline-orchestrator`'s GLOBAL
46943
- * (cluster-wide) settings — the same owner, and for the same reason, as the
46944
- * detail-crop convention: hub-resident and neutral between the addons that
46945
- * read it.
47481
+ * Can a pool of `format` run a step whose catalog is `models`, given the model
47482
+ * the step names (`null`/absent = unpinned, the format default)?
46946
47483
  */
46947
- var CLUSTER_MODEL_SECTION_ID = "cluster-step-models";
46948
- /**
46949
- * The settings key holding one step's cluster model choice.
46950
- *
46951
- * Step-qualified so the keys stay unique across the addon's whole schema, which
46952
- * is what lets {@link pickClusterStepModels} walk every section instead of
46953
- * trusting the section id.
46954
- */
46955
- function clusterModelSettingKey(stepId) {
46956
- return `clusterModel:${stepId}`;
46957
- }
46958
- Object.freeze(Object.fromEntries(CLUSTER_MODEL_SCOPED_STEPS.map((s) => [s.stepId, s.defaultModelId])));
46959
- string().min(1);
46960
- var CLUSTER_STEP_SETTING_FIELDS = [{
46961
- stepId: "face-embedding",
46962
- key: "minLandmarkFaceSize",
46963
- label: "Min face size for recognition (detection px)",
46964
- description: "Refuse to embed a face smaller than this IN THE DETECTION FRAME. Applies to every node — the enrolled gallery is one index, and two admission floors would fill it from two policies. 0 disables the gate.",
46965
- type: "slider",
46966
- min: 0,
46967
- max: 64,
46968
- step: 2,
46969
- default: 24
46970
- }];
46971
- function clusterStepSettingKey(stepId, fieldKey) {
46972
- return `clusterStepSetting:${stepId}:${fieldKey}`;
46973
- }
46974
- var ClusterSettingNumberSchema = number().finite();
46975
- function readClusterStepSettings(config) {
46976
- const out = {};
46977
- for (const field of CLUSTER_STEP_SETTING_FIELDS) {
46978
- const parsed = ClusterSettingNumberSchema.safeParse(config[clusterStepSettingKey(field.stepId, field.key)]);
46979
- const value = parsed.success ? parsed.data : field.default;
46980
- const existing = out[field.stepId] ?? {};
46981
- out[field.stepId] = {
46982
- ...existing,
46983
- [field.key]: value
46984
- };
47484
+ function stepModelRunsOnFormat(models, modelId, format) {
47485
+ if (modelId !== void 0 && modelId !== null) {
47486
+ const named = models.find((m) => m.id === modelId);
47487
+ if (named !== void 0 && hasBuild(named, format)) return true;
46985
47488
  }
46986
- return out;
47489
+ return models.some((m) => m.legacy !== true && hasBuild(m, format));
46987
47490
  }
46988
- readClusterStepSettings({});
46989
47491
  /**
46990
47492
  * THE detail-crop convention — the single derivation of the rectangle a
46991
47493
  * detail/enrichment step (clip-embedding, face-detection, plate-detection…)
@@ -47330,6 +47832,63 @@ function deviceBackendToFormat(backend) {
47330
47832
  return DEVICE_BACKEND_TO_FORMAT[backend] ?? "onnx";
47331
47833
  }
47332
47834
  //#endregion
47835
+ //#region src/cluster-model-choice.ts
47836
+ /**
47837
+ * The owner's answer to "which model did the operator choose for this
47838
+ * cluster-scoped step?" (D651 fix round 3).
47839
+ *
47840
+ * The orchestrator owns `clusterModel:*`: it renders the form and stores the
47841
+ * row. A consumer that REWRITES an index on this answer (the face re-embed,
47842
+ * the CLIP rebuild) must not read the orchestrator's private store layout,
47843
+ * and must not read its hydrated settings view either — that answers
47844
+ * `value ?? default`, so a default nobody chose and a failed read both look
47845
+ * like a choice. So the owner answers, from a three-way read of its own store.
47846
+ *
47847
+ * The stored VALUE is read with the runtime's own interpretation
47848
+ * (`interpretStoredClusterModel`, fix round 4): a cleared field (`null`, `''`)
47849
+ * is absent, any non-empty string is the choice — verbatim, as the runner pins
47850
+ * it — and only a non-string is `invalid-stored`. Checking the id against the
47851
+ * dropdown here would give one stored key two meanings: the runner deliberately
47852
+ * pins an id the catalog does not list (a custom encoder), so an owner that
47853
+ * called it invalid would refuse a pass on the very model the fleet is running.
47854
+ */
47855
+ /**
47856
+ * Turn a three-way read of `clusterModel:<stepId>` into the cap answer.
47857
+ * `'owner-not-ready'` is the caller's statement that its boot has not settled.
47858
+ */
47859
+ function decideClusterModelChoice(stepId, read) {
47860
+ const step = CLUSTER_MODEL_SCOPED_STEPS.find((s) => s.stepId === stepId);
47861
+ if (step === void 0) return {
47862
+ kind: "unreadable",
47863
+ reason: "not-cluster-scoped"
47864
+ };
47865
+ if (read === "owner-not-ready") return {
47866
+ kind: "unreadable",
47867
+ reason: "owner-not-ready"
47868
+ };
47869
+ if (read.kind === "unreadable") return read.error !== void 0 ? {
47870
+ kind: "unreadable",
47871
+ reason: read.reason,
47872
+ error: read.error
47873
+ } : {
47874
+ kind: "unreadable",
47875
+ reason: read.reason
47876
+ };
47877
+ const stored = interpretStoredClusterModel(read.kind === "absent" ? void 0 : read.value);
47878
+ if (stored.kind === "absent") return {
47879
+ kind: "absent",
47880
+ defaultModelId: step.defaultModelId
47881
+ };
47882
+ if (stored.kind === "chosen") return {
47883
+ kind: "stored",
47884
+ modelId: stored.modelId
47885
+ };
47886
+ return {
47887
+ kind: "unreadable",
47888
+ reason: "invalid-stored"
47889
+ };
47890
+ }
47891
+ //#endregion
47333
47892
  //#region src/audio-load-balancer.ts
47334
47893
  function balanceAudio(input) {
47335
47894
  if (input.nodes.length === 0) return null;
@@ -48238,6 +48797,352 @@ function createDiskReconcileJobRunner(now = Date.now) {
48238
48797
  };
48239
48798
  }
48240
48799
  //#endregion
48800
+ //#region src/pipeline-resolver.ts
48801
+ /**
48802
+ * The `package-detection` root step id. Zone-driven activation
48803
+ * ({@link applyPackageZoneActivation}) is keyed on it — the step has NO
48804
+ * user-facing toggle; a drawn package zone is the sole enable signal.
48805
+ */
48806
+ var PACKAGE_DETECTION_STEP_ID = "package-detection";
48807
+ /**
48808
+ * The CAMERA ROOT: the always-on object-detection step every session's tracks
48809
+ * originate from. The ONE definition behind two gates that must agree (D650
48810
+ * round 4): the device election's root guard (`makeRootCapabilityGuard`)
48811
+ * excludes a device whose format cannot run it, and the attach backstop
48812
+ * (`ResolvedPipeline.droppedRoots`) refuses an attach that lost it. Any other
48813
+ * gated-on root the elected device cannot run (`segmentation`, an opt-in
48814
+ * refiner) is a logged skip, never a refusal: the election does not look at
48815
+ * it, so refusing on it left a camera pending forever.
48816
+ *
48817
+ * Mirrors the addon-pipeline step id — a cross-addon import is forbidden.
48818
+ */
48819
+ var OBJECT_DETECTION_STEP_ID = "object-detection";
48820
+ /**
48821
+ * Zone-driven activation for the toggle-less `package-detection` root step.
48822
+ *
48823
+ * The step runs for a device IFF it has ≥1 package zone (an enabled
48824
+ * `package`-stage zone rule referencing geometry). There is deliberately NO
48825
+ * operator toggle — drawing a package zone IS the enable, removing every
48826
+ * package zone IS the disable. This gate reconciles the resolved step tree to
48827
+ * that rule:
48828
+ *
48829
+ * - `hasPackageZone` true → the step is ensured present as an ENABLED root
48830
+ * (a pre-existing node keeps its `modelId` / `settings` — e.g. a per-node
48831
+ * model choice); default-on where a zone is present.
48832
+ * - `hasPackageZone` false → any `package-detection` node is stripped so the
48833
+ * runner never dispatches it.
48834
+ *
48835
+ * `package-detection` is a ROOT (`inputClasses: null`) so it only ever appears
48836
+ * at the top level — no recursion into children needed. Every other step is
48837
+ * left untouched. Immutable: returns a fresh array only when a change applies.
48838
+ */
48839
+ function applyPackageZoneActivation(steps, hasPackageZone) {
48840
+ const existing = steps.find((s) => s.addonId === PACKAGE_DETECTION_STEP_ID);
48841
+ if (!hasPackageZone) {
48842
+ if (!existing) return steps;
48843
+ return steps.filter((s) => s.addonId !== PACKAGE_DETECTION_STEP_ID);
48844
+ }
48845
+ if (existing) {
48846
+ if (existing.enabled === true) return steps;
48847
+ return steps.map((s) => s.addonId === "package-detection" ? {
48848
+ ...s,
48849
+ enabled: true
48850
+ } : s);
48851
+ }
48852
+ const node = {
48853
+ addonId: PACKAGE_DETECTION_STEP_ID,
48854
+ enabled: true,
48855
+ children: []
48856
+ };
48857
+ return [...steps, node];
48858
+ }
48859
+ /**
48860
+ * Steps that are OFF by the per-camera gate DEFAULT (i.e. when the camera's
48861
+ * `stepToggles[addonId]` is unset). Mirrors each step's catalog
48862
+ * `enabledByDefault: false` — the heavy / opt-in classifiers + segmentation.
48863
+ *
48864
+ * The pure resolver derives the default gate from the LIVE catalog
48865
+ * (`addon.enabledByDefault ?? true`), so it stays correct if a future
48866
+ * default-off step ships. This literal set is the catalog-FREE fallback used
48867
+ * by the one-shot gating migration ({@link migrateStepGating}), which runs at
48868
+ * boot with no guaranteed live executor catalog. The two agree for every
48869
+ * step that exists today.
48870
+ *
48871
+ * `package-detection` is deliberately NOT listed — it is ZONE-driven
48872
+ * ({@link applyPackageZoneActivation}) and never governed by the gate.
48873
+ */
48874
+ var DEFAULT_OFF_STEP_IDS = new Set([
48875
+ "animal-classifier",
48876
+ "bird-classifier",
48877
+ "vehicle-classifier",
48878
+ "segmentation"
48879
+ ]);
48880
+ /**
48881
+ * Catalog-free default gate for `addonId` — `true` unless the step is in
48882
+ * {@link DEFAULT_OFF_STEP_IDS}. Used by the migration (no live catalog); the
48883
+ * resolver prefers the catalog's own `enabledByDefault ?? true`.
48884
+ */
48885
+ function defaultStepGate(addonId) {
48886
+ return !DEFAULT_OFF_STEP_IDS.has(addonId);
48887
+ }
48888
+ /**
48889
+ * PROVISIONING presence for `(addon, format)` — the node "provides" the step
48890
+ * iff a pool of that format can run it, by the ONE placement rule every
48891
+ * placement reads (`stepModelRunsOnFormat`, D657). The resolver is
48892
+ * structure-only, so the step names no model here ("Auto"): a NON-legacy
48893
+ * model must ship the build. A legacy stub never makes a format provisioned.
48894
+ */
48895
+ function addonHasCompatibleModel(addon, format) {
48896
+ return stepModelRunsOnFormat(addon.models ?? [], null, format);
48897
+ }
48898
+ /**
48899
+ * A ROOT runs on the frame plane of the ELECTED device and is never jumped
48900
+ * (D650). Roots are the video steps with no `inputClasses`.
48901
+ */
48902
+ function isVideoRoot(addon) {
48903
+ return (addon.inputClasses ?? []).length === 0;
48904
+ }
48905
+ /**
48906
+ * The per-DEVICE provisioning gate (D650). `null` = keep the step.
48907
+ *
48908
+ * - `audio-classifier` is NOT gated here at all. It never runs on an
48909
+ * inference pool — the audio-analyzer runs its own backend (YAMNet in an
48910
+ * embedded-Python subprocess, or Apple SoundAnalysis) — so an inference
48911
+ * format says nothing about whether it can run. Only enablement applies.
48912
+ * - A video ROOT needs a build for the ELECTED device's format — the runner
48913
+ * never jumps it, so a sibling accelerator that could run it is irrelevant.
48914
+ * - A detail step is kept when the elected format OR any format of the
48915
+ * node's jump roster (`jumpFormats` — the SAME enabled∧available devices
48916
+ * the runner jumps within) has a build: the runner's step-tree jump
48917
+ * (`pipeline-runner/device-jump.ts`) then places it.
48918
+ */
48919
+ function unprovisionedReason(addon, electedFormat, jumpFormats) {
48920
+ if (addon.slot === "audio-classifier") return null;
48921
+ if (addonHasCompatibleModel(addon, electedFormat)) return null;
48922
+ if (isVideoRoot(addon)) return jumpFormats.some((f) => addonHasCompatibleModel(addon, f)) ? "root-not-runnable-on-elected-device" : "no-compatible-model";
48923
+ return jumpFormats.some((f) => addonHasCompatibleModel(addon, f)) ? null : "no-compatible-model";
48924
+ }
48925
+ /**
48926
+ * Device-aware resolver — the SINGLE per-camera gate + per-DEVICE skip check.
48927
+ *
48928
+ * Step `S` runs for camera `C` on device `D` ⇔
48929
+ * gate(C, S) — `stepToggles[S]` ?? `addon.enabledByDefault ?? true`
48930
+ * AND provided(D, S) — the catalog lists a model with a build for D's FORMAT
48931
+ * (a ROOT), or for D's format or any `jumpFormats`
48932
+ * entry (a detail step — the runner jumps it, D650);
48933
+ * `audio-classifier` is never format-gated (it runs
48934
+ * on the audio-analyzer's own backend)
48935
+ *
48936
+ * Enablement is the per-camera `stepToggles` authority ONLY. Provisioning
48937
+ * (`modelId` / `settings`) is NOT applied here — the returned tree carries bare
48938
+ * `{ addonId, enabled: true, children }` nodes; {@link applyDeviceProvisioning}
48939
+ * attaches model + settings at dispatch from the per-(node,device) base + the
48940
+ * per-camera-device override.
48941
+ *
48942
+ * A step gated ON that no device it may run on can serve is DROPPED and
48943
+ * recorded in `skipped` so the caller can surface a visible "gated on, no model
48944
+ * on this node" status at dispatch time.
48945
+ *
48946
+ * `package-detection` is excluded here — it is zone-driven
48947
+ * ({@link applyPackageZoneActivation}, applied by the caller).
48948
+ *
48949
+ * "Inference disabled for this camera" is NOT handled here — the caller
48950
+ * (resolvePipelineForDevice) consults the detection-pipeline binding and
48951
+ * short-circuits to an empty pipeline before invoking this resolver.
48952
+ */
48953
+ function resolvePipelineWithSkips(cameraSettings, agentNodeId, deviceFormat, catalog, jumpFormats = []) {
48954
+ const cam = cameraSettings ?? {};
48955
+ const wholesale = cam.pipelineByAgent?.[agentNodeId];
48956
+ if (wholesale) return {
48957
+ config: {
48958
+ steps: mergeEnabledByDefaultSteps(wholesale.steps, catalog),
48959
+ audio: wholesale.audio ? {
48960
+ modelId: wholesale.audio.modelId,
48961
+ enabled: wholesale.audio.enabled
48962
+ } : null
48963
+ },
48964
+ skipped: [],
48965
+ droppedRoots: []
48966
+ };
48967
+ const toggles = cam.stepToggles ?? {};
48968
+ const format = deviceFormat;
48969
+ const enabled = [];
48970
+ const skipped = [];
48971
+ const droppedRoots = [];
48972
+ for (const slot of catalog.slots) for (const addon of slot.addons) {
48973
+ const addonId = addon.id;
48974
+ if (addonId === "package-detection") continue;
48975
+ if (!(toggles[addonId] ?? addon.enabledByDefault ?? true)) continue;
48976
+ const reason = unprovisionedReason(addon, format, jumpFormats);
48977
+ if (reason !== null) {
48978
+ skipped.push({
48979
+ addonId,
48980
+ reason
48981
+ });
48982
+ if (addonId === "object-detection") droppedRoots.push(addonId);
48983
+ continue;
48984
+ }
48985
+ enabled.push({ addonId });
48986
+ }
48987
+ const audioAddons = [];
48988
+ const videoAddons = [];
48989
+ for (const entry of enabled) {
48990
+ const meta = findInCatalog(catalog, entry.addonId);
48991
+ if (!meta) continue;
48992
+ if (meta.slot === "audio-classifier") audioAddons.push(entry);
48993
+ else videoAddons.push(entry);
48994
+ }
48995
+ return {
48996
+ config: {
48997
+ steps: buildTreeFromAddons(videoAddons, catalog),
48998
+ audio: audioAddons.length > 0 ? { enabled: true } : null
48999
+ },
49000
+ skipped,
49001
+ droppedRoots
49002
+ };
49003
+ }
49004
+ function findInCatalog(catalog, addonId) {
49005
+ for (const slot of catalog.slots) for (const addon of slot.addons) if (addon.id === addonId) return addon;
49006
+ return null;
49007
+ }
49008
+ /**
49009
+ * Compose a step tree from a flat list of enabled video addons using
49010
+ * catalog compatibility: an addon whose `inputClasses` overlaps a
49011
+ * candidate parent's `outputClasses` attaches as the parent's child.
49012
+ * Addons with `inputClasses` null/empty become roots.
49013
+ *
49014
+ * Multiple addons in the same slot with compatible inputClasses run in
49015
+ * parallel — they attach to the same parent as siblings. Example: two
49016
+ * classifiers (bird-classifier + animal-classifier) both accept 'animal'
49017
+ * → both attach to object-detection as siblings, both receive every
49018
+ * animal crop.
49019
+ *
49020
+ * Unparented leaves (inputClasses set, but no matching parent in the
49021
+ * enabled set) are DROPPED. The runner would have nothing to feed them
49022
+ * anyway — better silent drop than dispatching a dead step.
49023
+ */
49024
+ function buildTreeFromAddons(enabled, catalog) {
49025
+ const bySlot = /* @__PURE__ */ new Map();
49026
+ for (const slot of catalog.slots) bySlot.set(slot.id, slot);
49027
+ const nodes = /* @__PURE__ */ new Map();
49028
+ const metaById = /* @__PURE__ */ new Map();
49029
+ for (const { addonId } of enabled) {
49030
+ const meta = findInCatalog(catalog, addonId);
49031
+ if (!meta) continue;
49032
+ metaById.set(addonId, meta);
49033
+ nodes.set(addonId, {
49034
+ addonId,
49035
+ enabled: true,
49036
+ children: []
49037
+ });
49038
+ }
49039
+ const roots = [];
49040
+ for (const [addonId, node] of nodes) {
49041
+ const inputs = metaById.get(addonId).inputClasses;
49042
+ if (!inputs || inputs.length === 0) {
49043
+ roots.push(node);
49044
+ continue;
49045
+ }
49046
+ let parent = null;
49047
+ for (const [candidateId, candidateNode] of nodes) {
49048
+ if (candidateId === addonId) continue;
49049
+ if (metaById.get(candidateId).outputClasses.some((c) => inputs.includes(c))) {
49050
+ parent = candidateNode;
49051
+ break;
49052
+ }
49053
+ }
49054
+ if (parent) parent.children.push(node);
49055
+ }
49056
+ return roots;
49057
+ }
49058
+ /**
49059
+ * Back-fill catalog steps EXPLICITLY flagged `backfillIntoExistingOverrides`
49060
+ * but ENTIRELY ABSENT from an existing step tree — reconciles a stored
49061
+ * wholesale `pipelineByAgent` snapshot with genuinely-new default steps.
49062
+ *
49063
+ * Why it's needed: a `pipelineByAgent` wholesale override SNAPSHOTS the full
49064
+ * tree. A step introduced AFTER the snapshot (e.g. `clip-embedding` for object
49065
+ * semantic search) would otherwise never reach that camera.
49066
+ *
49067
+ * Why the flag (not `enabledByDefault`): absence-from-a-snapshot is ambiguous —
49068
+ * it can mean "predates this step" OR "operator deliberately pruned it". A
49069
+ * broad "back-fill every default-on step" would resurrect deliberate removals
49070
+ * of long-existing steps (face-detection, etc.), an operator-intent
49071
+ * regression. Only a step that opts in via `backfillIntoExistingOverrides`
49072
+ * (set on genuinely-new default steps ONLY) re-enters existing overrides.
49073
+ *
49074
+ * Non-destructive by design:
49075
+ * - Only steps whose id is absent from the WHOLE tree are added. A step the
49076
+ * operator has already seen — present anywhere, even `enabled: false` —
49077
+ * is left exactly as-is (its enabled flag is honoured).
49078
+ * - Only `backfillIntoExistingOverrides === true` addons are candidates.
49079
+ * Every other step (including long-standing default-on members) is left
49080
+ * untouched, so a deliberately-pruned override stays pruned.
49081
+ * - Audio-classifier addons are skipped — audio lives in
49082
+ * `CameraPipelineConfig.audio`, never in the video `steps` tree.
49083
+ * - A candidate attaches by catalog compat (its `inputClasses` overlap a
49084
+ * present node's `outputClasses`), mirroring {@link buildTreeFromAddons}.
49085
+ * A candidate with non-empty `inputClasses` and NO compatible parent is
49086
+ * dropped (never re-rooted) so a crop-scoped embedder never regresses to
49087
+ * running on the full frame. Empty `inputClasses` ⇒ a genuine root step.
49088
+ */
49089
+ function mergeEnabledByDefaultSteps(steps, catalog) {
49090
+ if (steps.length === 0) return steps;
49091
+ const present = /* @__PURE__ */ new Set();
49092
+ const collect = (nodes) => {
49093
+ for (const n of nodes) {
49094
+ present.add(n.addonId);
49095
+ if (n.children?.length) collect(n.children);
49096
+ }
49097
+ };
49098
+ collect(steps);
49099
+ const catalogById = /* @__PURE__ */ new Map();
49100
+ for (const slot of catalog.slots) for (const addon of slot.addons) catalogById.set(addon.id, addon);
49101
+ const newRoots = [];
49102
+ const childrenByParent = /* @__PURE__ */ new Map();
49103
+ for (const slot of catalog.slots) {
49104
+ if (slot.id === "audio-classifier") continue;
49105
+ for (const addon of slot.addons) {
49106
+ if (addon.backfillIntoExistingOverrides !== true) continue;
49107
+ if (present.has(addon.id)) continue;
49108
+ const node = {
49109
+ addonId: addon.id,
49110
+ enabled: true,
49111
+ children: []
49112
+ };
49113
+ const inputs = addon.inputClasses;
49114
+ if (!inputs || inputs.length === 0) {
49115
+ newRoots.push(node);
49116
+ continue;
49117
+ }
49118
+ let parentId = null;
49119
+ for (const presentId of present) {
49120
+ const meta = catalogById.get(presentId);
49121
+ if (!meta) continue;
49122
+ if (meta.outputClasses.some((c) => inputs.includes(c))) {
49123
+ parentId = presentId;
49124
+ break;
49125
+ }
49126
+ }
49127
+ if (!parentId) continue;
49128
+ const list = childrenByParent.get(parentId) ?? [];
49129
+ list.push(node);
49130
+ childrenByParent.set(parentId, list);
49131
+ }
49132
+ }
49133
+ if (childrenByParent.size === 0 && newRoots.length === 0) return steps;
49134
+ const attach = (nodes) => nodes.map((n) => {
49135
+ const rebuiltChildren = attach(n.children ?? []);
49136
+ const extra = childrenByParent.get(n.addonId) ?? [];
49137
+ const mergedChildren = [...rebuiltChildren, ...extra];
49138
+ return mergedChildren.length > 0 ? {
49139
+ ...n,
49140
+ children: mergedChildren
49141
+ } : n;
49142
+ });
49143
+ return [...attach(steps), ...newRoots];
49144
+ }
49145
+ //#endregion
48241
49146
  //#region src/inference-device-model.ts
48242
49147
  /**
48243
49148
  * Per-device default object-detection model + deviceKey parsing for the
@@ -48263,20 +49168,14 @@ function createDiskReconcileJobRunner(now = Date.now) {
48263
49168
  * gates (0/3 miss recovered at the current threshold).
48264
49169
  */
48265
49170
  /**
48266
- * The always-on object-detection ROOT step id. A camera session's tracks all
48267
- * originate from this detector, so a device whose engine format can't run it
48268
- * cannot host a camera root. Mirrors the addon-pipeline step id (cross-addon
48269
- * import is forbidden — this is the same duplication rationale as the model
48270
- * defaults above).
48271
- */
48272
- var OBJECT_DETECTION_STEP_ID = "object-detection";
48273
- /**
48274
49171
  * Build the camera-root capability predicate for a node from its live catalog:
48275
49172
  * `format → canHostCameraRoot`. A format can host a camera root iff the
48276
49173
  * catalog lists at least one object-detection model with a build for that
48277
- * format — byte-for-byte the resolver's per-device skip-gate test for the root
48278
- * step (`addonHasCompatibleModel`), so a device is deemed eligible iff the root
48279
- * would ACTUALLY provision on it.
49174
+ * format — it IS the resolver's per-device skip-gate test for the root step
49175
+ * (`addonHasCompatibleModel`), so a device is deemed eligible iff the root
49176
+ * would ACTUALLY provision on it. The root is {@link OBJECT_DETECTION_STEP_ID},
49177
+ * shared with the resolver's refusal backstop (`droppedRoots`) so the two gates
49178
+ * judge the same step (D650 round 4).
48280
49179
  *
48281
49180
  * Fails OPEN when the catalog has no object-detection slot at all (never
48282
49181
  * observed in production) so a malformed/empty catalog never strands every
@@ -48285,7 +49184,7 @@ var OBJECT_DETECTION_STEP_ID = "object-detection";
48285
49184
  function makeRootCapabilityGuard(catalog) {
48286
49185
  for (const slot of catalog.slots) {
48287
49186
  const objDet = slot.addons.find((a) => a.id === OBJECT_DETECTION_STEP_ID);
48288
- if (objDet) return (format) => objDet.models.some((m) => Boolean(m.formats[format]));
49187
+ if (objDet) return (format) => addonHasCompatibleModel(objDet, format);
48289
49188
  }
48290
49189
  return () => true;
48291
49190
  }
@@ -49796,16 +50695,18 @@ var CameraStatusService = class {
49796
50695
  const audioAssignment = this.deps.getAudioAssignment(deviceId);
49797
50696
  const audioNodeId = audioAssignment?.nodeId ?? null;
49798
50697
  const audioPinned = audioAssignment?.pinned ?? false;
50698
+ const pinned = {
50699
+ detection: pipelineAssignment?.pinned ?? false,
50700
+ decoder: false,
50701
+ audio: audioPinned
50702
+ };
50703
+ const pendingReason = this.deps.getPendingReason(deviceId);
49799
50704
  return {
49800
50705
  pipelineAssignment,
49801
50706
  detectionNodeId,
49802
50707
  sourceNodeId,
49803
- pinned: {
49804
- detection: pipelineAssignment?.pinned ?? false,
49805
- decoder: false,
49806
- audio: audioPinned
49807
- },
49808
- detectionReason: pipelineAssignment !== null ? pipelineAssignment.reason : this.deps.hasCameraConfig(deviceId) ? `pending:${this.deps.getPendingReason(deviceId) ?? "pending"}` : this.deps.isSessionCamera(deviceId) ? "armed:on-motion" : void 0,
50708
+ pinned,
50709
+ detectionReason: pipelineAssignment !== null ? pipelineAssignment.reason : this.deps.hasCameraConfig(deviceId) ? `pending:${pendingReason ?? "pending"}` : this.deps.isSessionCamera(deviceId) ? pendingReason !== void 0 ? `pending:${pendingReason}` : "armed:on-motion" : void 0,
49809
50710
  audioNodeId,
49810
50711
  audioPinned
49811
50712
  };
@@ -50661,48 +51562,6 @@ var DetectionBlindMonitor = class {
50661
51562
  }
50662
51563
  };
50663
51564
  //#endregion
50664
- //#region src/apply-device-provisioning.ts
50665
- /**
50666
- * Merge base then override for one step. Override keys win; undefined = inherit.
50667
- * `jumpDeviceKey` (step-tree device jump, phase 1) is a per-(node,device) BASE
50668
- * concept ONLY — the per-camera override never carries it (spec: per-camera jump
50669
- * is out of scope), so it is taken from `base` verbatim.
50670
- */
50671
- function resolveStep(base, override) {
50672
- if (!base && !override) return void 0;
50673
- const modelId = override?.modelId ?? base?.modelId;
50674
- const settings = base?.settings || override?.settings ? {
50675
- ...base?.settings,
50676
- ...override?.settings
50677
- } : void 0;
50678
- const jumpDeviceKey = base?.jumpDeviceKey;
50679
- return {
50680
- ...modelId !== void 0 ? { modelId } : {},
50681
- ...settings ? { settings } : {},
50682
- ...jumpDeviceKey !== void 0 ? { jumpDeviceKey } : {}
50683
- };
50684
- }
50685
- /**
50686
- * Attach modelId + settings onto each step from the per-(node,device) BASE and
50687
- * the per-camera-device OVERRIDE. Pure/immutable; recurses into children. Empty
50688
- * base+override ⇒ same reference.
50689
- */
50690
- function applyDeviceProvisioning(steps, base, override) {
50691
- if (Object.keys(base).length === 0 && Object.keys(override).length === 0) return steps;
50692
- return steps.map((step) => {
50693
- const cfg = resolveStep(base[step.addonId], override[step.addonId]);
50694
- const children = step.children ? applyDeviceProvisioning(step.children, base, override) : step.children;
50695
- if (!cfg && children === step.children) return step;
50696
- return {
50697
- ...step,
50698
- ...cfg?.modelId !== void 0 ? { modelId: cfg.modelId } : {},
50699
- ...cfg?.settings ? { settings: cfg.settings } : {},
50700
- ...cfg?.jumpDeviceKey !== void 0 ? { jumpDeviceKey: cfg.jumpDeviceKey } : {},
50701
- ...children !== void 0 ? { children } : {}
50702
- };
50703
- });
50704
- }
50705
- //#endregion
50706
51565
  //#region src/device-activity-source.ts
50707
51566
  /** The source's own name on the wire. One constant, used by every emit + gate. */
50708
51567
  var DEVICE_ACTIVITY_SOURCE = "device-activity";
@@ -51997,8 +52856,12 @@ var DetectionWiringController = class {
51997
52856
  }
51998
52857
  log.info("detection armed (session-time) — watching for motion", { meta: { motion: config.motionSources.join("+") } });
51999
52858
  } else {
52000
- const hydrateNodeId = this.deps.ledger.getAssignment(deviceId)?.agentNodeId ?? this.deps.localNodeId();
52001
- const pipelineConfig = await this.deps.settingsStore.resolvePipelineForDevice(deviceId);
52859
+ const placeholderNode = this.deps.ledger.getAssignment(deviceId)?.agentNodeId ?? this.deps.localNodeId();
52860
+ const placeholder = await this.deps.settingsStore.resolvePipelineForDeviceIfCatalogCached(deviceId, placeholderNode, void 0, []);
52861
+ const pipelineConfig = placeholder.kind === "resolved" ? placeholder.config : {
52862
+ steps: [],
52863
+ audio: null
52864
+ };
52002
52865
  const runnerConfig = buildRunnerConfig({
52003
52866
  deviceId,
52004
52867
  config,
@@ -52007,42 +52870,12 @@ var DetectionWiringController = class {
52007
52870
  steps: pipelineConfig.steps,
52008
52871
  audio: pipelineConfig.audio ?? null
52009
52872
  });
52010
- let dispatchedNodeId = null;
52011
52873
  try {
52012
- const result = await this.deps.placement.dispatchCamera(runnerConfig);
52013
- if (result.kind === "assigned") dispatchedNodeId = result.agentNodeId;
52874
+ await this.deps.placement.dispatchCamera(runnerConfig);
52014
52875
  } catch (err) {
52015
52876
  const msg = errMsg$1(err);
52016
52877
  log.error("dispatchCamera failed", { meta: { error: msg } });
52017
52878
  }
52018
- if (dispatchedNodeId && (dispatchedNodeId !== hydrateNodeId || (runnerConfig.steps?.length ?? 0) === 0)) try {
52019
- const dispatchedDeviceKey = this.deps.ledger.getConfig(deviceId)?.deviceKey;
52020
- const corrected = await this.deps.settingsStore.resolvePipelineForDevice(deviceId, dispatchedNodeId, dispatchedDeviceKey);
52021
- const correctedBase = await this.deps.settingsStore.buildNodeInferenceDeviceSteps(dispatchedNodeId, dispatchedDeviceKey ?? "");
52022
- const correctedOverride = dispatchedDeviceKey ? await this.deps.settingsStore.readCameraDeviceOverride(deviceId, dispatchedNodeId, dispatchedDeviceKey) : {};
52023
- const correctedConfig = {
52024
- ...runnerConfig,
52025
- ...dispatchedDeviceKey ? { deviceKey: dispatchedDeviceKey } : {},
52026
- steps: applyDeviceProvisioning(corrected.steps, correctedBase, correctedOverride),
52027
- audio: corrected.audio ?? null
52028
- };
52029
- if (JSON.stringify({
52030
- steps: runnerConfig.steps,
52031
- audio: runnerConfig.audio ?? null
52032
- }) !== JSON.stringify({
52033
- steps: correctedConfig.steps,
52034
- audio: correctedConfig.audio
52035
- })) {
52036
- await this.deps.attachOn(dispatchedNodeId, correctedConfig);
52037
- this.deps.ledger.setConfig(deviceId, correctedConfig);
52038
- log.info("startDetection: pipeline re-resolved after agent assignment", {
52039
- tags: { nodeId: dispatchedNodeId },
52040
- meta: { stepCount: corrected.steps.length }
52041
- });
52042
- }
52043
- } catch (err) {
52044
- log.warn("startDetection: post-dispatch re-resolve failed", { meta: { error: errMsg$1(err) } });
52045
- }
52046
52879
  }
52047
52880
  try {
52048
52881
  await this.deps.audio.resubscribe(deviceId, config);
@@ -52794,6 +53627,200 @@ async function migrateLegacyFlagsToBindings(deps) {
52794
53627
  } });
52795
53628
  }
52796
53629
  //#endregion
53630
+ //#region src/apply-device-provisioning.ts
53631
+ /**
53632
+ * Merge base then override for one step. Override keys win; undefined = inherit.
53633
+ * `jumpDeviceKey` (step-tree device jump, phase 1) is a per-(node,device) BASE
53634
+ * concept ONLY — the per-camera override never carries it (spec: per-camera jump
53635
+ * is out of scope), so it is taken from `base` verbatim.
53636
+ */
53637
+ function resolveStep(base, override) {
53638
+ if (!base && !override) return void 0;
53639
+ const modelId = override?.modelId ?? base?.modelId;
53640
+ const settings = base?.settings || override?.settings ? {
53641
+ ...base?.settings,
53642
+ ...override?.settings
53643
+ } : void 0;
53644
+ const jumpDeviceKey = base?.jumpDeviceKey;
53645
+ return {
53646
+ ...modelId !== void 0 ? { modelId } : {},
53647
+ ...settings ? { settings } : {},
53648
+ ...jumpDeviceKey !== void 0 ? { jumpDeviceKey } : {}
53649
+ };
53650
+ }
53651
+ /**
53652
+ * Attach modelId + settings onto each step from the per-(node,device) BASE and
53653
+ * the per-camera-device OVERRIDE. Pure/immutable; recurses into children. Empty
53654
+ * base+override ⇒ same reference.
53655
+ */
53656
+ function applyDeviceProvisioning(steps, base, override) {
53657
+ if (Object.keys(base).length === 0 && Object.keys(override).length === 0) return steps;
53658
+ return steps.map((step) => {
53659
+ const cfg = resolveStep(base[step.addonId], override[step.addonId]);
53660
+ const children = step.children ? applyDeviceProvisioning(step.children, base, override) : step.children;
53661
+ if (!cfg && children === step.children) return step;
53662
+ return {
53663
+ ...step,
53664
+ ...cfg?.modelId !== void 0 ? { modelId: cfg.modelId } : {},
53665
+ ...cfg?.settings ? { settings: cfg.settings } : {},
53666
+ ...cfg?.jumpDeviceKey !== void 0 ? { jumpDeviceKey: cfg.jumpDeviceKey } : {},
53667
+ ...children !== void 0 ? { children } : {}
53668
+ };
53669
+ });
53670
+ }
53671
+ /**
53672
+ * What each step of `steps` would run with had the camera been elected onto
53673
+ * the device whose `base` / `override` these are — as a flat
53674
+ * `stepId → { modelId?, settings? }` map (D650). It IS
53675
+ * {@link applyDeviceProvisioning} over the same resolved tree, flattened, so a
53676
+ * jump target and the elected device are provisioned by one rule, and a value
53677
+ * the resolved tree already carries (a wholesale `pipelineByAgent` snapshot)
53678
+ * survives exactly as it would on the elected device. The runner REPLACES a
53679
+ * jumped subtree's `modelId` / `settings` with these, so the elected device's
53680
+ * base and override never follow a step across. `jumpDeviceKey` is dropped: it
53681
+ * names where a step leaves the ELECTED device for, and means nothing on the
53682
+ * target. A step with neither field is absent (it runs the format default).
53683
+ */
53684
+ function provisionDeviceSteps(steps, base, override) {
53685
+ const out = {};
53686
+ const walk = (list) => {
53687
+ for (const step of list) {
53688
+ if (step.modelId !== void 0 || step.settings !== void 0) out[step.addonId] = {
53689
+ ...step.modelId !== void 0 ? { modelId: step.modelId } : {},
53690
+ ...step.settings !== void 0 ? { settings: step.settings } : {}
53691
+ };
53692
+ walk(step.children ?? []);
53693
+ }
53694
+ };
53695
+ walk(applyDeviceProvisioning(steps, base, override));
53696
+ return out;
53697
+ }
53698
+ //#endregion
53699
+ //#region src/jump-roster.ts
53700
+ /**
53701
+ * The roster (unprovisioned), or `undefined` when there is no jump to make: no
53702
+ * elected device, or fewer than two eligible devices on the node.
53703
+ */
53704
+ function buildJumpRoster(input) {
53705
+ if (!input.deviceKey || Object.keys(input.eligible).length < 2) return void 0;
53706
+ return buildInferenceDeviceRoster(input.eligible, input.caps);
53707
+ }
53708
+ /** The roster devices a detail step may jump TO — every entry except the elected one. */
53709
+ function jumpTargetKeys(roster, electedDeviceKey) {
53710
+ return (roster ?? []).map((d) => d.deviceKey).filter((k) => k !== electedDeviceKey);
53711
+ }
53712
+ //#endregion
53713
+ //#region src/attach-pipeline.ts
53714
+ /**
53715
+ * The steps whose ELECTED-device base carries a manual `jumpDeviceKey` while
53716
+ * the camera has an override for that step on the elected device (D650). A
53717
+ * jumped step runs the TARGET's provisioning, so that override does not
53718
+ * travel with it — the operator must be told, not left to wonder.
53719
+ */
53720
+ function manualJumpsLeavingOverride(base, override) {
53721
+ const out = [];
53722
+ for (const [stepId, cfg] of Object.entries(base)) {
53723
+ const jumpDeviceKey = cfg.jumpDeviceKey;
53724
+ if (jumpDeviceKey === void 0) continue;
53725
+ const own = override[stepId];
53726
+ if (own?.modelId === void 0 && own?.settings === void 0) continue;
53727
+ out.push({
53728
+ stepId,
53729
+ jumpDeviceKey
53730
+ });
53731
+ }
53732
+ return out;
53733
+ }
53734
+ /**
53735
+ * BLOCKING entry point: waits for the node's catalog (`resolvePipelineForDevice`
53736
+ * reconciles against the executor until it answers). For a FIRST placement —
53737
+ * a camera's first standing dispatch, an on-motion session, a re-attach —
53738
+ * where there is nothing better to attach than the real pipeline. See the
53739
+ * module header.
53740
+ */
53741
+ async function resolveAttachPipeline(input, store) {
53742
+ const roster = buildJumpRoster({
53743
+ deviceKey: input.deviceKey,
53744
+ eligible: input.eligible,
53745
+ caps: input.caps
53746
+ });
53747
+ const resolved = await store.resolvePipelineForDeviceWithRoots(input.deviceId, input.nodeId, input.deviceKey, jumpTargetKeys(roster, input.deviceKey));
53748
+ if (resolved.droppedRoots.length > 0) return rootRefusal(input, resolved.droppedRoots);
53749
+ return {
53750
+ kind: "resolved",
53751
+ pipeline: await provisionAttachPipeline(input, roster, resolved.config, store)
53752
+ };
53753
+ }
53754
+ /**
53755
+ * NON-BLOCKING entry point: reads the node's catalog from the cache only, and
53756
+ * answers `catalog-unavailable` instead of waiting (D650 round 2). For the
53757
+ * RETRY and re-place paths — a pending retry, a remote-health re-place, a
53758
+ * rebalance migration — which run cameras serially in one sweep: one node
53759
+ * whose executor never answers must leave ITS camera pending, not hold every
53760
+ * camera behind it (D3, D49).
53761
+ */
53762
+ async function resolveAttachPipelineIfCatalogCached(input, store) {
53763
+ const roster = buildJumpRoster({
53764
+ deviceKey: input.deviceKey,
53765
+ eligible: input.eligible,
53766
+ caps: input.caps
53767
+ });
53768
+ const outcome = await store.resolvePipelineForDeviceIfCatalogCached(input.deviceId, input.nodeId, input.deviceKey, jumpTargetKeys(roster, input.deviceKey));
53769
+ if (outcome.kind === "catalog-unavailable") return outcome;
53770
+ if (outcome.droppedRoots.length > 0) return rootRefusal(input, outcome.droppedRoots);
53771
+ return {
53772
+ kind: "resolved",
53773
+ pipeline: await provisionAttachPipeline(input, roster, outcome.config, store)
53774
+ };
53775
+ }
53776
+ function rootRefusal(input, droppedRoots) {
53777
+ return {
53778
+ kind: "root-refused",
53779
+ nodeId: input.nodeId,
53780
+ deviceKey: input.deviceKey,
53781
+ droppedRoots
53782
+ };
53783
+ }
53784
+ /** Steps 3 of the module header: provision the resolved structure. */
53785
+ async function provisionAttachPipeline(input, roster, resolved, store) {
53786
+ const { deviceId, nodeId, deviceKey } = input;
53787
+ const electedKey = deviceKey ?? "";
53788
+ const rosterKeys = (roster ?? []).map((d) => d.deviceKey);
53789
+ const sources = await store.readDeviceProvisioning(deviceId, nodeId, [...new Set([electedKey, ...rosterKeys])]);
53790
+ const elected = sources.get(electedKey);
53791
+ if (deviceKey && elected) {
53792
+ const left = manualJumpsLeavingOverride(elected.base, elected.override).filter((l) => rosterKeys.includes(l.jumpDeviceKey));
53793
+ if (left.length > 0) store.warnManualJumpsLeaveOverride(deviceId, nodeId, deviceKey, left);
53794
+ }
53795
+ const steps = applyDeviceProvisioning(resolved.steps, elected?.base ?? {}, elected?.override ?? {});
53796
+ const inferenceDevices = roster?.map((entry) => {
53797
+ const src = sources.get(entry.deviceKey);
53798
+ return {
53799
+ ...entry,
53800
+ steps: provisionDeviceSteps(resolved.steps, src?.base ?? {}, src?.override ?? {})
53801
+ };
53802
+ });
53803
+ return {
53804
+ steps,
53805
+ audio: resolved.audio ?? null,
53806
+ ...deviceKey ? { deviceKey } : {},
53807
+ ...inferenceDevices ? { inferenceDevices } : {}
53808
+ };
53809
+ }
53810
+ /**
53811
+ * `config` with its pipeline REPLACED by `pipeline`. The four pipeline fields
53812
+ * are removed first, so a previous attach's `deviceKey` / roster never survives
53813
+ * a spread when the new pipeline has none (a migration to a single-device node,
53814
+ * a re-dispatch from a ledger config).
53815
+ */
53816
+ function withAttachPipeline(config, pipeline) {
53817
+ const { deviceKey: _deviceKey, inferenceDevices: _inferenceDevices, steps: _steps, audio: _audio, ...rest } = config;
53818
+ return {
53819
+ ...rest,
53820
+ ...pipeline
53821
+ };
53822
+ }
53823
+ //#endregion
52797
53824
  //#region src/dispatch-reconcile.ts
52798
53825
  function runnerAttachmentKey(nodeId, deviceId) {
52799
53826
  return `${nodeId}:${deviceId}`;
@@ -53084,6 +54111,32 @@ function selectRunnerFrameSource(input) {
53084
54111
  //#region src/placement-service.ts
53085
54112
  /** Key under which the orchestrator stores its per-device manual pin — shared with `readPipelinePin` (index.ts) and `handleNodeDisconnect` (`node-lifecycle-handler.ts`). */
53086
54113
  var PREFERRED_AGENT_SETTING = "preferredAgent";
54114
+ /**
54115
+ * A load snapshot with this rebalance pass's own (re)assignments applied — at
54116
+ * node level, and per device so a device election in the same pass sees the
54117
+ * sessions the pass already gave each accelerator.
54118
+ */
54119
+ function tallyLoad(load, nodeDelta, perDevice) {
54120
+ if (nodeDelta === 0 && (perDevice === void 0 || perDevice.size === 0)) return load;
54121
+ const known = new Set(load.devices.map((d) => d.deviceKey));
54122
+ const devices = [...load.devices.map((d) => {
54123
+ const delta = perDevice?.get(d.deviceKey) ?? 0;
54124
+ return delta === 0 ? d : {
54125
+ ...d,
54126
+ attachedCameras: Math.max(0, d.attachedCameras + delta)
54127
+ };
54128
+ }), ...[...perDevice ?? /* @__PURE__ */ new Map()].filter(([deviceKey, delta]) => !known.has(deviceKey) && delta > 0).map(([deviceKey, delta]) => ({
54129
+ deviceKey,
54130
+ backend: deviceKey.split(":")[0] ?? deviceKey,
54131
+ attachedCameras: delta,
54132
+ queueDepthTotal: 0
54133
+ }))];
54134
+ return {
54135
+ ...load,
54136
+ attachedCameras: Math.max(0, load.attachedCameras + nodeDelta),
54137
+ devices
54138
+ };
54139
+ }
53087
54140
  var PlacementService = class {
53088
54141
  deps;
53089
54142
  /** True while an auto-rebalance pass is running (skip overlap). */
@@ -53121,7 +54174,116 @@ var PlacementService = class {
53121
54174
  });
53122
54175
  return usable;
53123
54176
  }
54177
+ /**
54178
+ * Pick which of `nodeId`'s enabled inference devices a camera runs on, and
54179
+ * log why (`inference device selection` — one line per placement, the same
54180
+ * message as the on-motion path so one grep covers both). Returns the
54181
+ * eligibility inputs too: the attach pipeline is resolved against the SAME
54182
+ * roster the device was elected from.
54183
+ */
54184
+ async electInferenceDevice(input) {
54185
+ const targetLoad = input.loads.find((load) => load.nodeId === input.nodeId);
54186
+ let eligible;
54187
+ if (input.read.kind === "await-catalog") eligible = await this.deps.getEligibleInferenceDevices(input.nodeId);
54188
+ else {
54189
+ const known = await this.deps.getEligibleInferenceDevicesIfCached(input.nodeId);
54190
+ if (known.kind === "unavailable") return known;
54191
+ eligible = known.eligible;
54192
+ }
54193
+ const caps = await this.deps.getInferenceDeviceCaps(input.nodeId);
54194
+ const pinnedDeviceKey = await this.deps.readPipelineDevicePin(input.deviceId);
54195
+ const selection = pickInferenceDevice({
54196
+ eligible,
54197
+ loads: targetLoad?.devices ?? [],
54198
+ caps,
54199
+ pinnedDeviceKey,
54200
+ currentDeviceKey: input.currentDeviceKey,
54201
+ rotation: this.deps.inferenceRotation
54202
+ });
54203
+ const deviceKey = selectedDeviceKey(selection);
54204
+ this.deps.ctx().logger.info("inference device selection", {
54205
+ tags: {
54206
+ deviceId: input.deviceId,
54207
+ nodeId: input.nodeId
54208
+ },
54209
+ meta: {
54210
+ path: input.path,
54211
+ eligible: selection.eligibleKeys,
54212
+ chosen: deviceKey ?? null,
54213
+ reason: selection.reason,
54214
+ ...selection.kind === "selected" ? {
54215
+ tied: selection.tiedKeys,
54216
+ tieBreak: selection.tieBreak
54217
+ } : {},
54218
+ ...pinnedDeviceKey ? { pin: pinnedDeviceKey } : {}
54219
+ }
54220
+ });
54221
+ return {
54222
+ kind: "elected",
54223
+ deviceKey,
54224
+ eligible,
54225
+ caps
54226
+ };
54227
+ }
54228
+ /**
54229
+ * Elect a device on `nodeId` and resolve the camera's attach pipeline for
54230
+ * it — the whole placement decision, for a first placement (`await-catalog`)
54231
+ * or a re-place (`cached-catalog`, never waits). Nothing is attached here.
54232
+ */
54233
+ async resolvePlacement(input) {
54234
+ const election = await this.electInferenceDevice(input);
54235
+ if (election.kind === "unavailable") return election;
54236
+ const attachInput = {
54237
+ deviceId: input.deviceId,
54238
+ nodeId: input.nodeId,
54239
+ deviceKey: election.deviceKey,
54240
+ eligible: election.eligible,
54241
+ caps: election.caps
54242
+ };
54243
+ if (input.read.kind === "await-catalog") return resolveAttachPipeline(attachInput, this.deps.settingsStore);
54244
+ const outcome = await resolveAttachPipelineIfCatalogCached(attachInput, this.deps.settingsStore);
54245
+ if (outcome.kind === "catalog-unavailable") return {
54246
+ kind: "unavailable",
54247
+ nodeId: outcome.nodeId,
54248
+ what: "catalog"
54249
+ };
54250
+ return outcome;
54251
+ }
54252
+ /**
54253
+ * Re-resolve a camera's attach pipeline on the device it is ALREADY
54254
+ * elected onto (`deviceKey` from its ledger config; absent ⇒ default pool)
54255
+ * — the entry for every path that replaces the steps of a live attach
54256
+ * without re-electing (executor readiness, a package-zone change). Goes
54257
+ * through the same {@link resolveAttachPipeline} as a dispatch (D650).
54258
+ */
54259
+ async resolveAttachPipelineOnDevice(input) {
54260
+ const [eligible, caps] = await Promise.all([this.deps.getEligibleInferenceDevices(input.nodeId), this.deps.getInferenceDeviceCaps(input.nodeId)]);
54261
+ return resolveAttachPipeline({
54262
+ ...input,
54263
+ eligible,
54264
+ caps
54265
+ }, this.deps.settingsStore);
54266
+ }
54267
+ /**
54268
+ * FIRST placement of a standing camera: waits for the chosen node's catalog
54269
+ * (the resolve reconciles against its executor until it answers). Callers:
54270
+ * `startDetection`'s standing path — one camera, nothing to attach instead.
54271
+ */
53124
54272
  async dispatchCamera(runnerConfig) {
54273
+ return this.placeCamera(runnerConfig, { kind: "await-catalog" });
54274
+ }
54275
+ /**
54276
+ * RE-placement from the ledger — the pending-retry sweep and the
54277
+ * remote-health re-place (D650 round 2). NEVER waits for a node: the
54278
+ * catalog is read from the cache only, and a node that has not answered
54279
+ * leaves THIS camera pending (`catalog-unavailable:<nodeId>`) for the next
54280
+ * round, instead of holding every camera after it in a serial sweep behind
54281
+ * one wedged executor (D3, D49).
54282
+ */
54283
+ async redispatchCamera(runnerConfig) {
54284
+ return this.placeCamera(runnerConfig, { kind: "cached-catalog" });
54285
+ }
54286
+ async placeCamera(runnerConfig, catalog) {
53125
54287
  if (!this.deps.ctx()) throw new Error("PipelineOrchestrator: dispatchCamera called before initialize");
53126
54288
  if (this.deps.maintenancePaused?.() === true) return {
53127
54289
  success: true,
@@ -53174,45 +54336,30 @@ var PlacementService = class {
53174
54336
  reason = decision.reason;
53175
54337
  dispatchScore = decision.score;
53176
54338
  }
53177
- const targetLoad = loads.find((load) => load.nodeId === targetNodeId);
53178
- const eligibleDevices = await this.deps.getEligibleInferenceDevices(targetNodeId);
53179
- const deviceCaps = await this.deps.getInferenceDeviceCaps(targetNodeId);
53180
- const pinnedDeviceKey = await this.deps.readPipelineDevicePin(runnerConfig.deviceId);
53181
- const selection = pickInferenceDevice({
53182
- eligible: eligibleDevices,
53183
- loads: targetLoad?.devices ?? [],
53184
- caps: deviceCaps,
53185
- pinnedDeviceKey,
54339
+ const placement = await this.resolvePlacement({
54340
+ deviceId: runnerConfig.deviceId,
54341
+ nodeId: targetNodeId,
54342
+ loads,
53186
54343
  currentDeviceKey,
53187
- rotation: this.deps.inferenceRotation
54344
+ path: "standing",
54345
+ read: catalog
53188
54346
  });
53189
- const deviceKey = selectedDeviceKey(selection);
53190
- this.deps.ctx().logger.info("inference device selection", {
53191
- tags: {
53192
- deviceId: runnerConfig.deviceId,
53193
- nodeId: targetNodeId
53194
- },
53195
- meta: {
53196
- path: "standing",
53197
- eligible: selection.eligibleKeys,
53198
- chosen: deviceKey ?? null,
53199
- reason: selection.reason,
53200
- ...selection.kind === "selected" ? {
53201
- tied: selection.tiedKeys,
53202
- tieBreak: selection.tieBreak
53203
- } : {},
53204
- ...pinnedDeviceKey ? { pin: pinnedDeviceKey } : {}
53205
- }
53206
- });
53207
- const deviceBase = await this.deps.settingsStore.buildNodeInferenceDeviceSteps(targetNodeId, deviceKey ?? "");
53208
- const deviceOverride = deviceKey ? await this.deps.settingsStore.readCameraDeviceOverride(runnerConfig.deviceId, targetNodeId, deviceKey) : {};
53209
- const inferenceDevices = deviceKey && Object.keys(eligibleDevices).length >= 2 ? buildInferenceDeviceRoster(eligibleDevices, deviceCaps) : void 0;
53210
- const attachConfig = {
53211
- ...runnerConfig,
53212
- ...deviceKey ? { deviceKey } : {},
53213
- ...inferenceDevices ? { inferenceDevices } : {},
53214
- ...runnerConfig.steps ? { steps: applyDeviceProvisioning(runnerConfig.steps, deviceBase, deviceOverride) } : {}
53215
- };
54347
+ if (placement.kind === "unavailable") {
54348
+ this.leavePendingUnanswered(runnerConfig.deviceId, placement.nodeId, placement.what);
54349
+ return {
54350
+ success: true,
54351
+ kind: "pending"
54352
+ };
54353
+ }
54354
+ if (placement.kind === "root-refused") {
54355
+ this.leavePendingRootRefused(runnerConfig.deviceId, placement);
54356
+ return {
54357
+ success: true,
54358
+ kind: "pending"
54359
+ };
54360
+ }
54361
+ const pipeline = placement.pipeline;
54362
+ const attachConfig = withAttachPipeline(runnerConfig, pipeline);
53216
54363
  const dispatchGuard = this.deps.ledger.markDispatching(runnerConfig.deviceId);
53217
54364
  try {
53218
54365
  await this.attachOn(targetNodeId, attachConfig);
@@ -53435,18 +54582,17 @@ var PlacementService = class {
53435
54582
  const nodeCaps = await this.deps.settingsStore.buildNodeCaps();
53436
54583
  const nodeWeights = await this.deps.settingsStore.buildNodeWeights();
53437
54584
  const attachedDelta = /* @__PURE__ */ new Map();
53438
- const bumpAttached = (nodeId, by) => {
54585
+ const deviceDelta = /* @__PURE__ */ new Map();
54586
+ const bumpAttached = (nodeId, by, deviceKey) => {
53439
54587
  attachedDelta.set(nodeId, (attachedDelta.get(nodeId) ?? 0) + by);
54588
+ if (deviceKey === void 0) return;
54589
+ const perDevice = deviceDelta.get(nodeId) ?? /* @__PURE__ */ new Map();
54590
+ perDevice.set(deviceKey, (perDevice.get(deviceKey) ?? 0) + by);
54591
+ deviceDelta.set(nodeId, perDevice);
53440
54592
  };
53441
- const talliedLoads = () => loads.map((load) => {
53442
- const delta = attachedDelta.get(load.nodeId) ?? 0;
53443
- if (delta === 0) return load;
53444
- return {
53445
- ...load,
53446
- attachedCameras: Math.max(0, load.attachedCameras + delta)
53447
- };
53448
- });
54593
+ const talliedLoads = () => loads.map((load) => tallyLoad(load, attachedDelta.get(load.nodeId) ?? 0, deviceDelta.get(load.nodeId)));
53449
54594
  let migrated = 0;
54595
+ const skipped = [];
53450
54596
  for (const [deviceId, config] of this.deps.ledger.configEntries()) {
53451
54597
  if (this.deps.isIdleSessionCamera(deviceId)) continue;
53452
54598
  const current = this.deps.ledger.getAssignment(deviceId);
@@ -53471,6 +54617,28 @@ var PlacementService = class {
53471
54617
  const targetLoad = tallied.find((l) => l.nodeId === decision.agentNodeId);
53472
54618
  if (currentLoad && targetLoad && capacityImprovement(currentLoad, targetLoad, nodeWeights) < minImprovement) continue;
53473
54619
  }
54620
+ const placement = await this.resolvePlacement({
54621
+ deviceId,
54622
+ nodeId: decision.agentNodeId,
54623
+ loads: talliedLoads(),
54624
+ currentDeviceKey: null,
54625
+ path: "rebalance",
54626
+ read: { kind: "cached-catalog" }
54627
+ });
54628
+ if (placement.kind !== "resolved") {
54629
+ const reason = placement.kind === "root-refused" ? `root-unrunnable:${placement.nodeId}/${placement.deviceKey ?? "default-pool"}` : `${placement.what}-unavailable:${placement.nodeId}`;
54630
+ skipped.push({
54631
+ deviceId,
54632
+ targetNodeId: decision.agentNodeId,
54633
+ reason
54634
+ });
54635
+ if (current) this.noteMigrationSkipped(deviceId, current.agentNodeId, decision.agentNodeId, reason);
54636
+ else if (placement.kind === "root-refused") this.leavePendingRootRefused(deviceId, placement);
54637
+ else this.leavePendingUnanswered(deviceId, placement.nodeId, placement.what);
54638
+ continue;
54639
+ }
54640
+ this.migrationSkips.delete(deviceId);
54641
+ const targetConfig = withAttachPipeline(config, placement.pipeline);
53474
54642
  if (current) {
53475
54643
  await this.detachOn(current.agentNodeId, deviceId).catch((err) => {
53476
54644
  const msg = errMsg$1(err);
@@ -53479,19 +54647,13 @@ var PlacementService = class {
53479
54647
  meta: { error: msg }
53480
54648
  });
53481
54649
  });
53482
- bumpAttached(current.agentNodeId, -1);
54650
+ bumpAttached(current.agentNodeId, -1, config.deviceKey);
53483
54651
  }
53484
- const reResolved = await this.deps.settingsStore.resolvePipelineForDevice(deviceId, decision.agentNodeId);
53485
- const targetConfig = reResolved.steps.length > 0 ? {
53486
- ...config,
53487
- steps: reResolved.steps,
53488
- audio: reResolved.audio ?? null
53489
- } : config;
53490
54652
  this.deps.ledger.setConfig(deviceId, targetConfig);
53491
54653
  const dispatchGuard = this.deps.ledger.markDispatching(deviceId);
53492
54654
  try {
53493
54655
  await this.attachOn(decision.agentNodeId, targetConfig);
53494
- bumpAttached(decision.agentNodeId, 1);
54656
+ bumpAttached(decision.agentNodeId, 1, targetConfig.deviceKey);
53495
54657
  this.deps.ledger.recordAssignment(deviceId, decision.agentNodeId, "rebalance", false);
53496
54658
  } finally {
53497
54659
  dispatchGuard.dispose();
@@ -53499,7 +54661,10 @@ var PlacementService = class {
53499
54661
  migrated++;
53500
54662
  }
53501
54663
  ctx.logger.info("rebalance: migrated cameras", { meta: { migrated } });
53502
- return { migrated };
54664
+ return {
54665
+ migrated,
54666
+ skipped
54667
+ };
53503
54668
  }
53504
54669
  getPipelineAssignments() {
53505
54670
  return this.deps.ledger.listAssignments();
@@ -53678,6 +54843,71 @@ var PlacementService = class {
53678
54843
  };
53679
54844
  }
53680
54845
  /**
54846
+ * A re-place found the node it chose has never answered what the placement
54847
+ * needs (its catalog, or its device probe + pool health): the camera stays
54848
+ * pending with the node NAMED, and the line fires once per change of pending
54849
+ * state — a sweep re-finding the same unanswering node every tick is not
54850
+ * news, a camera newly stuck on it (or on another node) is.
54851
+ */
54852
+ leavePendingUnanswered(deviceId, nodeId, what) {
54853
+ const reason = what === "catalog" ? `catalog-unavailable:${nodeId}` : `device-health-unavailable:${nodeId}`;
54854
+ if (!this.setPendingIfChanged(deviceId, reason)) return;
54855
+ this.deps.ctx().logger.warn("camera left pending — the chosen node has not answered", {
54856
+ tags: {
54857
+ deviceId,
54858
+ nodeId
54859
+ },
54860
+ meta: {
54861
+ reason,
54862
+ hint: "a re-place never waits for a node; the next retry round places it once the node answers"
54863
+ }
54864
+ });
54865
+ }
54866
+ /**
54867
+ * The NAMED refusal of a root-less attach (D650 round 3): the elected
54868
+ * device cannot run a gated-on root of the camera, so the attach would
54869
+ * detect nothing and — once its assignment were recorded — nothing would
54870
+ * re-place it. The camera stays pending instead, once-per-change logged.
54871
+ */
54872
+ leavePendingRootRefused(deviceId, refusal) {
54873
+ const reason = `root-unrunnable:${refusal.nodeId}/${refusal.deviceKey ?? "default-pool"}`;
54874
+ if (!this.setPendingIfChanged(deviceId, reason)) return;
54875
+ this.deps.ctx().logger.error("attach refused — the elected device cannot run the camera root; camera left pending", {
54876
+ tags: {
54877
+ deviceId,
54878
+ nodeId: refusal.nodeId
54879
+ },
54880
+ meta: {
54881
+ reason,
54882
+ deviceKey: refusal.deviceKey ?? null,
54883
+ droppedRoots: refusal.droppedRoots.join(","),
54884
+ hint: "the root is never jumped: enable a device on this node whose format ships the root model, or disable the device that cannot"
54885
+ }
54886
+ });
54887
+ }
54888
+ /** Last migration-skip reason per camera — the info line fires once per change. */
54889
+ migrationSkips = /* @__PURE__ */ new Map();
54890
+ noteMigrationSkipped(deviceId, sourceNodeId, targetNodeId, reason) {
54891
+ if (this.migrationSkips.get(deviceId) === reason) return;
54892
+ this.migrationSkips.set(deviceId, reason);
54893
+ this.deps.ctx().logger.info("rebalance: migration skipped — camera stays on its source", {
54894
+ tags: {
54895
+ deviceId,
54896
+ nodeId: targetNodeId
54897
+ },
54898
+ meta: {
54899
+ sourceNodeId,
54900
+ reason
54901
+ }
54902
+ });
54903
+ }
54904
+ /** Record `reason`; `true` when it differs from the camera's previous pending reason. */
54905
+ setPendingIfChanged(deviceId, reason) {
54906
+ const prior = this.deps.ledger.getPendingReason(deviceId);
54907
+ this.deps.ledger.setPendingReason(deviceId, reason);
54908
+ return prior !== reason;
54909
+ }
54910
+ /**
53681
54911
  * Normalizes the `decision.kind === 'pending'` → `setPendingReason` +
53682
54912
  * `warn` half shared by `dispatchCamera`/`unassignPipeline`/`rebalance`
53683
54913
  * (smell #12). Each call site keeps its own bail (return / fall-through /
@@ -53934,13 +55164,16 @@ var NodeLifecycleHandler = class {
53934
55164
  for (const deviceId of candidates) {
53935
55165
  const cached = this.deps.ledger.getConfig(deviceId);
53936
55166
  if (!cached) continue;
53937
- const resolved = await this.deps.settingsStore.resolvePipelineForDevice(deviceId, nodeId);
53938
- if (resolved.steps.length === 0) continue;
53939
- const next = {
53940
- ...cached,
53941
- steps: resolved.steps,
53942
- audio: resolved.audio ?? null
53943
- };
55167
+ if (this.isUnplacedPlaceholder(deviceId, cached, nodeId)) continue;
55168
+ const outcome = await this.deps.resolveAttachPipeline({
55169
+ deviceId,
55170
+ nodeId,
55171
+ deviceKey: cached.deviceKey
55172
+ });
55173
+ if (this.refusedReattach(deviceId, nodeId, outcome)) continue;
55174
+ const pipeline = outcome.pipeline;
55175
+ if (pipeline.steps.length === 0) continue;
55176
+ const next = withAttachPipeline(cached, pipeline);
53944
55177
  this.deps.ledger.setConfig(deviceId, next);
53945
55178
  affected.push({
53946
55179
  deviceId,
@@ -53993,6 +55226,43 @@ var NodeLifecycleHandler = class {
53993
55226
  }
53994
55227
  }
53995
55228
  /**
55229
+ * A re-attach whose elected device cannot run the camera's root is REFUSED
55230
+ * (D650 round 3): the live attach is left as it is rather than replaced by
55231
+ * one that detects nothing. Said once per pass, with the roots.
55232
+ */
55233
+ refusedReattach(deviceId, nodeId, outcome) {
55234
+ if (outcome.kind !== "root-refused") return false;
55235
+ this.deps.logger.error("re-attach refused — the elected device cannot run the camera root", {
55236
+ tags: {
55237
+ deviceId,
55238
+ nodeId
55239
+ },
55240
+ meta: {
55241
+ deviceKey: outcome.deviceKey ?? null,
55242
+ droppedRoots: outcome.droppedRoots.join(",")
55243
+ }
55244
+ });
55245
+ return true;
55246
+ }
55247
+ /**
55248
+ * A ledger config with no assignment AND no elected device is the standing
55249
+ * path's PLACEHOLDER (D650 round 2): a camera the balancer has not placed,
55250
+ * hydrated before any election. Attaching it here would place it on this
55251
+ * node behind the balancer's back, on no device — the pending-retry sweep
55252
+ * owns it. Skipped, and said so.
55253
+ */
55254
+ isUnplacedPlaceholder(deviceId, cached, nodeId) {
55255
+ if (this.deps.ledger.hasAssignment(deviceId) || cached.deviceKey !== void 0) return false;
55256
+ this.deps.logger.info("readiness: skipped an unplaced placeholder config", {
55257
+ tags: {
55258
+ deviceId,
55259
+ nodeId
55260
+ },
55261
+ meta: { reason: "no assignment and no elected device — the pending-retry sweep places it" }
55262
+ });
55263
+ return true;
55264
+ }
55265
+ /**
53996
55266
  * Walk every cached RunnerCameraConfig and re-attach any whose stored
53997
55267
  * `steps` are empty AND whose latest resolve produces a non-empty
53998
55268
  * pipeline. Catches the "camera dispatched mid-readiness" race where
@@ -54003,13 +55273,16 @@ var NodeLifecycleHandler = class {
54003
55273
  for (const [deviceId, cached] of this.deps.ledger.configEntries()) {
54004
55274
  if ((cached.steps?.length ?? 0) > 0) continue;
54005
55275
  if ((this.deps.ledger.getAssignment(deviceId)?.agentNodeId ?? this.deps.localNodeId()) !== nodeId) continue;
54006
- const resolved = await this.deps.settingsStore.resolvePipelineForDevice(deviceId, nodeId);
54007
- if (resolved.steps.length === 0) continue;
54008
- const next = {
54009
- ...cached,
54010
- steps: resolved.steps,
54011
- audio: resolved.audio ?? null
54012
- };
55276
+ if (this.isUnplacedPlaceholder(deviceId, cached, nodeId)) continue;
55277
+ const outcome = await this.deps.resolveAttachPipeline({
55278
+ deviceId,
55279
+ nodeId,
55280
+ deviceKey: cached.deviceKey
55281
+ });
55282
+ if (this.refusedReattach(deviceId, nodeId, outcome)) continue;
55283
+ const pipeline = outcome.pipeline;
55284
+ if (pipeline.steps.length === 0) continue;
55285
+ const next = withAttachPipeline(cached, pipeline);
54013
55286
  this.deps.ledger.setConfig(deviceId, next);
54014
55287
  try {
54015
55288
  await this.deps.attachOn(nodeId, next);
@@ -55392,9 +56665,11 @@ function wireOrchestratorSubscriptions(deps) {
55392
56665
  });
55393
56666
  const unsubFrameTracked = deps.eventBus.subscribe({ category: EventCategory.PipelineAnalyticsFrameTracked }, (event) => {
55394
56667
  if (!isEvent(event, EventCategory.PipelineAnalyticsFrameTracked)) return;
55395
- if (event.data.hasLiveTrack !== true) return;
55396
56668
  const deviceId = event.data.deviceId;
55397
56669
  if (typeof deviceId !== "number") return;
56670
+ const liveTrackCount = event.data.liveTrackCount;
56671
+ if (typeof liveTrackCount === "number") deps.noteSessionTrackedFrame(deviceId, liveTrackCount);
56672
+ if (event.data.hasLiveTrack !== true) return;
55398
56673
  deps.noteSessionSubjectPresent(deviceId);
55399
56674
  });
55400
56675
  const unsubMotionAnalysis = deps.eventBus.subscribe({ category: EventCategory.MotionAnalysis }, (event) => {
@@ -55536,303 +56811,54 @@ var PackageZoneGate = class {
55536
56811
  }
55537
56812
  };
55538
56813
  //#endregion
55539
- //#region src/pipeline-resolver.ts
55540
- /**
55541
- * The `package-detection` root step id. Zone-driven activation
55542
- * ({@link applyPackageZoneActivation}) is keyed on it — the step has NO
55543
- * user-facing toggle; a drawn package zone is the sole enable signal.
55544
- */
55545
- var PACKAGE_DETECTION_STEP_ID = "package-detection";
55546
- /**
55547
- * Zone-driven activation for the toggle-less `package-detection` root step.
55548
- *
55549
- * The step runs for a device IFF it has ≥1 package zone (an enabled
55550
- * `package`-stage zone rule referencing geometry). There is deliberately NO
55551
- * operator toggle — drawing a package zone IS the enable, removing every
55552
- * package zone IS the disable. This gate reconciles the resolved step tree to
55553
- * that rule:
55554
- *
55555
- * - `hasPackageZone` true → the step is ensured present as an ENABLED root
55556
- * (a pre-existing node keeps its `modelId` / `settings` — e.g. a per-node
55557
- * model choice); default-on where a zone is present.
55558
- * - `hasPackageZone` false → any `package-detection` node is stripped so the
55559
- * runner never dispatches it.
55560
- *
55561
- * `package-detection` is a ROOT (`inputClasses: null`) so it only ever appears
55562
- * at the top level — no recursion into children needed. Every other step is
55563
- * left untouched. Immutable: returns a fresh array only when a change applies.
55564
- */
55565
- function applyPackageZoneActivation(steps, hasPackageZone) {
55566
- const existing = steps.find((s) => s.addonId === PACKAGE_DETECTION_STEP_ID);
55567
- if (!hasPackageZone) {
55568
- if (!existing) return steps;
55569
- return steps.filter((s) => s.addonId !== PACKAGE_DETECTION_STEP_ID);
55570
- }
55571
- if (existing) {
55572
- if (existing.enabled === true) return steps;
55573
- return steps.map((s) => s.addonId === "package-detection" ? {
55574
- ...s,
55575
- enabled: true
55576
- } : s);
55577
- }
55578
- const node = {
55579
- addonId: PACKAGE_DETECTION_STEP_ID,
55580
- enabled: true,
55581
- children: []
55582
- };
55583
- return [...steps, node];
55584
- }
55585
- /**
55586
- * Steps that are OFF by the per-camera gate DEFAULT (i.e. when the camera's
55587
- * `stepToggles[addonId]` is unset). Mirrors each step's catalog
55588
- * `enabledByDefault: false` — the heavy / opt-in classifiers + segmentation.
55589
- *
55590
- * The pure resolver derives the default gate from the LIVE catalog
55591
- * (`addon.enabledByDefault ?? true`), so it stays correct if a future
55592
- * default-off step ships. This literal set is the catalog-FREE fallback used
55593
- * by the one-shot gating migration ({@link migrateStepGating}), which runs at
55594
- * boot with no guaranteed live executor catalog. The two agree for every
55595
- * step that exists today.
55596
- *
55597
- * `package-detection` is deliberately NOT listed — it is ZONE-driven
55598
- * ({@link applyPackageZoneActivation}) and never governed by the gate.
55599
- */
55600
- var DEFAULT_OFF_STEP_IDS = new Set([
55601
- "animal-classifier",
55602
- "bird-classifier",
55603
- "vehicle-classifier",
55604
- "segmentation"
55605
- ]);
55606
- /**
55607
- * Catalog-free default gate for `addonId` — `true` unless the step is in
55608
- * {@link DEFAULT_OFF_STEP_IDS}. Used by the migration (no live catalog); the
55609
- * resolver prefers the catalog's own `enabledByDefault ?? true`.
55610
- */
55611
- function defaultStepGate(addonId) {
55612
- return !DEFAULT_OFF_STEP_IDS.has(addonId);
55613
- }
55614
- /**
55615
- * PROVISIONING presence for `(addon, format)` — the node "provides" the step
55616
- * iff the catalog lists at least one model with a build for the node's engine
55617
- * format. Byte-for-byte the same test the admin-ui matrices use
55618
- * (`addon.models.some(m => Boolean(m.formats[fmt]))`). An absent `modelId`
55619
- * on the resolved step is still "Auto" (the node resolves its own format
55620
- * default) — provisioning does NOT require an operator-pinned model, only a
55621
- * compatible model to EXIST for the format.
55622
- */
55623
- function addonHasCompatibleModel(addon, format) {
55624
- return (addon.models ?? []).some((m) => Boolean(m.formats[format]));
55625
- }
55626
- /**
55627
- * Device-aware resolver — the SINGLE per-camera gate + per-DEVICE skip check.
55628
- *
55629
- * Step `S` runs for camera `C` on device `D` ⇔
55630
- * gate(C, S) — `stepToggles[S]` ?? `addon.enabledByDefault ?? true`
55631
- * AND provided(D, S) — the catalog lists a model with a build for D's FORMAT
55632
- *
55633
- * Enablement is the per-camera `stepToggles` authority ONLY. Provisioning
55634
- * (`modelId` / `settings`) is NOT applied here — the returned tree carries bare
55635
- * `{ addonId, enabled: true, children }` nodes; {@link applyDeviceProvisioning}
55636
- * attaches model + settings at dispatch from the per-(node,device) base + the
55637
- * per-camera-device override.
55638
- *
55639
- * A step gated ON but with NO compatible model for the DEVICE's format is
55640
- * DROPPED and recorded in `skipped` so the caller can surface a visible "gated
55641
- * on, no model on this device" status at dispatch time.
55642
- *
55643
- * `package-detection` is excluded here — it is zone-driven
55644
- * ({@link applyPackageZoneActivation}, applied by the caller).
55645
- *
55646
- * "Inference disabled for this camera" is NOT handled here — the caller
55647
- * (resolvePipelineForDevice) consults the detection-pipeline binding and
55648
- * short-circuits to an empty pipeline before invoking this resolver.
55649
- */
55650
- function resolvePipelineWithSkips(cameraSettings, agentNodeId, deviceFormat, catalog) {
55651
- const cam = cameraSettings ?? {};
55652
- const wholesale = cam.pipelineByAgent?.[agentNodeId];
55653
- if (wholesale) return {
55654
- config: {
55655
- steps: mergeEnabledByDefaultSteps(wholesale.steps, catalog),
55656
- audio: wholesale.audio ? {
55657
- modelId: wholesale.audio.modelId,
55658
- enabled: wholesale.audio.enabled
55659
- } : null
55660
- },
55661
- skipped: []
55662
- };
55663
- const toggles = cam.stepToggles ?? {};
55664
- const format = deviceFormat;
55665
- const enabled = [];
55666
- const skipped = [];
55667
- for (const slot of catalog.slots) for (const addon of slot.addons) {
55668
- const addonId = addon.id;
55669
- if (addonId === "package-detection") continue;
55670
- if (!(toggles[addonId] ?? addon.enabledByDefault ?? true)) continue;
55671
- if (!addonHasCompatibleModel(addon, format)) {
55672
- skipped.push({
55673
- addonId,
55674
- reason: "no-compatible-model"
55675
- });
55676
- continue;
55677
- }
55678
- enabled.push({ addonId });
55679
- }
55680
- const audioAddons = [];
55681
- const videoAddons = [];
55682
- for (const entry of enabled) {
55683
- const meta = findInCatalog(catalog, entry.addonId);
55684
- if (!meta) continue;
55685
- if (meta.slot === "audio-classifier") audioAddons.push(entry);
55686
- else videoAddons.push(entry);
55687
- }
55688
- return {
55689
- config: {
55690
- steps: buildTreeFromAddons(videoAddons, catalog),
55691
- audio: audioAddons.length > 0 ? { enabled: true } : null
55692
- },
55693
- skipped
55694
- };
55695
- }
55696
- function findInCatalog(catalog, addonId) {
55697
- for (const slot of catalog.slots) for (const addon of slot.addons) if (addon.id === addonId) return addon;
55698
- return null;
55699
- }
55700
- /**
55701
- * Compose a step tree from a flat list of enabled video addons using
55702
- * catalog compatibility: an addon whose `inputClasses` overlaps a
55703
- * candidate parent's `outputClasses` attaches as the parent's child.
55704
- * Addons with `inputClasses` null/empty become roots.
55705
- *
55706
- * Multiple addons in the same slot with compatible inputClasses run in
55707
- * parallel — they attach to the same parent as siblings. Example: two
55708
- * classifiers (bird-classifier + animal-classifier) both accept 'animal'
55709
- * → both attach to object-detection as siblings, both receive every
55710
- * animal crop.
55711
- *
55712
- * Unparented leaves (inputClasses set, but no matching parent in the
55713
- * enabled set) are DROPPED. The runner would have nothing to feed them
55714
- * anyway — better silent drop than dispatching a dead step.
55715
- */
55716
- function buildTreeFromAddons(enabled, catalog) {
55717
- const bySlot = /* @__PURE__ */ new Map();
55718
- for (const slot of catalog.slots) bySlot.set(slot.id, slot);
55719
- const nodes = /* @__PURE__ */ new Map();
55720
- const metaById = /* @__PURE__ */ new Map();
55721
- for (const { addonId } of enabled) {
55722
- const meta = findInCatalog(catalog, addonId);
55723
- if (!meta) continue;
55724
- metaById.set(addonId, meta);
55725
- nodes.set(addonId, {
55726
- addonId,
55727
- enabled: true,
55728
- children: []
56814
+ //#region src/package-zone-reattach.ts
56815
+ /**
56816
+ * Re-attach a placed camera after its `package` zone rules changed.
56817
+ *
56818
+ * A package zone flips the toggle-less `package-detection` root on or off
56819
+ * (`applyPackageZoneActivation`), which changes the step TREE, so the live
56820
+ * attach must be re-resolved rather than wait for the next natural attach.
56821
+ * It goes through the one owner of an attach's steps (D650) on the device the
56822
+ * camera is already elected onto — a bare re-resolve here used to gate on the
56823
+ * node DEFAULT format and drop the device's provisioning and jump roster.
56824
+ */
56825
+ /** No-op for a camera that is not placed or has no ledger config. Never throws. */
56826
+ async function reattachAfterPackageZoneChange(deps, deviceId) {
56827
+ const assignment = deps.ledger.getAssignment(deviceId);
56828
+ const cached = deps.ledger.getConfig(deviceId);
56829
+ if (!assignment || !cached) return;
56830
+ const nodeId = assignment.agentNodeId;
56831
+ try {
56832
+ const outcome = await deps.resolveAttachPipeline({
56833
+ deviceId,
56834
+ nodeId,
56835
+ deviceKey: cached.deviceKey
55729
56836
  });
55730
- }
55731
- const roots = [];
55732
- for (const [addonId, node] of nodes) {
55733
- const inputs = metaById.get(addonId).inputClasses;
55734
- if (!inputs || inputs.length === 0) {
55735
- roots.push(node);
55736
- continue;
55737
- }
55738
- let parent = null;
55739
- for (const [candidateId, candidateNode] of nodes) {
55740
- if (candidateId === addonId) continue;
55741
- if (metaById.get(candidateId).outputClasses.some((c) => inputs.includes(c))) {
55742
- parent = candidateNode;
55743
- break;
55744
- }
55745
- }
55746
- if (parent) parent.children.push(node);
55747
- }
55748
- return roots;
55749
- }
55750
- /**
55751
- * Back-fill catalog steps EXPLICITLY flagged `backfillIntoExistingOverrides`
55752
- * but ENTIRELY ABSENT from an existing step tree — reconciles a stored
55753
- * wholesale `pipelineByAgent` snapshot with genuinely-new default steps.
55754
- *
55755
- * Why it's needed: a `pipelineByAgent` wholesale override SNAPSHOTS the full
55756
- * tree. A step introduced AFTER the snapshot (e.g. `clip-embedding` for object
55757
- * semantic search) would otherwise never reach that camera.
55758
- *
55759
- * Why the flag (not `enabledByDefault`): absence-from-a-snapshot is ambiguous —
55760
- * it can mean "predates this step" OR "operator deliberately pruned it". A
55761
- * broad "back-fill every default-on step" would resurrect deliberate removals
55762
- * of long-existing steps (face-detection, etc.), an operator-intent
55763
- * regression. Only a step that opts in via `backfillIntoExistingOverrides`
55764
- * (set on genuinely-new default steps ONLY) re-enters existing overrides.
55765
- *
55766
- * Non-destructive by design:
55767
- * - Only steps whose id is absent from the WHOLE tree are added. A step the
55768
- * operator has already seen — present anywhere, even `enabled: false` —
55769
- * is left exactly as-is (its enabled flag is honoured).
55770
- * - Only `backfillIntoExistingOverrides === true` addons are candidates.
55771
- * Every other step (including long-standing default-on members) is left
55772
- * untouched, so a deliberately-pruned override stays pruned.
55773
- * - Audio-classifier addons are skipped — audio lives in
55774
- * `CameraPipelineConfig.audio`, never in the video `steps` tree.
55775
- * - A candidate attaches by catalog compat (its `inputClasses` overlap a
55776
- * present node's `outputClasses`), mirroring {@link buildTreeFromAddons}.
55777
- * A candidate with non-empty `inputClasses` and NO compatible parent is
55778
- * dropped (never re-rooted) so a crop-scoped embedder never regresses to
55779
- * running on the full frame. Empty `inputClasses` ⇒ a genuine root step.
55780
- */
55781
- function mergeEnabledByDefaultSteps(steps, catalog) {
55782
- if (steps.length === 0) return steps;
55783
- const present = /* @__PURE__ */ new Set();
55784
- const collect = (nodes) => {
55785
- for (const n of nodes) {
55786
- present.add(n.addonId);
55787
- if (n.children?.length) collect(n.children);
55788
- }
55789
- };
55790
- collect(steps);
55791
- const catalogById = /* @__PURE__ */ new Map();
55792
- for (const slot of catalog.slots) for (const addon of slot.addons) catalogById.set(addon.id, addon);
55793
- const newRoots = [];
55794
- const childrenByParent = /* @__PURE__ */ new Map();
55795
- for (const slot of catalog.slots) {
55796
- if (slot.id === "audio-classifier") continue;
55797
- for (const addon of slot.addons) {
55798
- if (addon.backfillIntoExistingOverrides !== true) continue;
55799
- if (present.has(addon.id)) continue;
55800
- const node = {
55801
- addonId: addon.id,
55802
- enabled: true,
55803
- children: []
55804
- };
55805
- const inputs = addon.inputClasses;
55806
- if (!inputs || inputs.length === 0) {
55807
- newRoots.push(node);
55808
- continue;
55809
- }
55810
- let parentId = null;
55811
- for (const presentId of present) {
55812
- const meta = catalogById.get(presentId);
55813
- if (!meta) continue;
55814
- if (meta.outputClasses.some((c) => inputs.includes(c))) {
55815
- parentId = presentId;
55816
- break;
56837
+ if (outcome.kind === "root-refused") {
56838
+ deps.logger.error("package zone-rule re-attach refused — the elected device cannot run the camera root", {
56839
+ tags: {
56840
+ deviceId,
56841
+ nodeId
56842
+ },
56843
+ meta: {
56844
+ deviceKey: outcome.deviceKey ?? null,
56845
+ droppedRoots: outcome.droppedRoots.join(",")
55817
56846
  }
55818
- }
55819
- if (!parentId) continue;
55820
- const list = childrenByParent.get(parentId) ?? [];
55821
- list.push(node);
55822
- childrenByParent.set(parentId, list);
56847
+ });
56848
+ return;
55823
56849
  }
56850
+ const next = withAttachPipeline(cached, outcome.pipeline);
56851
+ deps.ledger.setConfig(deviceId, next);
56852
+ await deps.attachOn(nodeId, next);
56853
+ } catch (err) {
56854
+ deps.logger.warn("package zone-rule change re-dispatch failed", {
56855
+ tags: {
56856
+ deviceId,
56857
+ nodeId
56858
+ },
56859
+ meta: { error: errMsg$1(err) }
56860
+ });
55824
56861
  }
55825
- if (childrenByParent.size === 0 && newRoots.length === 0) return steps;
55826
- const attach = (nodes) => nodes.map((n) => {
55827
- const rebuiltChildren = attach(n.children ?? []);
55828
- const extra = childrenByParent.get(n.addonId) ?? [];
55829
- const mergedChildren = [...rebuiltChildren, ...extra];
55830
- return mergedChildren.length > 0 ? {
55831
- ...n,
55832
- children: mergedChildren
55833
- } : n;
55834
- });
55835
- return [...attach(steps), ...newRoots];
55836
56862
  }
55837
56863
  //#endregion
55838
56864
  //#region src/node-step-settings-fold.ts
@@ -56063,6 +57089,10 @@ var PipelineSettingsStore = class PipelineSettingsStore {
56063
57089
  this.deps = deps;
56064
57090
  }
56065
57091
  disposed = false;
57092
+ /** Whether `dispose()` has run — a `null` catalog from a bounded wait means "not answered" only while this is false. */
57093
+ isDisposed() {
57094
+ return this.disposed;
57095
+ }
56066
57096
  /** Called once from the addon's `onShutdown()`. Idempotent. */
56067
57097
  dispose() {
56068
57098
  this.disposed = true;
@@ -56354,35 +57384,140 @@ var PipelineSettingsStore = class PipelineSettingsStore {
56354
57384
  }
56355
57385
  catalogCache = /* @__PURE__ */ new Map();
56356
57386
  /**
56357
- * Public, non-blocking accessor for a node's cached `PipelineSchema` (the
56358
- * balancer's root-capability guard reads the object-detection model set from
56359
- * it). Returns `null` on cold start / unreachable node — the caller
56360
- * fails-open. Never blocks (unlike {@link waitForAgentAndCatalog}).
57387
+ * A node's `PipelineSchema`: the cached answer when fresh, else ONE live
57388
+ * `getSchema` RPC (bounded by the transport's request timeout, not by this
57389
+ * store). `null` when that read fails. Unlike {@link waitForAgentAndCatalog}
57390
+ * it does not retry; unlike {@link peekCatalogForAgent} it DOES wait for the
57391
+ * node — use the peek on any path that must never wait.
56361
57392
  */
56362
57393
  async getCatalogForNode(nodeId) {
56363
57394
  return this.getCatalogForAgent(nodeId);
56364
57395
  }
57396
+ /**
57397
+ * The node's catalog for a LIVE eligibility read, in one of two explicit
57398
+ * modes (D650 round 3, bounded in the pre-ship review):
57399
+ * - `unbounded` — WAIT until the node's executor answers, the same
57400
+ * loss-proof reconcile the resolve uses ({@link awaitExecutorCatalog}).
57401
+ * For a FIRST placement, which will wait for this catalog to resolve its
57402
+ * pipeline anyway: electing against it too keeps the root guard from
57403
+ * failing open. `null` only when the store is disposed mid-wait.
57404
+ * - `one-round` — ONE reconcile round (one `getSchema`, no acquire wait).
57405
+ * `null` means "not answered". For a read nothing waits on — a background
57406
+ * refresher — where the unbounded form is a loop that outlives the node
57407
+ * it is asking (D3): a `getSchema` plus a 30 s acquire per round and a
57408
+ * WARN every round, for as long as the key is held.
57409
+ */
57410
+ async awaitCatalogForNode(nodeId, wait) {
57411
+ return this.awaitExecutorCatalog(nodeId, wait === "one-round" ? { maxRounds: 1 } : {});
57412
+ }
57413
+ /**
57414
+ * Per-node catalog generation. {@link invalidateCatalog} bumps it; a
57415
+ * refresh records its answer only if the generation it started under is
57416
+ * still current, so a read that began BEFORE an invalidation can never
57417
+ * re-install the catalog the invalidation just removed.
57418
+ */
57419
+ catalogGeneration = /* @__PURE__ */ new Map();
57420
+ /** Bumped by a full {@link invalidateCatalog}; part of every node's generation. */
57421
+ catalogEpoch = 0;
57422
+ catalogGenerationOf(nodeId) {
57423
+ return `${this.catalogEpoch}:${this.catalogGeneration.get(nodeId) ?? 0}`;
57424
+ }
57425
+ /** Nodes whose last catalog read FAILED — the warn fires once per failing stretch. */
57426
+ catalogReadFailing = /* @__PURE__ */ new Set();
57427
+ /** Nodes being served a catalog past its TTL — the warn fires once per stale stretch. */
57428
+ catalogServedStale = /* @__PURE__ */ new Set();
57429
+ /** Nodes with a background catalog refresh in flight (single-flight per node). */
57430
+ catalogRefreshInFlight = /* @__PURE__ */ new Set();
57431
+ /**
57432
+ * A catalog served this far past its TTL is WORTH a warn: its background
57433
+ * refresh has not landed in ten refresh windows. Below this, serving past
57434
+ * the TTL is by construction — the re-place sweep runs every 60 s against a
57435
+ * 30 s TTL, so every round finds the entry expired and refreshes it behind
57436
+ * the read — and a line per round said nothing (pre-ship review, M5).
57437
+ */
57438
+ static CATALOG_STALE_WARN_MS = 5 * 6e4;
57439
+ /**
57440
+ * Cache-only catalog read — NEVER awaits the node (D650 round 2). Serves
57441
+ * the last answer the node gave, even past its TTL (a stale catalog is a
57442
+ * structure that was true a minute ago; its absence is a camera left
57443
+ * pending), and starts ONE background {@link getCatalogForAgent} when the
57444
+ * entry is missing or expired, so the next caller sees a fresh answer.
57445
+ * Returns `null` only when the node has never answered.
57446
+ */
57447
+ peekCatalogForAgent(nodeId) {
57448
+ const hit = this.catalogCache.get(`${nodeId}::*`);
57449
+ const ageMs = hit === void 0 ? null : Date.now() - hit.at;
57450
+ const fresh = ageMs !== null && ageMs < PipelineSettingsStore.CATALOG_CACHE_TTL_MS;
57451
+ if (fresh) this.catalogServedStale.delete(nodeId);
57452
+ else if (ageMs !== null && ageMs >= PipelineSettingsStore.CATALOG_STALE_WARN_MS && !this.catalogServedStale.has(nodeId)) {
57453
+ this.catalogServedStale.add(nodeId);
57454
+ this.deps.logger.warn("serving a node catalog long past its TTL — its refresh has not landed", {
57455
+ tags: { nodeId },
57456
+ meta: {
57457
+ ageMs,
57458
+ ttlMs: PipelineSettingsStore.CATALOG_CACHE_TTL_MS,
57459
+ warnAfterMs: PipelineSettingsStore.CATALOG_STALE_WARN_MS
57460
+ }
57461
+ });
57462
+ }
57463
+ if (!fresh && !this.catalogRefreshInFlight.has(nodeId)) {
57464
+ this.catalogRefreshInFlight.add(nodeId);
57465
+ this.getCatalogForAgent(nodeId).finally(() => {
57466
+ this.catalogRefreshInFlight.delete(nodeId);
57467
+ });
57468
+ }
57469
+ return hit?.schema ?? null;
57470
+ }
57471
+ /**
57472
+ * How many times one {@link getCatalogForAgent} call re-reads after its
57473
+ * answer was fenced out by an invalidation, before it gives up (`null`, which
57474
+ * the callers treat as "not answered"). Invalidations come from readiness
57475
+ * epochs and operator resets — a bound, not a budget.
57476
+ */
57477
+ static CATALOG_FENCE_REREADS = 3;
56365
57478
  async getCatalogForAgent(nodeId) {
56366
57479
  const api = this.deps.api();
56367
57480
  if (!api) return null;
56368
57481
  const engineKey = `${nodeId}::*`;
56369
- const now = Date.now();
56370
- const hit = this.catalogCache.get(engineKey);
56371
- if (hit && now - hit.at < PipelineSettingsStore.CATALOG_CACHE_TTL_MS) return hit.schema;
56372
- try {
56373
- const schema = await api.pipelineExecutor.getSchema.query({ nodeId });
56374
- this.catalogCache.set(engineKey, {
56375
- schema,
56376
- at: now
56377
- });
56378
- return schema;
56379
- } catch (err) {
56380
- this.deps.logger.debug("getCatalogForAgent failed", {
57482
+ for (let reread = 0; !this.disposed; reread++) {
57483
+ const now = Date.now();
57484
+ const hit = this.catalogCache.get(engineKey);
57485
+ if (hit && now - hit.at < PipelineSettingsStore.CATALOG_CACHE_TTL_MS) return hit.schema;
57486
+ const generation = this.catalogGenerationOf(nodeId);
57487
+ let schema;
57488
+ try {
57489
+ schema = await api.pipelineExecutor.getSchema.query({ nodeId });
57490
+ } catch (err) {
57491
+ if (!this.catalogReadFailing.has(nodeId)) {
57492
+ this.catalogReadFailing.add(nodeId);
57493
+ this.deps.logger.warn("node catalog read failed — placements on it wait or stay pending", {
57494
+ tags: { nodeId },
57495
+ meta: { error: errMsg$1(err) }
57496
+ });
57497
+ }
57498
+ return null;
57499
+ }
57500
+ if (this.catalogReadFailing.delete(nodeId)) this.deps.logger.info("node catalog read recovered", { tags: { nodeId } });
57501
+ if (this.catalogGenerationOf(nodeId) === generation) {
57502
+ this.catalogCache.set(engineKey, {
57503
+ schema,
57504
+ at: now
57505
+ });
57506
+ return schema;
57507
+ }
57508
+ if (reread >= PipelineSettingsStore.CATALOG_FENCE_REREADS) {
57509
+ this.deps.logger.warn("node catalog read fenced repeatedly — giving up this read", {
57510
+ tags: { nodeId },
57511
+ meta: { rereads: reread }
57512
+ });
57513
+ return null;
57514
+ }
57515
+ this.deps.logger.info("node catalog read fenced by an invalidation — re-reading", {
56381
57516
  tags: { nodeId },
56382
- meta: { error: errMsg$1(err) }
57517
+ meta: { reread: reread + 1 }
56383
57518
  });
56384
- return null;
56385
57519
  }
57520
+ return null;
56386
57521
  }
56387
57522
  /**
56388
57523
  * Consolidated catalog-cache invalidation (smell #21). Omit `nodeId` for
@@ -56394,9 +57529,11 @@ var PipelineSettingsStore = class PipelineSettingsStore {
56394
57529
  */
56395
57530
  invalidateCatalog(nodeId) {
56396
57531
  if (nodeId === void 0) {
57532
+ this.catalogEpoch++;
56397
57533
  this.catalogCache.clear();
56398
57534
  return;
56399
57535
  }
57536
+ this.catalogGeneration.set(nodeId, (this.catalogGeneration.get(nodeId) ?? 0) + 1);
56400
57537
  for (const key of Array.from(this.catalogCache.keys())) if (key.startsWith(`${nodeId}::`)) this.catalogCache.delete(key);
56401
57538
  }
56402
57539
  /**
@@ -56468,8 +57605,40 @@ var PipelineSettingsStore = class PipelineSettingsStore {
56468
57605
  *
56469
57606
  * Everything else (boot ordering, catalog cold-start, transient cap
56470
57607
  * registration delay) loops with backoff until the catalog responds.
57608
+ *
57609
+ * `jumpDeviceKeys` (D650) are the node's OTHER enabled devices the runner
57610
+ * may jump a detail step to — `jumpTargetKeys(roster)` of the SAME roster the
57611
+ * attach carries. A detail step the elected device cannot run is kept when
57612
+ * one of them can; empty ⇒ the elected format alone decides.
57613
+ */
57614
+ async resolvePipelineForDevice(deviceId, agentNodeIdOverride, deviceKey, jumpDeviceKeys = []) {
57615
+ const outcome = await this.resolvePipelineWithCatalog(deviceId, agentNodeIdOverride, deviceKey, jumpDeviceKeys, { kind: "await" });
57616
+ if (outcome.kind === "resolved") return outcome.config;
57617
+ throw new Error(`PipelineSettingsStore.resolvePipelineForDevice: catalog unavailable for "${outcome.nodeId}"`);
57618
+ }
57619
+ /**
57620
+ * {@link resolvePipelineForDevice} with the gated-on roots the elected device
57621
+ * cannot run (D650 round 3) — what an attach needs to refuse a camera whose
57622
+ * own detector would not run. Waits for the catalog exactly as it does.
57623
+ */
57624
+ async resolvePipelineForDeviceWithRoots(deviceId, agentNodeId, deviceKey, jumpDeviceKeys) {
57625
+ const outcome = await this.resolvePipelineWithCatalog(deviceId, agentNodeId, deviceKey, jumpDeviceKeys, { kind: "await" });
57626
+ if (outcome.kind === "resolved") return outcome;
57627
+ throw new Error(`PipelineSettingsStore.resolvePipelineForDeviceWithRoots: catalog unavailable for "${outcome.nodeId}"`);
57628
+ }
57629
+ /**
57630
+ * The NON-BLOCKING sibling of {@link resolvePipelineForDevice} (D650 round
57631
+ * 2): the same resolution, but the node's catalog is read from the cache
57632
+ * only ({@link peekCatalogForAgent}). A cold or unanswering node yields
57633
+ * `catalog-unavailable` instead of a wait — the RETRY / re-place paths call
57634
+ * this, so one wedged executor can never hold a sweep (D3: no waiting for a
57635
+ * node; D49: never gate on a fallible read). A cold cache also starts one
57636
+ * background refresh, so the next retry round finds the catalog.
56471
57637
  */
56472
- async resolvePipelineForDevice(deviceId, agentNodeIdOverride, deviceKey) {
57638
+ async resolvePipelineForDeviceIfCatalogCached(deviceId, agentNodeId, deviceKey, jumpDeviceKeys) {
57639
+ return this.resolvePipelineWithCatalog(deviceId, agentNodeId, deviceKey, jumpDeviceKeys, { kind: "cached" });
57640
+ }
57641
+ async resolvePipelineWithCatalog(deviceId, agentNodeIdOverride, deviceKey, jumpDeviceKeys, catalogRead) {
56473
57642
  const agentNodeId = agentNodeIdOverride ?? this.deps.getAssignmentNode(deviceId);
56474
57643
  if (!agentNodeId) throw new Error(`PipelineSettingsStore.resolvePipelineForDevice: no node resolvable for device ${deviceId}`);
56475
57644
  if (!await this.isDetectionPipelineActive(deviceId)) {
@@ -56481,33 +57650,57 @@ var PipelineSettingsStore = class PipelineSettingsStore {
56481
57650
  meta: { reason: "detection-pipeline binding inactive" }
56482
57651
  });
56483
57652
  return {
56484
- steps: [],
56485
- audio: null
57653
+ kind: "resolved",
57654
+ config: {
57655
+ steps: [],
57656
+ audio: null
57657
+ },
57658
+ droppedRoots: []
56486
57659
  };
56487
57660
  }
56488
57661
  const hasPackageZone = await this.deps.hasPackageZone(deviceId);
56489
57662
  const cam = (await this.readCameraSettingsMap())[String(deviceId)] ?? null;
56490
57663
  const wholesale = cam?.pipelineByAgent?.[agentNodeId];
56491
57664
  if (wholesale) {
56492
- const catalog = await this.getCatalogForAgent(agentNodeId);
57665
+ const catalog = catalogRead.kind === "await" ? await this.getCatalogForAgent(agentNodeId) : this.peekCatalogForAgent(agentNodeId);
56493
57666
  const baseSteps = catalog ? mergeEnabledByDefaultSteps(wholesale.steps, catalog) : wholesale.steps;
56494
57667
  return {
56495
- steps: baseSteps.length > 0 ? applyPackageZoneActivation(baseSteps, hasPackageZone) : baseSteps,
56496
- audio: wholesale.audio ? {
56497
- modelId: wholesale.audio.modelId,
56498
- enabled: wholesale.audio.enabled
56499
- } : null
57668
+ kind: "resolved",
57669
+ droppedRoots: [],
57670
+ config: {
57671
+ steps: baseSteps.length > 0 ? applyPackageZoneActivation(baseSteps, hasPackageZone) : baseSteps,
57672
+ audio: wholesale.audio ? {
57673
+ modelId: wholesale.audio.modelId,
57674
+ enabled: wholesale.audio.enabled
57675
+ } : null
57676
+ }
57677
+ };
57678
+ }
57679
+ let catalog;
57680
+ if (catalogRead.kind === "await") catalog = (await this.waitForAgentAndCatalog(agentNodeId)).catalog;
57681
+ else {
57682
+ const cached = this.peekCatalogForAgent(agentNodeId);
57683
+ if (!cached) return {
57684
+ kind: "catalog-unavailable",
57685
+ nodeId: agentNodeId
56500
57686
  };
57687
+ catalog = cached;
56501
57688
  }
56502
- const { catalog } = await this.waitForAgentAndCatalog(agentNodeId);
56503
57689
  const deviceFormat = deviceKey ? parseDeviceKey(deviceKey).format : catalog.selectedEngine.format;
56504
- const { config: resolvedBase, skipped } = resolvePipelineWithSkips(cam, agentNodeId, deviceFormat, catalog);
57690
+ const jumpFormats = [...new Set(jumpDeviceKeys.map((k) => parseDeviceKey(k).format))];
57691
+ const { config: resolvedBase, skipped, droppedRoots } = resolvePipelineWithSkips(cam, agentNodeId, deviceFormat, catalog, jumpFormats);
56505
57692
  if (skipped.length > 0) this.deps.logger.warn("pipeline steps gated on but not provisioned on this node — skipped", {
56506
57693
  tags: {
56507
57694
  deviceId,
56508
57695
  nodeId: agentNodeId
56509
57696
  },
56510
- meta: { skipped: skipped.map((s) => `${s.addonId}:${s.reason}`).join(",") }
57697
+ meta: {
57698
+ skipped: skipped.map((s) => `${s.addonId}:${s.reason}`).join(","),
57699
+ electedDeviceKey: deviceKey ?? null,
57700
+ electedFormat: deviceFormat,
57701
+ jumpDeviceKeys: jumpDeviceKeys.join(","),
57702
+ why: "no enabled device of this node the step may run on ships a build for it (a root must run on the elected device; a detail step on the elected device or a jump-roster device)"
57703
+ }
56511
57704
  });
56512
57705
  const resolved = {
56513
57706
  ...resolvedBase,
@@ -56524,7 +57717,11 @@ var PipelineSettingsStore = class PipelineSettingsStore {
56524
57717
  catalogSlotCount: catalog.slots?.length ?? 0
56525
57718
  }
56526
57719
  });
56527
- return resolved;
57720
+ return {
57721
+ kind: "resolved",
57722
+ config: resolved,
57723
+ droppedRoots
57724
+ };
56528
57725
  }
56529
57726
  /**
56530
57727
  * Loss-proof `pipeline-executor` gate (D8 — reconcile against the
@@ -56658,6 +57855,56 @@ var PipelineSettingsStore = class PipelineSettingsStore {
56658
57855
  return foldNodeStepSettings((await this.agentSettingsState.get())[nodeId]?.inferenceDevices, deviceKey);
56659
57856
  }
56660
57857
  /**
57858
+ * The provisioning SOURCES of every device an attach may run a step on
57859
+ * (D650): for each requested `deviceKey`, its per-(node,device) base (the
57860
+ * same fold as {@link buildNodeInferenceDeviceSteps}) and the camera's
57861
+ * override for that device (the same slot as {@link readCameraDeviceOverride}).
57862
+ * ONE read of each blob for the whole set — the elected device and every
57863
+ * jump target — instead of two reads per device. `''` is the default pool:
57864
+ * its base is the node's folded settings and it has no camera override.
57865
+ */
57866
+ async readDeviceProvisioning(deviceId, nodeId, deviceKeys) {
57867
+ const [agentBlob, cameraMap] = await Promise.all([this.agentSettingsState.get(), this.readCameraSettingsMap()]);
57868
+ const devices = agentBlob[nodeId]?.inferenceDevices;
57869
+ const byDevice = cameraMap[String(deviceId)]?.stepOverridesByDevice?.[nodeId];
57870
+ const out = /* @__PURE__ */ new Map();
57871
+ for (const deviceKey of deviceKeys) {
57872
+ const override = deviceKey === "" ? void 0 : byDevice?.[deviceKey];
57873
+ out.set(deviceKey, {
57874
+ base: foldNodeStepSettings(devices, deviceKey),
57875
+ override: override ? { ...override } : {}
57876
+ });
57877
+ }
57878
+ return out;
57879
+ }
57880
+ /** `${deviceId}:${stepId}` pairs already warned by {@link warnManualJumpsLeaveOverride}. */
57881
+ warnedManualJumpOverride = /* @__PURE__ */ new Set();
57882
+ /**
57883
+ * WARN, once per (camera, step), that an operator `jumpDeviceKey` sends a
57884
+ * step off the elected device while the camera has an override for that
57885
+ * step ON the elected device (D650): the jumped step runs the TARGET's
57886
+ * provisioning, so that override is not applied when the jump is taken.
57887
+ */
57888
+ warnManualJumpsLeaveOverride(deviceId, nodeId, electedDeviceKey, steps) {
57889
+ for (const { stepId, jumpDeviceKey } of steps) {
57890
+ const key = `${deviceId}:${stepId}`;
57891
+ if (this.warnedManualJumpOverride.has(key)) continue;
57892
+ this.warnedManualJumpOverride.add(key);
57893
+ this.deps.logger.warn("manual jumpDeviceKey leaves an elected-device override behind — the jumped step runs the target device provisioning", {
57894
+ tags: {
57895
+ deviceId,
57896
+ nodeId
57897
+ },
57898
+ meta: {
57899
+ step: stepId,
57900
+ electedDeviceKey,
57901
+ jumpDeviceKey,
57902
+ hint: `move the camera's override for ${stepId} to ${jumpDeviceKey}, or drop the jumpDeviceKey`
57903
+ }
57904
+ });
57905
+ }
57906
+ }
57907
+ /**
56661
57908
  * Structural runtime check for `CameraPipelineConfig`. Persisted JSON
56662
57909
  * may be stale or partial across upgrades, so we gate reads on shape
56663
57910
  * rather than trusting the raw object. False = silently ignore that
@@ -57447,7 +58694,7 @@ var ReconcileController = class ReconcileController {
57447
58694
  const cfg = this.deps.ledger.getConfig(deviceId);
57448
58695
  if (!cfg) continue;
57449
58696
  try {
57450
- const result = await this.deps.dispatchCamera(cfg);
58697
+ const result = await this.deps.redispatchCamera(cfg);
57451
58698
  retried++;
57452
58699
  if (result.kind === "pending") stillPending++;
57453
58700
  } catch (err) {
@@ -57558,7 +58805,7 @@ var ReconcileController = class ReconcileController {
57558
58805
  });
57559
58806
  });
57560
58807
  this.deps.ledger.dropAssignment(deviceId, "silent");
57561
- await this.deps.dispatchCamera(cfg).catch((err) => {
58808
+ await this.deps.redispatchCamera(cfg).catch((err) => {
57562
58809
  this.deps.logger.warn("remote-health re-dispatch failed", {
57563
58810
  tags: { deviceId },
57564
58811
  meta: { error: errMsg$1(err) }
@@ -58041,7 +59288,7 @@ var TtlMemo = class {
58041
59288
  });
58042
59289
  if (entry && at - entry.readAt < this.ttlMs) return entry.value;
58043
59290
  try {
58044
- return await this.read(key);
59291
+ return await this.read(key, "miss");
58045
59292
  } catch (err) {
58046
59293
  if (entry) return entry.value;
58047
59294
  throw err;
@@ -58073,7 +59320,7 @@ var TtlMemo = class {
58073
59320
  ageMs: at - entry.readAt
58074
59321
  };
58075
59322
  const fallback = this.lastKnown.get(key);
58076
- const pending = this.read(key);
59323
+ const pending = this.read(key, "miss");
58077
59324
  if (fallback === void 0) return {
58078
59325
  value: await pending,
58079
59326
  stale: false,
@@ -58112,7 +59359,7 @@ var TtlMemo = class {
58112
59359
  * is what re-warms a key immediately after {@link invalidate}.
58113
59360
  */
58114
59361
  async refresh(key) {
58115
- await this.read(key).catch(() => void 0);
59362
+ await this.read(key, "refresh").catch(() => void 0);
58116
59363
  }
58117
59364
  /**
58118
59365
  * Re-read every key still in use and drop the ones nobody has asked for in
@@ -58138,12 +59385,16 @@ var TtlMemo = class {
58138
59385
  this.generations.clear();
58139
59386
  this.lastKnown.clear();
58140
59387
  }
58141
- /** One shared in-flight read per key; stores the result on success. */
58142
- read(key) {
59388
+ /**
59389
+ * One shared in-flight read per key; stores the result on success. A
59390
+ * refresh that finds a miss's read in flight joins it — the waiting caller
59391
+ * chose to wait, and the refresher never awaits the promise it is handed.
59392
+ */
59393
+ read(key, mode) {
58143
59394
  const existing = this.inFlight.get(key);
58144
59395
  if (existing) return existing;
58145
59396
  const generation = this.generations.get(key) ?? 0;
58146
- const pending = this.opts.load(key).then((value) => {
59397
+ const pending = this.opts.load(key, mode).then((value) => {
58147
59398
  const at = this.now();
58148
59399
  const stored = {
58149
59400
  value,
@@ -58337,6 +59588,23 @@ var SessionDispatchController = class {
58337
59588
  */
58338
59589
  motionWatchByDevice = /* @__PURE__ */ new Map();
58339
59590
  /**
59591
+ * The last session REFUSAL per camera (D650 rounds 3–4): the elected device
59592
+ * could not run the camera root, so no session opened and nothing was
59593
+ * recorded. Two readers:
59594
+ * - the refusal line fires once per CHANGE of this entry, not on every
59595
+ * motion edge (a camera re-elected onto the same device every few
59596
+ * seconds is one fact, not a log storm);
59597
+ * - the camera status reads it through {@link refusalReason} and shows
59598
+ * `pending:<reason>` in `reasons.detection`, where a STANDING camera's
59599
+ * pending reason already appears — an armed camera whose every session
59600
+ * is refused must not look like a healthy one waiting for motion.
59601
+ * Deliberately NOT the ledger's `pendingReasons`: an entry there counts as a
59602
+ * standing attach in {@link handleSessionMotion} and would freeze the camera
59603
+ * idle — the trap `isIdleSessionCamera` (index.ts) flags. Cleared when a
59604
+ * session opens and when the camera's detection is stopped.
59605
+ */
59606
+ sessionRefusals = /* @__PURE__ */ new Map();
59607
+ /**
58340
59608
  * Per-device wall-clock of the last frame on which the tracker still had a
58341
59609
  * subject — ANY tracked object, moving or stopped — while a session was
58342
59610
  * active. The session-hold's only liveness input.
@@ -58357,6 +59625,15 @@ var SessionDispatchController = class {
58357
59625
  */
58358
59626
  lastSubjectSeenAt = /* @__PURE__ */ new Map();
58359
59627
  /**
59628
+ * Per-device count of objects the TRACKER held on the last tracked frame of
59629
+ * the active session — moving OR still (D652). Diagnostics only: it decides
59630
+ * nothing. {@link lastSubjectSeenAt} is presence as the HOLD sees it, which
59631
+ * the producer's stillness valve deliberately subtracts a still subject from;
59632
+ * this is what lets a close say whether it ended tracks the tracker still
59633
+ * had. Fed by {@link noteTrackedFrame}; pruned with the session.
59634
+ */
59635
+ lastLiveTrackCount = /* @__PURE__ */ new Map();
59636
+ /**
58360
59637
  * Per-device count of "motion refired while the session was already
58361
59638
  * active" no-ops since the current session was dispatched. Drives the
58362
59639
  * SAMPLED debug line in the dispatch path: on a busy camera the refire
@@ -58382,7 +59659,7 @@ var SessionDispatchController = class {
58382
59659
  agentLoads = new TtlMemo({ load: () => this.deps.loadService.collectAgentLoad({ onlyEnabled: true }) });
58383
59660
  nodeCaps = new TtlMemo({ load: () => this.deps.settingsStore.buildNodeCaps() });
58384
59661
  nodeWeights = new TtlMemo({ load: () => this.deps.settingsStore.buildNodeWeights() });
58385
- eligibleInferenceDevices = new TtlMemo({ load: (nodeId) => this.deps.getEligibleInferenceDevices(nodeId) });
59662
+ eligibleInferenceDevices = new TtlMemo({ load: (nodeId, read) => this.deps.getEligibleInferenceDevices(nodeId, read === "refresh" ? "one-round" : "await-catalog") });
58386
59663
  inferenceDeviceCaps = new TtlMemo({ load: (nodeId) => this.deps.getInferenceDeviceCaps(nodeId) });
58387
59664
  /** Background refresher for {@link agentLoads} et al. Null until first use. */
58388
59665
  balancerRefreshTimer = null;
@@ -58477,6 +59754,17 @@ var SessionDispatchController = class {
58477
59754
  this.lastSubjectSeenAt.set(deviceId, at);
58478
59755
  }
58479
59756
  /**
59757
+ * Record how many objects the tracker held on this frame — the count the
59758
+ * close line reports as `liveTracks` (D652). Every tracked frame, not only
59759
+ * the ones that hold the session: a still subject is exactly the case that
59760
+ * stops holding it. No-op without an active session, which also bounds the
59761
+ * map. Telemetry-lossy (D8): a dropped frame leaves the previous count.
59762
+ */
59763
+ noteTrackedFrame(deviceId, liveTrackCount) {
59764
+ if (!this.sessionRegistry.has(deviceId)) return;
59765
+ this.lastLiveTrackCount.set(deviceId, liveTrackCount);
59766
+ }
59767
+ /**
58480
59768
  * `sessionRegistry.has(deviceId)` — the closure other controllers (Task 3's
58481
59769
  * standing-machinery guards, `index.ts`'s `isIdleSessionCamera`) consume
58482
59770
  * instead of reaching into `sessionRegistry` directly.
@@ -58523,6 +59811,7 @@ var SessionDispatchController = class {
58523
59811
  this.deps.dropAssignment(deviceId, "silent");
58524
59812
  this.sessionRegistry.deregister(deviceId);
58525
59813
  this.lastSubjectSeenAt.delete(deviceId);
59814
+ this.lastLiveTrackCount.delete(deviceId);
58526
59815
  this.notePlacementChanged();
58527
59816
  });
58528
59817
  }
@@ -58761,11 +60050,34 @@ var SessionDispatchController = class {
58761
60050
  }
58762
60051
  });
58763
60052
  const deviceSelectedAt = Date.now();
58764
- const pipelineConfig = await this.deps.settingsStore.resolvePipelineForDevice(deviceId, targetNodeId, deviceKey);
58765
- const deviceBase = await this.deps.settingsStore.buildNodeInferenceDeviceSteps(targetNodeId, deviceKey ?? "");
58766
- const deviceOverride = deviceKey ? await this.deps.settingsStore.readCameraDeviceOverride(deviceId, targetNodeId, deviceKey) : {};
58767
- const steps = applyDeviceProvisioning(pipelineConfig.steps, deviceBase, deviceOverride);
58768
- const inferenceDevices = deviceKey && Object.keys(enabledDevices).length >= 2 ? buildInferenceDeviceRoster(enabledDevices, deviceCaps) : void 0;
60053
+ const outcome = await resolveAttachPipeline({
60054
+ deviceId,
60055
+ nodeId: targetNodeId,
60056
+ deviceKey,
60057
+ eligible: enabledDevices,
60058
+ caps: deviceCaps
60059
+ }, this.deps.settingsStore);
60060
+ if (outcome.kind === "root-refused") {
60061
+ const reason = `root-unrunnable:${targetNodeId}/${outcome.deviceKey ?? "default-pool"}`;
60062
+ if (this.sessionRefusals.get(deviceId) !== reason) {
60063
+ this.sessionRefusals.set(deviceId, reason);
60064
+ log.error("detection session refused — the elected device cannot run the camera root", {
60065
+ tags: {
60066
+ deviceId,
60067
+ nodeId: targetNodeId
60068
+ },
60069
+ meta: {
60070
+ reason,
60071
+ deviceKey: outcome.deviceKey ?? null,
60072
+ droppedRoots: outcome.droppedRoots.join(","),
60073
+ hint: "the root is never jumped: enable a device on this node whose format ships the root model, or disable the device that cannot; said once until the refusal changes"
60074
+ }
60075
+ });
60076
+ }
60077
+ releaseGuard();
60078
+ return null;
60079
+ }
60080
+ const pipeline = outcome.pipeline;
58769
60081
  const zones = await this.deps.listZones(deviceId);
58770
60082
  const sessionDetectionFps = detectionFpsForTrigger(config, trigger);
58771
60083
  if (sessionDetectionFps !== config.detectionFps) log.info("session opened at the device-activity rate", { meta: {
@@ -58775,8 +60087,8 @@ var SessionDispatchController = class {
58775
60087
  } });
58776
60088
  const sessionConfig = {
58777
60089
  deviceId,
58778
- ...deviceKey ? { deviceKey } : {},
58779
- ...inferenceDevices ? { inferenceDevices } : {},
60090
+ ...pipeline.deviceKey ? { deviceKey: pipeline.deviceKey } : {},
60091
+ ...pipeline.inferenceDevices ? { inferenceDevices: pipeline.inferenceDevices } : {},
58780
60092
  motionCooldownMs: config.motionCooldownMs,
58781
60093
  motionFps: config.motionFps,
58782
60094
  detectionFps: sessionDetectionFps,
@@ -58784,8 +60096,8 @@ var SessionDispatchController = class {
58784
60096
  detectionStreamId: config.detectionStreamId,
58785
60097
  motionSources: [],
58786
60098
  pipelineEnabled: config.pipelineEnabled,
58787
- steps,
58788
- audio: pipelineConfig.audio ?? null,
60099
+ steps: pipeline.steps,
60100
+ audio: pipeline.audio,
58789
60101
  zones,
58790
60102
  detectionMode: "always-on",
58791
60103
  audioMode: config.audioMode,
@@ -58804,6 +60116,13 @@ var SessionDispatchController = class {
58804
60116
  attachedAt = Date.now();
58805
60117
  this.deps.recordAssignment(deviceId, targetNodeId, decision.reason, decision.reason === "manual");
58806
60118
  this.sessionRegistry.register(deviceId, targetNodeId, Date.now(), deviceKey);
60119
+ if (this.sessionRefusals.delete(deviceId)) log.info("detection session refusal cleared — a session opened", {
60120
+ tags: {
60121
+ deviceId,
60122
+ nodeId: targetNodeId
60123
+ },
60124
+ meta: deviceKey ? { deviceKey } : {}
60125
+ });
58807
60126
  this.recheck?.noteSessionDispatched(deviceId, targetNodeId, sessionConfig);
58808
60127
  log.info("detection session: dispatched on motion", {
58809
60128
  tags: {
@@ -58812,9 +60131,7 @@ var SessionDispatchController = class {
58812
60131
  },
58813
60132
  meta: {
58814
60133
  reason: decision.reason,
58815
- ...deviceKey ? { deviceKey } : {},
58816
- ...Object.keys(deviceBase).length > 0 ? { deviceBase } : {},
58817
- ...Object.keys(deviceOverride).length > 0 ? { deviceOverride } : {}
60134
+ ...deviceKey ? { deviceKey } : {}
58818
60135
  }
58819
60136
  });
58820
60137
  } finally {
@@ -58978,6 +60295,8 @@ var SessionDispatchController = class {
58978
60295
  this.deps.dropAssignment(deviceId, "unassigned");
58979
60296
  this.sessionRegistry.deregister(deviceId);
58980
60297
  this.lastSubjectSeenAt.delete(deviceId);
60298
+ const liveTracks = this.lastLiveTrackCount.get(deviceId) ?? null;
60299
+ this.lastLiveTrackCount.delete(deviceId);
58981
60300
  this.activeRefireCountByDevice.delete(deviceId);
58982
60301
  this.notePlacementChanged();
58983
60302
  const { openForMs, sinceSubjectSeenMs, maxHoldMs } = observed;
@@ -58988,6 +60307,7 @@ var SessionDispatchController = class {
58988
60307
  openForMs,
58989
60308
  sinceSubjectSeenMs,
58990
60309
  subjectPresent,
60310
+ liveTracks,
58991
60311
  maxHoldMs,
58992
60312
  minOpenMs: MIN_SESSION_OPEN_MS,
58993
60313
  subjectStaleMs: SUBJECT_PRESENT_STALE_MS
@@ -58999,6 +60319,17 @@ var SessionDispatchController = class {
58999
60319
  });
59000
60320
  return;
59001
60321
  }
60322
+ if (liveTracks !== null && liveTracks > 0) {
60323
+ const designedValve = sinceSubjectSeenMs !== null;
60324
+ const line = reason === "motion_cleared" ? "detection session: closed on motion clear while the tracker was still tracking a subject — its live tracks end here" : "detection session: closed after motion cooldown while the tracker was still tracking a subject — its live tracks end here";
60325
+ const extras = {
60326
+ tags: { nodeId: session.nodeId },
60327
+ meta
60328
+ };
60329
+ if (designedValve) log.info(line, extras);
60330
+ else log.warn(line, extras);
60331
+ return;
60332
+ }
59002
60333
  log.info(reason === "motion_cleared" ? "detection session: closed on motion clear" : "detection session: closed after motion cooldown", {
59003
60334
  tags: { nodeId: session.nodeId },
59004
60335
  meta
@@ -59044,6 +60375,14 @@ var SessionDispatchController = class {
59044
60375
  * down too, since it reads `sessionRegistry` fresh at the top of this same
59045
60376
  * callback).
59046
60377
  */
60378
+ /**
60379
+ * Why this camera's LAST on-motion session was refused, or `undefined` when
60380
+ * it was not (or a session has opened since). The camera status renders it
60381
+ * as `pending:<reason>` — see `sessionRefusals`.
60382
+ */
60383
+ refusalReason(deviceId) {
60384
+ return this.sessionRefusals.get(deviceId);
60385
+ }
59047
60386
  async stopForDevice(deviceId) {
59048
60387
  await this.sessionLocks.run(deviceId, async () => {
59049
60388
  const session = this.sessionRegistry.get(deviceId);
@@ -59063,6 +60402,8 @@ var SessionDispatchController = class {
59063
60402
  }
59064
60403
  this.deps.dropActiveDetection(deviceId);
59065
60404
  this.lastSubjectSeenAt.delete(deviceId);
60405
+ this.lastLiveTrackCount.delete(deviceId);
60406
+ this.sessionRefusals.delete(deviceId);
59066
60407
  this.recheck?.forget(deviceId);
59067
60408
  });
59068
60409
  const watchNodeId = this.motionWatchByDevice.get(deviceId);
@@ -59103,6 +60444,8 @@ var SessionDispatchController = class {
59103
60444
  this.sessionRegistry.clear();
59104
60445
  this.motionWatchByDevice.clear();
59105
60446
  this.lastSubjectSeenAt.clear();
60447
+ this.lastLiveTrackCount.clear();
60448
+ this.sessionRefusals.clear();
59106
60449
  if (this.balancerRefreshTimer !== null) {
59107
60450
  clearInterval(this.balancerRefreshTimer);
59108
60451
  this.balancerRefreshTimer = null;
@@ -60072,7 +61415,8 @@ async function buildOrchestratorControllers(deps) {
60072
61415
  watchdog: () => pipelineWatchdog,
60073
61416
  localNodeId: () => localNodeId,
60074
61417
  readPipelinePin: (deviceId) => deps.readPipelinePin(deviceId),
60075
- getEligibleInferenceDevices: (nodeId) => deps.getEligibleInferenceDevices(nodeId),
61418
+ getEligibleInferenceDevices: (nodeId) => deps.getEligibleInferenceDevices(nodeId, "await-catalog"),
61419
+ getEligibleInferenceDevicesIfCached: (nodeId) => deps.getEligibleInferenceDevicesIfCached(nodeId),
60076
61420
  isNodeInferenceUsable: (nodeId) => deps.isNodeInferenceUsable(nodeId),
60077
61421
  getInferenceDeviceCaps: (nodeId) => deps.getInferenceDeviceCaps(nodeId),
60078
61422
  readPipelineDevicePin: (deviceId) => deps.readPipelineDevicePin(deviceId),
@@ -60108,7 +61452,7 @@ async function buildOrchestratorControllers(deps) {
60108
61452
  },
60109
61453
  listZones: async (deviceId) => await zonesProvider?.listZones({ deviceId }) ?? [],
60110
61454
  readPipelinePin: (deviceId) => deps.readPipelinePin(deviceId),
60111
- getEligibleInferenceDevices: (nodeId) => deps.getEligibleInferenceDevices(nodeId),
61455
+ getEligibleInferenceDevices: (nodeId, read) => deps.getEligibleInferenceDevices(nodeId, read),
60112
61456
  isNodeInferenceUsable: (nodeId) => deps.isNodeInferenceUsable(nodeId),
60113
61457
  getInferenceDeviceCaps: (nodeId) => deps.getInferenceDeviceCaps(nodeId),
60114
61458
  readPipelineDevicePin: (deviceId) => deps.readPipelineDevicePin(deviceId),
@@ -60151,7 +61495,7 @@ async function buildOrchestratorControllers(deps) {
60151
61495
  getAudioAssignment: (deviceId) => audio.getAssignment(deviceId),
60152
61496
  getCameraConfig: (deviceId) => ledger.getConfig(deviceId),
60153
61497
  hasCameraConfig: (deviceId) => ledger.hasConfig(deviceId),
60154
- getPendingReason: (deviceId) => ledger.getPendingReason(deviceId),
61498
+ getPendingReason: (deviceId) => ledger.getPendingReason(deviceId) ?? session.refusalReason(deviceId),
60155
61499
  assignSource: (deviceId) => topology.assignSource(deviceId),
60156
61500
  listAssignedDeviceIds: () => [...new Set([...ledger.listAssignedDeviceIds(), ...detectionWiring.activeDeviceIds()])],
60157
61501
  isSessionCamera: (deviceId) => deps.isSessionCamera(deviceId),
@@ -60169,7 +61513,7 @@ async function buildOrchestratorControllers(deps) {
60169
61513
  logger: deps.ctx().logger,
60170
61514
  handleDeviceRegistered: (deviceId) => deps.handleDeviceRegistered(deviceId),
60171
61515
  handleDeviceUnregistered: (deviceId) => deps.handleDeviceUnregistered(deviceId),
60172
- dispatchCamera: (config) => deps.dispatchCamera(config),
61516
+ redispatchCamera: (config) => placement.redispatchCamera(config),
60173
61517
  detachOn: (nodeId, deviceId) => deps.detachOn(nodeId, deviceId),
60174
61518
  localNodeId: () => localNodeId,
60175
61519
  activeDeviceIds: () => detectionWiring.activeDeviceIds(),
@@ -60199,6 +61543,7 @@ async function buildOrchestratorControllers(deps) {
60199
61543
  loadService,
60200
61544
  settingsStore,
60201
61545
  attachOn: (nodeId, config) => deps.attachOn(nodeId, config),
61546
+ resolveAttachPipeline: (input) => placement.resolveAttachPipelineOnDevice(input),
60202
61547
  rebalance: (opts) => deps.rebalance(opts),
60203
61548
  scheduleReconcile: () => reconcile.scheduleReconcile(),
60204
61549
  schedulePendingRetry: () => reconcile.schedulePendingRetry(),
@@ -60217,6 +61562,7 @@ async function buildOrchestratorControllers(deps) {
60217
61562
  if (!nodeId.includes("/")) topology.removeKnownRunnerNode(nodeId);
60218
61563
  nodeLifecycle.unsubscribeDetectionPipelineReadiness(nodeId);
60219
61564
  audio.markNodeOffline(nodeId);
61565
+ deps.forgetInferenceDeviceProbe(nodeId);
60220
61566
  nodeLifecycle.handleNodeDisconnect(nodeId).catch((err) => {
60221
61567
  const msg = errMsg$1(err);
60222
61568
  deps.ctx().logger.error("failover after disconnect failed", {
@@ -60332,26 +61678,12 @@ async function buildOrchestratorControllers(deps) {
60332
61678
  fetchDevice: (deviceId) => deps.ctx().fetchDevice(deviceId),
60333
61679
  onRulesChanged: (deviceId, stage) => {
60334
61680
  if (stage !== "package") return;
60335
- const assignment = ledger.getAssignment(deviceId);
60336
- const cached = ledger.getConfig(deviceId);
60337
- if (!assignment || !cached) return;
60338
- settingsStore.resolvePipelineForDevice(deviceId, assignment.agentNodeId).then((resolved) => {
60339
- const next = {
60340
- ...cached,
60341
- steps: resolved.steps,
60342
- audio: resolved.audio ?? null
60343
- };
60344
- ledger.setConfig(deviceId, next);
60345
- return deps.attachOn(assignment.agentNodeId, next);
60346
- }).catch((err) => {
60347
- deps.ctx().logger.warn("package zone-rule change re-dispatch failed", {
60348
- tags: {
60349
- deviceId,
60350
- nodeId: assignment.agentNodeId
60351
- },
60352
- meta: { error: errMsg$1(err) }
60353
- });
60354
- });
61681
+ reattachAfterPackageZoneChange({
61682
+ ledger,
61683
+ resolveAttachPipeline: (input) => placement.resolveAttachPipelineOnDevice(input),
61684
+ attachOn: (nodeId, config) => deps.attachOn(nodeId, config),
61685
+ logger: deps.ctx().logger
61686
+ }, deviceId);
60355
61687
  }
60356
61688
  });
60357
61689
  zoneRulesProviderRef = zoneRulesProvider;
@@ -60415,6 +61747,7 @@ async function buildOrchestratorControllers(deps) {
60415
61747
  getActiveDetectionConfig: (deviceId) => detectionWiring.getActiveDetectionConfig(deviceId),
60416
61748
  handleSessionMotion: (deviceId, detected, emittedAt) => session.handleSessionMotion(deviceId, detected, emittedAt),
60417
61749
  noteSessionSubjectPresent: (deviceId) => session.noteSubjectPresent(deviceId),
61750
+ noteSessionTrackedFrame: (deviceId, liveTrackCount) => session.noteTrackedFrame(deviceId, liveTrackCount),
60418
61751
  noteWatchdogSignal: (deviceId, stage) => pipelineWatchdog?.noteSignal(deviceId, stage)
60419
61752
  });
60420
61753
  reconcile.scheduleReconcile();
@@ -61139,6 +62472,15 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
61139
62472
  /** This node's Moleculer nodeId (from this.ctx.kernel.localNodeId). */
61140
62473
  localNodeId = "hub";
61141
62474
  /**
62475
+ * A shutdown latch for {@link getClusterModelChoice} (D651): cleared on the
62476
+ * first line of `onShutdown`, so an owner that is tearing down stops
62477
+ * answering. Set at the end of `onInitialize`; it is also false before that,
62478
+ * but nothing can call the method then — the cap provider is registered
62479
+ * from what `onInitialize` RETURNS, so the pre-init state is unreachable and
62480
+ * not a claim this flag makes.
62481
+ */
62482
+ clusterModelChoiceReady = false;
62483
+ /**
61142
62484
  * Single owner of the shared placement state (`assignments`/`cameraConfigs`/
61143
62485
  * `pendingReasons`/`dispatchingDevices`) — see `placement-ledger.ts` (S6
61144
62486
  * extraction). Constructed FIRST in `onInitialize` (several other
@@ -61383,7 +62725,9 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
61383
62725
  attachOn: (nodeId, config) => this.attachOn(nodeId, config),
61384
62726
  detachOn: (nodeId, deviceId) => this.detachOn(nodeId, deviceId),
61385
62727
  readPipelinePin: (deviceId) => this.readPipelinePin(deviceId),
61386
- getEligibleInferenceDevices: (nodeId) => this.resolveEligibleInferenceDevices(nodeId),
62728
+ getEligibleInferenceDevices: (nodeId, read) => this.resolveEligibleInferenceDevices(nodeId, read),
62729
+ getEligibleInferenceDevicesIfCached: (nodeId) => this.resolveEligibleInferenceDevicesIfCached(nodeId),
62730
+ forgetInferenceDeviceProbe: (nodeId) => this.forgetInferenceDeviceProbe(nodeId),
61387
62731
  isNodeInferenceUsable: (nodeId) => this.isNodeInferenceUsable(nodeId),
61388
62732
  getInferenceDeviceCaps: (nodeId) => this.getInferenceDeviceCaps(nodeId),
61389
62733
  readPipelineDevicePin: (deviceId) => this.readPipelineDevicePin(deviceId),
@@ -61410,7 +62754,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
61410
62754
  state: this.state.bind(this),
61411
62755
  rebalance: (opts) => this.rebalance(opts),
61412
62756
  setCameraPipelineForAgent: (input) => this.setCameraPipelineForAgent(input),
61413
- dispatchCamera: (config) => this.dispatchCamera(config),
61414
62757
  maintenancePaused: () => this.storageMigrationLeaseId !== null,
61415
62758
  setTopology: (topology) => {
61416
62759
  this.topology = topology;
@@ -61455,6 +62798,8 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
61455
62798
  this.autoRebalanceTimer = controllers.autoRebalanceTimer;
61456
62799
  this.nodeStressLts = controllers.nodeStressLts;
61457
62800
  this.nodeStressLtsTimer = controllers.nodeStressLtsTimer;
62801
+ const widgetsProvider = { listWidgets: async () => pipelineOrchestratorWidgets };
62802
+ this.clusterModelChoiceReady = true;
61458
62803
  return {
61459
62804
  providers: [
61460
62805
  {
@@ -61475,7 +62820,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
61475
62820
  },
61476
62821
  {
61477
62822
  capability: addonWidgetsSourceCapability,
61478
- provider: { listWidgets: async () => pipelineOrchestratorWidgets }
62823
+ provider: widgetsProvider
61479
62824
  },
61480
62825
  {
61481
62826
  capability: viewerUiCapability,
@@ -61524,6 +62869,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
61524
62869
  };
61525
62870
  }
61526
62871
  async onShutdown() {
62872
+ this.clusterModelChoiceReady = false;
61527
62873
  this.audio.beginShutdown();
61528
62874
  this.session.shutdown();
61529
62875
  if (this.pendingRetryTimer !== null) {
@@ -61669,6 +63015,16 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
61669
63015
  * a server restart that rehydrates assignments from disk without
61670
63016
  * re-dispatching).
61671
63017
  */
63018
+ /**
63019
+ * The operator's stored model choice for a cluster-scoped step (D651).
63020
+ * Answers `owner-not-ready` from the first line of `onShutdown`: an owner
63021
+ * that is tearing down must not answer at all. (Before `onInitialize`
63022
+ * returns nobody can reach this method — see `clusterModelChoiceReady`.)
63023
+ */
63024
+ async getClusterModelChoice(input) {
63025
+ if (!this.clusterModelChoiceReady) return decideClusterModelChoice(input.stepId, "owner-not-ready");
63026
+ return decideClusterModelChoice(input.stepId, await this.readStoredGlobalKey(clusterModelSettingKey(input.stepId)));
63027
+ }
61672
63028
  async getAgentLoad() {
61673
63029
  await this.loadService.refreshDecodeHwaccels();
61674
63030
  await this.loadService.collectAgentLoad();
@@ -61785,6 +63141,14 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
61785
63141
  async evaluateRemoteAssignmentHealth() {
61786
63142
  return this.reconcile.evaluateRemoteAssignmentHealth();
61787
63143
  }
63144
+ /**
63145
+ * Thin seam onto `ReconcileController.retryPendingDispatches` — the same
63146
+ * `protected` pattern, for `pending-retry-catalog-wedge.spec.ts` to run one
63147
+ * sweep and bound it.
63148
+ */
63149
+ async retryPendingDispatches() {
63150
+ return this.reconcile.retryPendingDispatches();
63151
+ }
61788
63152
  async getCapabilityBindings(input) {
61789
63153
  if (!this.ctx?.settings) return {};
61790
63154
  return this.settingsStore.getCapabilityBindings(input.nodeId);
@@ -61867,6 +63231,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
61867
63231
  success: true,
61868
63232
  removed: false
61869
63233
  };
63234
+ this.forgetInferenceDeviceProbe(input.agentNodeId);
61870
63235
  await this.topology.refreshNodeCapabilities();
61871
63236
  this.ctx.logger.info("agentSettings entry removed", { tags: { nodeId: input.agentNodeId } });
61872
63237
  return {
@@ -62068,12 +63433,110 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
62068
63433
  * the runner uses its single default accelerator. NEVER throws (an
62069
63434
  * unreachable probe yields no eligible devices).
62070
63435
  */
62071
- async resolveEligibleInferenceDevices(nodeId) {
62072
- const stored = (await this.settingsStore.readAgentSettingsMap())[nodeId]?.inferenceDevices ?? {};
62073
- const [{ probed }, poolHealth] = await Promise.all([this.probeNodeInferenceDevices(nodeId), this.probeNodeInferenceDeviceHealth(nodeId)]);
62074
- this.noteInferenceDeviceHealth(nodeId, poolHealth, probed.map((d) => d.key));
62075
- const catalog = await this.settingsStore.getCatalogForNode(nodeId);
62076
- const eligibility = resolveInferenceDeviceEligibility(probed, stored, catalog ? makeRootCapabilityGuard(catalog) : void 0, (deviceKey) => this.inferenceDeviceUsability.isUsable(nodeId, deviceKey));
63436
+ async resolveEligibleInferenceDevices(nodeId, read) {
63437
+ const [probe, poolHealth] = await Promise.all([this.probeNodeInferenceDevices(nodeId), this.probeNodeInferenceDeviceHealth(nodeId)]);
63438
+ this.noteInferenceDeviceProbe(nodeId, probe, poolHealth);
63439
+ const probed = probe.probed;
63440
+ const catalog = await this.settingsStore.awaitCatalogForNode(nodeId, read === "one-round" ? "one-round" : "unbounded");
63441
+ if (catalog === null && read === "one-round" && !this.settingsStore.isDisposed()) {
63442
+ this.ctx.logger.debug("inference device eligibility: node catalog not answered in one round — last answer kept", {
63443
+ tags: { nodeId },
63444
+ meta: { nodeId }
63445
+ });
63446
+ throw new Error(`inference device eligibility: node "${nodeId}" catalog not answered`);
63447
+ }
63448
+ return this.electableDevices(nodeId, probed, catalog);
63449
+ }
63450
+ /**
63451
+ * Forget a node's last device probe (pre-ship review, M4). Called when the
63452
+ * node leaves (`AgentOffline`) and when the operator removes its settings: a
63453
+ * node that comes back with a different accelerator set must be probed
63454
+ * again, not elected against the list it left with.
63455
+ */
63456
+ forgetInferenceDeviceProbe(nodeId) {
63457
+ if (!this.lastInferenceDeviceProbe.delete(nodeId)) return;
63458
+ this.ctx.logger.info("inference device probe forgotten — the node left", {
63459
+ tags: { nodeId },
63460
+ meta: { nodeId }
63461
+ });
63462
+ }
63463
+ /**
63464
+ * The NON-BLOCKING sibling of {@link resolveEligibleInferenceDevices}, for
63465
+ * the RE-place paths (D650 round 3). Makes no live call to the node: the
63466
+ * device list and the pool health are the last ones it answered, the
63467
+ * catalog is the cached one. A node that has never answered any of them is
63468
+ * `unavailable` — the camera stays pending, named, and ONE background
63469
+ * refresh per node is started so the next round can place it. A cold cache
63470
+ * never fails open here: failing open on a re-place is how a camera gets
63471
+ * attached with no root.
63472
+ */
63473
+ async resolveEligibleInferenceDevicesIfCached(nodeId) {
63474
+ const known = this.lastInferenceDeviceProbe.get(nodeId);
63475
+ if (known === void 0) {
63476
+ this.refreshInferenceDeviceProbe(nodeId);
63477
+ return {
63478
+ kind: "unavailable",
63479
+ nodeId,
63480
+ what: "device-health"
63481
+ };
63482
+ }
63483
+ const catalog = this.settingsStore.peekCatalogForAgent(nodeId);
63484
+ if (catalog === null) return {
63485
+ kind: "unavailable",
63486
+ nodeId,
63487
+ what: "catalog"
63488
+ };
63489
+ this.refreshInferenceDeviceProbe(nodeId);
63490
+ return {
63491
+ kind: "eligible",
63492
+ eligible: await this.electableDevices(nodeId, known, catalog)
63493
+ };
63494
+ }
63495
+ /**
63496
+ * The last device list each node's probe returned — what the re-place paths
63497
+ * elect against, with the pool-health mirror that probe updated. Written
63498
+ * when a probe COMPLETES, so a cold entry means "the node's probe has never
63499
+ * come back", never "came back with nothing".
63500
+ */
63501
+ lastInferenceDeviceProbe = /* @__PURE__ */ new Map();
63502
+ /** Nodes with a background device probe in flight (single-flight per node). */
63503
+ inferenceProbeInFlight = /* @__PURE__ */ new Set();
63504
+ refreshInferenceDeviceProbe(nodeId) {
63505
+ if (this.inferenceProbeInFlight.has(nodeId)) return;
63506
+ this.inferenceProbeInFlight.add(nodeId);
63507
+ Promise.all([this.probeNodeInferenceDevices(nodeId), this.probeNodeInferenceDeviceHealth(nodeId)]).then(([probe, poolHealth]) => this.noteInferenceDeviceProbe(nodeId, probe, poolHealth)).finally(() => {
63508
+ this.inferenceProbeInFlight.delete(nodeId);
63509
+ });
63510
+ }
63511
+ /**
63512
+ * Fold one COMPLETED probe into the pool-health mirror and the last-known
63513
+ * list. "Completed" means the node ANSWERED: a probe that could not reach
63514
+ * it (`reachable: false`) changes nothing (D49, round 4) — its empty device
63515
+ * list is not an answer, and recording it as one both wiped a good
63516
+ * last-known list and turned a never-answered node into an "answered with
63517
+ * nothing" one, so `device-health-unavailable` stopped firing after a single
63518
+ * failure. A health read that failed (`null`) changes no verdict in the
63519
+ * mirror either, but it did come back — a node whose read never comes back
63520
+ * at all is the one a re-place must not wait on, and that node never reaches
63521
+ * this line.
63522
+ */
63523
+ noteInferenceDeviceProbe(nodeId, probe, poolHealth) {
63524
+ if (!probe.reachable) {
63525
+ this.ctx.logger.debug("inference device probe unreachable — last known device list kept", {
63526
+ tags: { nodeId },
63527
+ meta: {
63528
+ nodeId,
63529
+ known: this.lastInferenceDeviceProbe.has(nodeId)
63530
+ }
63531
+ });
63532
+ return;
63533
+ }
63534
+ this.noteInferenceDeviceHealth(nodeId, poolHealth, probe.probed.map((d) => d.key));
63535
+ this.lastInferenceDeviceProbe.set(nodeId, probe.probed);
63536
+ }
63537
+ /** Eligibility from a device list + catalog; records usability like every dispatch read. */
63538
+ async electableDevices(nodeId, probed, catalog) {
63539
+ const eligibility = resolveInferenceDeviceEligibility(probed, (await this.settingsStore.readAgentSettingsMap())[nodeId]?.inferenceDevices ?? {}, catalog ? makeRootCapabilityGuard(catalog) : void 0, (deviceKey) => this.inferenceDeviceUsability.isUsable(nodeId, deviceKey));
62077
63540
  const { eligible, excluded } = eligibility;
62078
63541
  this.logInferenceDeviceShrinkage(nodeId, eligible, excluded);
62079
63542
  this.noteNodeInferenceUsability(nodeId, resolveNodeInferenceUsability(eligibility));