@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.js CHANGED
@@ -5391,7 +5391,7 @@ var ZodIssueCode = {
5391
5391
  var ZodFirstPartyTypeKind;
5392
5392
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5393
5393
  //#endregion
5394
- //#region ../types/dist/sleep-BaRu0bS5.mjs
5394
+ //#region ../types/dist/sleep-DC-wdyeS.mjs
5395
5395
  /**
5396
5396
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5397
5397
  * window to float samples (D455).
@@ -6192,6 +6192,40 @@ var BaseAddon = class {
6192
6192
  const node = normalizeNodeId(nodeId ?? this._ctx?.kernel?.localNodeId);
6193
6193
  return keys.size > 0 ? projectStore(raw, keys, node) : raw;
6194
6194
  }
6195
+ /**
6196
+ * Whether ONE global key of this addon's store was written, read three-way
6197
+ * (D651). For a question a hydrated view cannot answer: `getGlobalSettings`
6198
+ * reports `value ?? default`, so "the operator chose the default" and "nobody
6199
+ * chose" look the same, and a failed read looks like both. A `perNode` key is
6200
+ * not supported here: its stored name is scoped.
6201
+ */
6202
+ async readStoredGlobalKey(key) {
6203
+ const read = this._ctx?.settings?.readAddonStoreResult;
6204
+ if (read === void 0) return {
6205
+ kind: "unreadable",
6206
+ reason: "three-way-read-unavailable"
6207
+ };
6208
+ let result;
6209
+ try {
6210
+ result = await read.call(this._ctx?.settings);
6211
+ } catch (err) {
6212
+ return {
6213
+ kind: "unreadable",
6214
+ reason: "store-read-failed",
6215
+ error: err instanceof Error ? err.message : String(err)
6216
+ };
6217
+ }
6218
+ if (result.kind === "failed") return {
6219
+ kind: "unreadable",
6220
+ reason: "store-read-failed",
6221
+ error: result.error
6222
+ };
6223
+ if (result.kind === "missing" || !Object.prototype.hasOwnProperty.call(result.value, key)) return { kind: "absent" };
6224
+ return {
6225
+ kind: "present",
6226
+ value: result.value[key]
6227
+ };
6228
+ }
6195
6229
  async updateGlobalSettings(patch, nodeId) {
6196
6230
  const keys = this.perNodeKeys();
6197
6231
  const localNode = normalizeNodeId(this._ctx?.kernel?.localNodeId);
@@ -10319,6 +10353,43 @@ var LogChannelWindowSchema = object({
10319
10353
  /** `null` = every camera. A non-empty list narrows to those numeric ids. */
10320
10354
  deviceIds: array(number().int()).readonly().nullable()
10321
10355
  });
10356
+ var ClipModelMetaSchema = object({
10357
+ /** The paired text encoder's catalog id (the embedding-encoder's text catalog). */
10358
+ textModelId: string().min(1),
10359
+ /** Output dimension of BOTH towers. A vector of any other length is refused. */
10360
+ embeddingDim: number().int().positive(),
10361
+ /**
10362
+ * The `vector-store` index this model's vectors live in. ONE feature space
10363
+ * per index: two models share an index only when their vectors are ranked
10364
+ * against each other, which for CLIP means never across families. MobileCLIP
10365
+ * S1 and S2 share `object-clip` for history (separated by the row's
10366
+ * `modelId`); any other model gets its own.
10367
+ */
10368
+ vectorIndex: string().regex(/^object-clip(-[a-z0-9][a-z0-9-]*)?$/),
10369
+ /**
10370
+ * The text encoder's tokenizer, a declared SIBLING file of its onnx. The name
10371
+ * must be unique per tokenizer: every model's siblings land in ONE flat
10372
+ * models directory, so two different tokenizers under the same name would
10373
+ * silently overwrite each other (whichever model downloaded last wins, and
10374
+ * the other one tokenises with the wrong vocabulary).
10375
+ */
10376
+ tokenizerFile: string().min(1),
10377
+ /** Token window the text graph takes (SigLIP2: 64; CLIP BPE: 77). */
10378
+ contextLength: number().int().positive(),
10379
+ /** Id the tokenizer pads with. */
10380
+ padId: number().int().nonnegative(),
10381
+ /**
10382
+ * The search cosine floor at `strictness: 'loose'` — calibrated per model,
10383
+ * because cosine bands are a property of the model: 0.2 keeps 96% of matched
10384
+ * COCO caption-image pairs on MobileCLIP S1 and 2.7% on SigLIP2.
10385
+ */
10386
+ searchMinScore: number().min(0).max(1)
10387
+ });
10388
+ var ClipSearchStrictnessSchema = _enum([
10389
+ "loose",
10390
+ "balanced",
10391
+ "strict"
10392
+ ]);
10322
10393
  var MODEL_FORMATS = [
10323
10394
  "onnx",
10324
10395
  "coreml",
@@ -10522,7 +10593,14 @@ var ModelCatalogEntrySchema = object({
10522
10593
  * applies (Frigate / COCO public catalog). Set on a custom model whose raw
10523
10594
  * labels already ARE the CamStack macros (Scrypted identity map).
10524
10595
  */
10525
- classMap: DetectionCatalogClassMapSchema.optional()
10596
+ classMap: DetectionCatalogClassMapSchema.optional(),
10597
+ /**
10598
+ * The model's CLIP feature-space contract (text encoder, dimension, vector
10599
+ * index, token window, search floor) — see `clip-model.ts`. Present ONLY on a
10600
+ * CLIP image encoder; its absence is what "not a CLIP model" means to the
10601
+ * embedding encoder and the semantic-search store (D649).
10602
+ */
10603
+ clip: ClipModelMetaSchema.optional()
10526
10604
  });
10527
10605
  var ConvertTargetSchema = discriminatedUnion("format", [object({
10528
10606
  format: literal("openvino"),
@@ -14797,6 +14875,27 @@ var LinkedDeviceSchema = object({
14797
14875
  features: array(string()),
14798
14876
  producesTrackedEvents: boolean().optional()
14799
14877
  });
14878
+ /**
14879
+ * The `linked-devices` extension slot's editable value (D648): the operator's
14880
+ * pick list, exactly what `_linkedDeviceIds` persists. Tree children are NOT
14881
+ * in it — they are always linked and are not a pick.
14882
+ *
14883
+ * `trackedDeviceIds` (D655) is the tracked-event opt-in, exactly what
14884
+ * `_linkedDeviceTrackedIds` persists: which linked devices materialize a
14885
+ * synthetic tracked event on the camera. A read always carries it AS STORED
14886
+ * (an absent key reads as `[]`, which is what the resolver makes of it —
14887
+ * NONE). On a write it is optional: absent leaves the opt-in untouched, so a
14888
+ * card built before D655 cannot erase it by saving the pick list. A present
14889
+ * list REPLACES the opt-in; each id must be a pick, a device the camera links
14890
+ * by structure (the row's `impliedSources`: tree children, a picked
14891
+ * container's child) or already stored — so what was read can always be
14892
+ * written back unchanged, and a NEW opt-in outside the linked set is refused.
14893
+ */
14894
+ var LinkedDevicesSlotValueSchema = object({
14895
+ deviceIds: array(number().int().positive()),
14896
+ trackedDeviceIds: array(number().int().positive()).optional()
14897
+ });
14898
+ DeviceType.Camera;
14800
14899
  /** One camera's resolved linked set, tagged with the camera it belongs to.
14801
14900
  * The batch answer needs the tag; the single-device answer already has it
14802
14901
  * from the input, which is why `getLinkedDevices` keeps the untagged shape. */
@@ -15322,16 +15421,101 @@ var EmbeddingResultSchema = object({
15322
15421
  embedding: array(number()),
15323
15422
  inferenceMs: number()
15324
15423
  });
15424
+ /** The two CLIP towers the encoder runs; a crash budget is kept per tower. */
15425
+ var EmbeddingTowerSchema = _enum(["image", "text"]);
15426
+ /**
15427
+ * A tower in the terminal `failed` state (D649, D6): it crashed or failed to
15428
+ * load `crashes` times within `windowMs`, and is refused by name until an
15429
+ * operator `resetFailedModels` or a model change — never on a timer.
15430
+ *
15431
+ * `nodeId` is the node whose tower failed. The state lives in THAT node's
15432
+ * encoder process (in memory — a runner respawn clears it), and one node's
15433
+ * answer says nothing about another's.
15434
+ */
15435
+ var EmbeddingFailedModelSchema = object({
15436
+ modelId: string(),
15437
+ tower: EmbeddingTowerSchema,
15438
+ nodeId: string(),
15439
+ crashes: number(),
15440
+ windowMs: number(),
15441
+ sinceMs: number(),
15442
+ lastReason: string()
15443
+ });
15444
+ /**
15445
+ * A model whose REQUIREMENTS can never be met on this node (D649 round 5):
15446
+ * an id the catalog does not know, a model URL answering 404/410, a declared
15447
+ * sibling (the tokenizer) that is not there. After `attempts` failed builds
15448
+ * it is terminally `requirements-failed`, refused by name until an operator
15449
+ * `resetFailedModels` or a model change — the same exits as a crashed tower.
15450
+ */
15451
+ var EmbeddingFailedRequirementSchema = object({
15452
+ modelId: string(),
15453
+ tower: EmbeddingTowerSchema,
15454
+ nodeId: string(),
15455
+ attempts: number(),
15456
+ sinceMs: number(),
15457
+ lastReason: string()
15458
+ });
15325
15459
  var EmbeddingInfoSchema = object({
15326
15460
  modelId: string(),
15327
15461
  embeddingDim: number(),
15328
- ready: boolean()
15329
- });
15462
+ ready: boolean(),
15463
+ /**
15464
+ * The node that answered. Absent only from an encoder older than D649's
15465
+ * crash budget — whose `failedModels` is then UNKNOWN (D315), never "none".
15466
+ */
15467
+ nodeId: string().optional(),
15468
+ /**
15469
+ * Towers in `failed` on the answering node, each row naming it. `undefined`
15470
+ * means the node could not say (pre-budget encoder); a node that has the
15471
+ * budget always answers an array, empty included.
15472
+ */
15473
+ failedModels: array(EmbeddingFailedModelSchema).optional(),
15474
+ /** Models terminally `requirements-failed` on the answering node; same reading as above. */
15475
+ failedRequirements: array(EmbeddingFailedRequirementSchema).optional()
15476
+ });
15477
+ /**
15478
+ * The THREE codes an encoder refusal can carry (D649, D6). Same shape as
15479
+ * `VECTOR_INDEX_DIM_CONFLICT`: the code rides in the MESSAGE, because an
15480
+ * Error's own properties do not survive the UDS/tRPC hop and its message does,
15481
+ * and both sides go through the helpers below — the encoder builds the
15482
+ * refusal, the live detail plane classifies on it to count refusals per
15483
+ * camera instead of logging one warning per track. The bracketed token
15484
+ * carries the code, the model, the tower and the node so a consumer never
15485
+ * parses prose.
15486
+ *
15487
+ * - `EMBEDDING_MODEL_FAILED`: the tower crashed / failed to load past the crash
15488
+ * budget — terminal until `resetFailedModels` or a model change.
15489
+ * - `EMBEDDING_REQUIREMENTS_UNAVAILABLE`: files or runtime not there right now
15490
+ * (a download or Python that failed, or the cooldown after one) — the next
15491
+ * request past the cooldown tries again.
15492
+ * - `EMBEDDING_REQUIREMENTS_FAILED`: the requirements can never be met (unknown
15493
+ * id, 404/410, missing sibling) and the bounded attempts are spent —
15494
+ * terminal, same exits as a crashed tower.
15495
+ */
15496
+ var EMBEDDING_MODEL_FAILED = "embedding-model-failed";
15497
+ var EMBEDDING_REQUIREMENTS_UNAVAILABLE = "embedding-requirements-unavailable";
15498
+ var EMBEDDING_REQUIREMENTS_FAILED = "embedding-requirements-failed";
15499
+ _enum([
15500
+ EMBEDDING_MODEL_FAILED,
15501
+ EMBEDDING_REQUIREMENTS_UNAVAILABLE,
15502
+ EMBEDDING_REQUIREMENTS_FAILED
15503
+ ]);
15504
+ new RegExp(`\\[(${EMBEDDING_MODEL_FAILED}|${EMBEDDING_REQUIREMENTS_UNAVAILABLE}|${EMBEDDING_REQUIREMENTS_FAILED}) model=([^\\s\\]]+) tower=(image|text) node=([^\\s\\]]+)\\]`);
15330
15505
  method(object({
15331
15506
  crop: _instanceof(Uint8Array),
15332
15507
  width: number(),
15333
15508
  height: number()
15334
- }), EmbeddingResultSchema, { auth: "admin" }), method(object({ text: string() }), EmbeddingResultSchema, { auth: "admin" }), method(_void(), EmbeddingInfoSchema, { auth: "admin" });
15509
+ }), EmbeddingResultSchema, { auth: "admin" }), method(object({
15510
+ text: string(),
15511
+ modelId: string().min(1).optional()
15512
+ }), EmbeddingResultSchema, { auth: "admin" }), method(_void(), EmbeddingInfoSchema, { auth: "admin" }), method(object({ modelId: string().min(1).optional() }), object({
15513
+ cleared: array(string()),
15514
+ nodeId: string()
15515
+ }), {
15516
+ kind: "mutation",
15517
+ auth: "admin"
15518
+ });
15335
15519
  /**
15336
15520
  * `failure-contribution` — the capability an addon reports its OWN losses
15337
15521
  * through, per camera, with the denominator attached. It stores nothing.
@@ -18737,7 +18921,12 @@ var NcMediaPolicySchema = object({
18737
18921
  * the degrade engine, which drops the video and keeps the still.
18738
18922
  */
18739
18923
  clip: boolean().optional(),
18740
- /** Seconds of footage BEFORE / AFTER the event instant. Defaults 3 / 7. */
18924
+ /**
18925
+ * Seconds of footage BEFORE / AFTER the event instant. Absent =
18926
+ * {@link NC_CLIP_PRE_ROLL_DEFAULT_SEC} / {@link NC_CLIP_POST_ROLL_DEFAULT_SEC}
18927
+ * (4 / 6), resolved by the Notification Center at dispatch — deliberately not
18928
+ * a Zod default, so a rule that never chose keeps following the hub's.
18929
+ */
18741
18930
  clipPreRollSec: number().int().min(0).max(30).optional(),
18742
18931
  clipPostRollSec: number().int().min(0).max(30).optional(),
18743
18932
  /**
@@ -20115,6 +20304,163 @@ var OauthIntegrationDescriptorSchema = object({
20115
20304
  });
20116
20305
  method(_void(), OauthIntegrationDescriptorSchema, { auth: "admin" });
20117
20306
  /**
20307
+ * A model choice has a SCOPE, and a scope change has a workflow.
20308
+ *
20309
+ * ## The two scopes
20310
+ *
20311
+ * Almost every pipeline step's model is a **node** question: which build runs
20312
+ * fastest on THIS accelerator. Two nodes may legitimately answer differently —
20313
+ * a Coral runs the tflite SSD, a Mac runs the coreml YOLO — because the answers
20314
+ * are compared against nothing. They produce boxes, and a box is a box.
20315
+ *
20316
+ * Two steps are not like that. `face-embedding` and `clip-embedding` produce
20317
+ * **vectors that land in ONE shared index** (`identity_samples`,
20318
+ * `vec_object_clip`) and are ranked against each other by cosine similarity.
20319
+ * Cosine similarity between two encoders is not a weak signal, it is a
20320
+ * meaningless number — and nothing inside a vector can tell you it happened.
20321
+ * So for those two steps the model is a **cluster** question with exactly one
20322
+ * answer, stored in exactly one row.
20323
+ *
20324
+ * That is the same argument [D52](../../../../docs/decisions/adr-0052.md) makes
20325
+ * for the crop convention (`detail-crop.ts`), one level up: the crop decides
20326
+ * WHAT pixels the encoder sees, this decides WHICH encoder sees them. Both are
20327
+ * cluster-wide for the same reason and neither may become per-node.
20328
+ *
20329
+ * ## Why the registry lives here and not in the step catalog
20330
+ *
20331
+ * The scope itself is declared on the step, in
20332
+ * `detection-pipeline/registry/step-definitions.ts` (`StepDefinition.modelScope`)
20333
+ * — that is where a new step declares what kind of choice its model is. But the
20334
+ * catalog lives inside `addon-pipeline`, and three packages that must NOT
20335
+ * import an addon need to know the set: the orchestrator (which renders and
20336
+ * stores the row), post-analysis (which pins the row for a re-embed pass) and
20337
+ * admin-ui (which mirrors it). So the set is mirrored here, and
20338
+ * `scripts/check-cluster-model-scope.ts` fails the build when the two disagree.
20339
+ * A hand-copied key that stops matching is exactly how a setting silently stops
20340
+ * arriving while both sides still look correct.
20341
+ *
20342
+ * ## The defaults ARE today's behaviour
20343
+ *
20344
+ * {@link DEFAULT_CLUSTER_STEP_MODELS} reproduces the catalog `defaultModelId`
20345
+ * of both steps. Declaring the scope therefore changes nothing on the day it
20346
+ * ships: the cluster row starts out saying what every node was already doing.
20347
+ * Changing it is legitimate — that is what the knob is for — but it invalidates
20348
+ * the stored vectors of that surface, and must be followed by a re-embed pass
20349
+ * (`face-reembed-pass.ts`, `embedding-rebuild.ts`).
20350
+ */
20351
+ /**
20352
+ * Why the orchestrator cannot say which model the operator chose for a
20353
+ * cluster-scoped step (D651).
20354
+ */
20355
+ var CLUSTER_MODEL_CHOICE_UNREADABLE_REASONS = [
20356
+ "owner-not-ready",
20357
+ "store-read-failed",
20358
+ "three-way-read-unavailable",
20359
+ "invalid-stored",
20360
+ "not-cluster-scoped"
20361
+ ];
20362
+ /**
20363
+ * The cluster-scoped steps. Mirrored from the catalog's `modelScope: 'cluster'`
20364
+ * declarations and guarded by `scripts/check-cluster-model-scope.ts`.
20365
+ */
20366
+ var CLUSTER_MODEL_SCOPED_STEPS = [{
20367
+ stepId: "face-embedding",
20368
+ label: "Face recognition model",
20369
+ defaultModelId: "auraface-r100",
20370
+ indexName: "the enrolled face gallery",
20371
+ options: [
20372
+ {
20373
+ id: "auraface-r100",
20374
+ label: "AuraFace R100"
20375
+ },
20376
+ {
20377
+ id: "arcface-r100",
20378
+ label: "ArcFace (ResNet34)"
20379
+ },
20380
+ {
20381
+ id: "inception-resnet-v1",
20382
+ label: "Inception ResNet V1"
20383
+ }
20384
+ ]
20385
+ }, {
20386
+ stepId: "clip-embedding",
20387
+ label: "Semantic search model",
20388
+ defaultModelId: "mobileclip-s1",
20389
+ indexName: "the object semantic-search index",
20390
+ options: [
20391
+ {
20392
+ id: "mobileclip-s1",
20393
+ label: "MobileCLIP S1"
20394
+ },
20395
+ {
20396
+ id: "mobileclip-s2",
20397
+ label: "MobileCLIP S2"
20398
+ },
20399
+ {
20400
+ id: "siglip2-b16-224",
20401
+ label: "SigLIP2 B/16 (Apache-2.0)"
20402
+ }
20403
+ ]
20404
+ }];
20405
+ /**
20406
+ * Store identity of the cluster row inside `pipeline-orchestrator`'s GLOBAL
20407
+ * (cluster-wide) settings — the same owner, and for the same reason, as the
20408
+ * detail-crop convention: hub-resident and neutral between the addons that
20409
+ * read it.
20410
+ */
20411
+ var CLUSTER_MODEL_SECTION_ID = "cluster-step-models";
20412
+ /**
20413
+ * The settings key holding one step's cluster model choice.
20414
+ *
20415
+ * Step-qualified so the keys stay unique across the addon's whole schema, which
20416
+ * is what lets {@link pickClusterStepModels} walk every section instead of
20417
+ * trusting the section id.
20418
+ */
20419
+ function clusterModelSettingKey(stepId) {
20420
+ return `clusterModel:${stepId}`;
20421
+ }
20422
+ Object.freeze(Object.fromEntries(CLUSTER_MODEL_SCOPED_STEPS.map((s) => [s.stepId, s.defaultModelId])));
20423
+ function interpretStoredClusterModel(raw) {
20424
+ if (raw === void 0 || raw === null || raw === "") return { kind: "absent" };
20425
+ if (typeof raw === "string") return {
20426
+ kind: "chosen",
20427
+ modelId: raw
20428
+ };
20429
+ return {
20430
+ kind: "invalid",
20431
+ value: raw
20432
+ };
20433
+ }
20434
+ var CLUSTER_STEP_SETTING_FIELDS = [{
20435
+ stepId: "face-embedding",
20436
+ key: "minLandmarkFaceSize",
20437
+ label: "Min face size for recognition (detection px)",
20438
+ 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.",
20439
+ type: "slider",
20440
+ min: 0,
20441
+ max: 64,
20442
+ step: 2,
20443
+ default: 24
20444
+ }];
20445
+ function clusterStepSettingKey(stepId, fieldKey) {
20446
+ return `clusterStepSetting:${stepId}:${fieldKey}`;
20447
+ }
20448
+ var ClusterSettingNumberSchema = number().finite();
20449
+ function readClusterStepSettings(config) {
20450
+ const out = {};
20451
+ for (const field of CLUSTER_STEP_SETTING_FIELDS) {
20452
+ const parsed = ClusterSettingNumberSchema.safeParse(config[clusterStepSettingKey(field.stepId, field.key)]);
20453
+ const value = parsed.success ? parsed.data : field.default;
20454
+ const existing = out[field.stepId] ?? {};
20455
+ out[field.stepId] = {
20456
+ ...existing,
20457
+ [field.key]: value
20458
+ };
20459
+ }
20460
+ return out;
20461
+ }
20462
+ readClusterStepSettings({});
20463
+ /**
20118
20464
  * pipeline-analytics — device-scoped wrapper cap. Refines raw
20119
20465
  * per-frame detections emitted by the pipeline runner into tracked
20120
20466
  * objects, per-kind event collections (motion / object / audio), and
@@ -20930,7 +21276,8 @@ var MediaFileKindEnum = _enum([
20930
21276
  "thumbnailSmall",
20931
21277
  "confirmationCrop",
20932
21278
  "debugCrop",
20933
- "sceneFrame"
21279
+ "sceneFrame",
21280
+ "lastSceneFrame"
20934
21281
  ]);
20935
21282
  /**
20936
21283
  * One media row ON THE WIRE: what it is, how big it is, and WHERE ITS BYTES
@@ -21482,7 +21829,23 @@ var SearchObjectEventsInput = object({
21482
21829
  until: number().optional(),
21483
21830
  classFilter: string().optional(),
21484
21831
  limit: number().default(50),
21485
- minScore: number().min(0).max(1).default(.2)
21832
+ /**
21833
+ * An ABSOLUTE cosine floor. Optional since D649: a floor is a property of the
21834
+ * model (0.2 kept 96% of matched pairs on MobileCLIP S1 and 2.7% on SigLIP2),
21835
+ * so an absent one resolves to the searched model's own `searchMinScore`
21836
+ * scaled by `strictness`. A number sent here is honoured verbatim.
21837
+ */
21838
+ minScore: number().min(0).max(1).optional(),
21839
+ /** Model-relative floor — see `resolveClipSearchMinScore`. Default `loose`. */
21840
+ strictness: ClipSearchStrictnessSchema.optional(),
21841
+ /**
21842
+ * Search THIS CLIP model's index instead of the cluster row's. Every index a
21843
+ * model ever wrote stays searchable until it is wiped (the MobileCLIP index
21844
+ * through a SigLIP2 cutover and its rollback window). An id with no CLIP
21845
+ * metadata is refused; an id other than the cluster row's is ADMIN-only
21846
+ * (`model-override-requires-admin`).
21847
+ */
21848
+ modelId: string().min(1).optional()
21486
21849
  });
21487
21850
  var TrackCascadeCountsSchema = object({
21488
21851
  /** Persisted track roots deleted (authoritative). */
@@ -21617,7 +21980,41 @@ var RebuildObjectEmbeddingsInput = object({
21617
21980
  * comparable, so wiping and rebuilding is the only way to be sure every vector
21618
21981
  * means the same thing.
21619
21982
  */
21620
- var WipeObjectEmbeddingsResultSchema = object({ deleted: number() });
21983
+ var WipeObjectEmbeddingsResultSchema = object({
21984
+ deleted: number(),
21985
+ /**
21986
+ * CLIP indexes that could NOT be emptied (unreachable, or refused at their
21987
+ * dimension) — not a clean slate. Additive and optional (D649).
21988
+ */
21989
+ failedIndexes: array(string()).optional()
21990
+ });
21991
+ /**
21992
+ * Why a rebuild did not start, BY NAME — ONE vocabulary for both gates in front
21993
+ * of a pass (D649 over D651). A bare `started: false` read the same for "the
21994
+ * stores are booting" and "the cluster row names a model with no CLIP
21995
+ * metadata", which need different fixes.
21996
+ * - `stores-unavailable` — the analytics stores or the API are not up yet.
21997
+ * - the owner's confirmation of the cluster model (D651, `ClusterPinConfirmer`):
21998
+ * a pass that rewrites an index needs two agreeing reads of the OWNER's
21999
+ * answer (`pipelineOrchestrator.getClusterModelChoice`) — a pass that cannot
22000
+ * name its model does not run (D54):
22001
+ * - the owner's own `unreadable` reasons (`owner-not-ready`,
22002
+ * `store-read-failed`, `three-way-read-unavailable`, `invalid-stored`,
22003
+ * `not-cluster-scoped`);
22004
+ * - `method-unavailable` — the orchestrator predates the method;
22005
+ * - `read-failed` — the call failed or answered something unparseable;
22006
+ * - `reads-disagree` — the two reads named different choices.
22007
+ * - `not-a-clip-model` — the CONFIRMED id has no CLIP metadata, so there is no
22008
+ * index to fill and no dimension to check against (D649).
22009
+ */
22010
+ var RebuildObjectEmbeddingsRefusalSchema = _enum([
22011
+ "stores-unavailable",
22012
+ ...CLUSTER_MODEL_CHOICE_UNREADABLE_REASONS,
22013
+ "method-unavailable",
22014
+ "read-failed",
22015
+ "reads-disagree",
22016
+ "not-a-clip-model"
22017
+ ]);
21621
22018
  /**
21622
22019
  * Acknowledgement that a rebuild STARTED.
21623
22020
  *
@@ -21630,7 +22027,12 @@ var WipeObjectEmbeddingsResultSchema = object({ deleted: number() });
21630
22027
  var RebuildObjectEmbeddingsResultSchema = object({
21631
22028
  started: boolean(),
21632
22029
  /** True when a pass was already running; the new request is ignored. */
21633
- alreadyRunning: boolean()
22030
+ alreadyRunning: boolean(),
22031
+ /** Present when the pass refused to start — see `RebuildObjectEmbeddingsRefusalSchema`. */
22032
+ refusal: RebuildObjectEmbeddingsRefusalSchema.optional(),
22033
+ /** The CLIP model the pass pinned, and the index it fills (when it started). */
22034
+ modelId: string().optional(),
22035
+ index: string().optional()
21634
22036
  });
21635
22037
  var RebuildStatusSchema = object({
21636
22038
  running: boolean(),
@@ -21664,7 +22066,11 @@ var RebuildStatusSchema = object({
21664
22066
  startedAtMs: number().nullable(),
21665
22067
  finishedAtMs: number().nullable(),
21666
22068
  /** Present when the pass ended by throwing. */
21667
- error: string().nullable()
22069
+ error: string().nullable(),
22070
+ /** The CLIP model the current / last pass pinned (D54), `null` before any. */
22071
+ modelId: string().nullable(),
22072
+ /** The index that pass fills — `ClipModelMeta.vectorIndex` of `modelId` (D649). */
22073
+ index: string().nullable()
21668
22074
  });
21669
22075
  /**
21670
22076
  * Acknowledgement that a debug-media reclaim STARTED.
@@ -21985,7 +22391,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
21985
22391
  deviceId: number(),
21986
22392
  ownerKind: MediaPresenceOwnerKindSchema,
21987
22393
  ownerIds: array(string()).max(5e3)
21988
- }), array(string()).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
22394
+ }), array(string()).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly(), { caller: "required" }), method(object({}), WipeObjectEmbeddingsResultSchema, {
21989
22395
  kind: "mutation",
21990
22396
  auth: "admin"
21991
22397
  }), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
@@ -22604,7 +23010,14 @@ var pipelineExecutorCapability = {
22604
23010
  */
22605
23011
  inferCached: method(object({
22606
23012
  stepId: string(),
22607
- frameId: number().int()
23013
+ frameId: number().int(),
23014
+ /**
23015
+ * The model to time — REQUIRED (D658). Without it the pool answered the
23016
+ * step's ACTIVE variant (the first loaded after boot), so a benchmark
23017
+ * of model B silently timed model A. Callers pass the `rootModelId`
23018
+ * their warmup `runPipeline` reported: the model that actually ran.
23019
+ */
23020
+ modelId: string().min(1)
22608
23021
  }), record(string(), unknown()), { kind: "mutation" }),
22609
23022
  /**
22610
23023
  * Release a cached frame from the Python pool's memory.
@@ -23256,6 +23669,17 @@ var RunnerFrameSourceSchema = discriminatedUnion("kind", [object({ kind: literal
23256
23669
  hubHostnameOverride: string().optional()
23257
23670
  })]).describe("Per-camera frame-source mode for the runner (P2c)");
23258
23671
  /**
23672
+ * What ONE step runs with on ONE roster device for THIS camera (D650): the
23673
+ * device's per-(node,device) base with the camera's override for that device
23674
+ * on top — the same `override ?? base` merge the orchestrator applies to the
23675
+ * elected device (`applyDeviceProvisioning`). Absent `modelId` ⇒ the executor
23676
+ * resolves the device format's default, exactly as on the elected device.
23677
+ */
23678
+ var RunnerJumpStepProvisioningSchema = object({
23679
+ modelId: string().optional(),
23680
+ settings: record(string(), unknown()).optional()
23681
+ });
23682
+ /**
23259
23683
  * One ENABLED inference device on the runner's node, as the step-tree
23260
23684
  * device-jump resolver sees it (phase 1). The orchestrator populates this
23261
23685
  * roster on the attach payload whenever the camera is elected onto a specific
@@ -23267,7 +23691,16 @@ var RunnerFrameSourceSchema = discriminatedUnion("kind", [object({ kind: literal
23267
23691
  var RunnerInferenceDeviceSchema = object({
23268
23692
  deviceKey: string(),
23269
23693
  weight: number().positive().default(1),
23270
- maxSessions: number().int().positive().nullable().default(null)
23694
+ maxSessions: number().int().positive().nullable().default(null),
23695
+ /**
23696
+ * Per-step provisioning for this camera on THIS device (D650), keyed by step
23697
+ * id. A step the runner jumps here runs with these fields, never with the
23698
+ * elected device's: `modelId` / `settings` are REPLACED on the jumped subtree
23699
+ * (a step absent from the map runs with neither — the target format default).
23700
+ * Absent field ⇒ an orchestrator that predates D650: the jumped step keeps
23701
+ * the elected device's provisioning, as before.
23702
+ */
23703
+ steps: record(string(), RunnerJumpStepProvisioningSchema).optional()
23271
23704
  });
23272
23705
  /**
23273
23706
  * Camera assignment payload sent by `addon-pipeline-orchestrator` to a
@@ -24343,6 +24776,28 @@ var NodeInferenceDeviceSchema = object({
24343
24776
  */
24344
24777
  exclusion: InferenceDeviceExclusionReasonSchema.nullable()
24345
24778
  });
24779
+ /**
24780
+ * The operator's model choice for one cluster-scoped step, answered by the
24781
+ * addon that OWNS the `clusterModel:*` settings (D651). Three answers, never
24782
+ * folded: a written choice, a key nobody wrote (the cluster runs the default
24783
+ * BY ABSENCE), or no answer. A caller that rewrites an index acts only on the
24784
+ * first two, read twice and agreeing.
24785
+ */
24786
+ var ClusterModelChoiceSchema = discriminatedUnion("kind", [
24787
+ object({
24788
+ kind: literal("stored"),
24789
+ modelId: string()
24790
+ }),
24791
+ object({
24792
+ kind: literal("absent"),
24793
+ defaultModelId: string()
24794
+ }),
24795
+ object({
24796
+ kind: literal("unreadable"),
24797
+ reason: _enum(CLUSTER_MODEL_CHOICE_UNREADABLE_REASONS),
24798
+ error: string().optional()
24799
+ })
24800
+ ]);
24346
24801
  var NodeInferenceDevicesSchema = object({
24347
24802
  nodeId: string(),
24348
24803
  /** False when the node's platform-probe was unreachable (no live device set). */
@@ -24439,9 +24894,19 @@ var pipelineOrchestratorCapability = {
24439
24894
  * Trigger a full rebalance pass. Iterates over all assigned cameras,
24440
24895
  * recomputes the optimal agent for each one based on current load +
24441
24896
  * hardware affinity, and migrates cameras whose current agent is no
24442
- * longer optimal. Pinned cameras are never migrated.
24897
+ * longer optimal. Pinned cameras are never migrated. `skipped` names the
24898
+ * cameras the pass wanted to move and could not, with why — a target node
24899
+ * that has not answered its catalog / device health yet, or a device that
24900
+ * cannot run the camera's root (D650).
24443
24901
  */
24444
- rebalance: method(_void(), object({ migrated: number() }), {
24902
+ rebalance: method(_void(), object({
24903
+ migrated: number(),
24904
+ skipped: array(object({
24905
+ deviceId: number(),
24906
+ targetNodeId: string(),
24907
+ reason: string()
24908
+ })).default([])
24909
+ }), {
24445
24910
  kind: "mutation",
24446
24911
  auth: "admin"
24447
24912
  }),
@@ -24451,6 +24916,13 @@ var pipelineOrchestratorCapability = {
24451
24916
  getPipelineAssignment: method(object({ deviceId: number() }), PipelineAssignmentSchema.nullable()),
24452
24917
  /** Get per-agent load summaries used by the load balancer + dashboards. */
24453
24918
  getAgentLoad: method(_void(), array(AgentLoadSummarySchema).readonly()),
24919
+ /**
24920
+ * The operator's STORED model choice for one cluster-scoped step (D651).
24921
+ * Read three-way from this addon's own store, only once its boot has
24922
+ * settled. Unlike `addonSettings.getGlobalSettings`, it never answers a
24923
+ * default as though someone chose it.
24924
+ */
24925
+ getClusterModelChoice: method(object({ stepId: string() }), ClusterModelChoiceSchema),
24454
24926
  /** Aggregate global metrics across the whole detection cluster. */
24455
24927
  getGlobalMetrics: method(_void(), GlobalMetricsSchema),
24456
24928
  /**
@@ -27003,6 +27475,177 @@ DeviceType.Camera, method(object({
27003
27475
  auth: "protected"
27004
27476
  });
27005
27477
  /**
27478
+ * LA tabella "quale booleano di questo tipo di device conta come ALTO", e il
27479
+ * valutatore puro del suo FRONTE.
27480
+ *
27481
+ * Viveva dentro il builtin virtual-doorbell
27482
+ * (`@camstack/system` — `builtins/doorbell/trigger-engine.ts`) e i suoi
27483
+ * predicati erano privati al modulo. Il recorder ne ha bisogno per il trigger
27484
+ * `RecordingTriggers.sensorDeviceIds`: copiarla avrebbe creato la SECONDA
27485
+ * tabella, che diverge alla prima cap aggiunta e il cui sintomo — "il sensore
27486
+ * fa suonare il campanello ma non registra" — è esattamente D62. Quindi si
27487
+ * SPOSTA qui e il doorbell la ri-esporta.
27488
+ *
27489
+ * ⚠ NON è `DEVICE_STATE_READERS` (`catalogs/device-state-vocabulary.ts`), e le
27490
+ * due non vanno unificate: quella risponde a "qual è la PAROLA di stato per una
27491
+ * regola" (e include `presence`, `cover`, `alarm-panel`), questa a "qual è il
27492
+ * booleano il cui FRONTE conta". Vocabolari deliberatamente diversi.
27493
+ */
27494
+ /**
27495
+ * Known binary / switch source caps → the boolean slice field whose
27496
+ * false→true rise counts as ACTIVE. Every entry is "fire on active".
27497
+ * Sensors whose "active" reading is not a plain boolean (presence's string
27498
+ * state, connectivity's connected flag) are deliberately excluded — a
27499
+ * reconnect is not a doorbell press, and it is not a recording either.
27500
+ */
27501
+ var SOURCE_CAP_ACTIVE_FIELD = {
27502
+ contact: "entryOpen",
27503
+ binary: "on",
27504
+ switch: "on",
27505
+ motion: "detected",
27506
+ flood: "flooded",
27507
+ gas: "detected",
27508
+ smoke: "detected",
27509
+ "carbon-monoxide": "detected",
27510
+ vibration: "detected",
27511
+ tamper: "tampered"
27512
+ };
27513
+ object({
27514
+ /** Ms epoch of the last press. null = never observed since this provider started. */
27515
+ lastPressedAt: number().nullable(),
27516
+ /** Counter since provider start. Resets on reboot. Useful for metrics/debug. */
27517
+ pressCountSinceStart: number()
27518
+ });
27519
+ object({
27520
+ deviceId: number(),
27521
+ timestamp: number()
27522
+ });
27523
+ /**
27524
+ * The `doorbell-source` extension slot's editable value (D648): the camera's
27525
+ * virtual-doorbell trigger devices, exactly what the wrapper's
27526
+ * `doorbellSources` persists (as `{ deviceId }` records).
27527
+ */
27528
+ var DoorbellSourceSlotValueSchema = object({ deviceIds: array(number().int().positive()) });
27529
+ DeviceType.Camera;
27530
+ DeviceType.Button, DeviceType.Camera;
27531
+ var ExtensionSlotIdSchema = _enum(["doorbell-source", "linked-devices"]);
27532
+ DeviceType.Camera, DeviceType.Camera;
27533
+ /** A slot's editable value, discriminated on `slot`. */
27534
+ var ExtensionSlotValueSchema = discriminatedUnion("slot", [DoorbellSourceSlotValueSchema.extend({ slot: literal("doorbell-source") }), LinkedDevicesSlotValueSchema.extend({ slot: literal("linked-devices") })]);
27535
+ DeviceType.Camera;
27536
+ var ExtensionSourceAvailabilitySchema = discriminatedUnion("state", [object({ state: literal("ok") }), object({
27537
+ state: literal("unreachable"),
27538
+ reason: string()
27539
+ })]);
27540
+ /** One provider of slots — the D554 source-listing row. */
27541
+ var ExtensionSourceSchema = object({
27542
+ /** The routing prefix of every `slotRef` this provider serves. */
27543
+ source: string(),
27544
+ label: string(),
27545
+ /** Stamped by the dispatcher from the registry (D557). */
27546
+ addonId: string().optional(),
27547
+ availability: ExtensionSourceAvailabilitySchema
27548
+ });
27549
+ /**
27550
+ * How one picked source stands. `missing` — no such device (deleted, or an id
27551
+ * that never existed); `foreign` — a device the slot's owner cannot read from
27552
+ * (slice 2's cross-owner graft source); `unavailable` — it exists but is not a
27553
+ * valid source now (a container, which is never a pick). A source in any of
27554
+ * these states is SHOWN, never silently dropped or re-pointed.
27555
+ */
27556
+ var ExtensionSourceHealthSchema = _enum([
27557
+ "ok",
27558
+ "missing",
27559
+ "foreign",
27560
+ "unavailable"
27561
+ ]);
27562
+ var ExtensionSlotSourceSchema = object({
27563
+ deviceId: number().int(),
27564
+ /** The device's name, or null when it could not be resolved (`missing`). */
27565
+ name: string().nullable(),
27566
+ health: ExtensionSourceHealthSchema,
27567
+ detail: string().optional()
27568
+ });
27569
+ /**
27570
+ * A DIFFERENT authority that gates whether the slot's sources do anything —
27571
+ * reported read-only, named, never written through this contract (D62). The
27572
+ * virtual doorbell's sources ring only while the wrapper is bound to the
27573
+ * camera, and that binding belongs to `deviceManager.setWrapperActive`.
27574
+ * `unknown` means the gate could not be read; it is not "inactive" (D315).
27575
+ */
27576
+ var ExtensionSlotActivationSchema = object({
27577
+ state: _enum([
27578
+ "active",
27579
+ "inactive",
27580
+ "unknown"
27581
+ ]),
27582
+ authority: string(),
27583
+ detail: string().optional()
27584
+ });
27585
+ var ExtensionSlotRowSchema = discriminatedUnion("state", [object({
27586
+ state: literal("ok"),
27587
+ /** `<source>:<slotId>` — the routing key for `listCandidates` / `setSlot`. */
27588
+ slotRef: string(),
27589
+ slot: ExtensionSlotIdSchema,
27590
+ /** Names the store written (D62), e.g. `virtual-doorbell:doorbellSources`. */
27591
+ authority: string(),
27592
+ /** graft: its switch. association: null — removing every source is "off". */
27593
+ enabled: boolean().nullable(),
27594
+ /** graft: the cap registered on the host. association: null. */
27595
+ projects: string().nullable(),
27596
+ activation: ExtensionSlotActivationSchema.nullable(),
27597
+ value: ExtensionSlotValueSchema,
27598
+ sources: array(ExtensionSlotSourceSchema),
27599
+ /**
27600
+ * Devices the owner links to the host ON ITS OWN, by structure, and that
27601
+ * are therefore not in `value` (D655): a camera's device-tree children, and
27602
+ * the child a picked container stands for (D356 — a container is never a
27603
+ * pick, its children are). Read-only; the editor shows them and may offer
27604
+ * per-device options on them (the linked-devices tracked-event opt-in).
27605
+ * Absent when the owner links nothing beyond the picks, and from an owner
27606
+ * older than D655.
27607
+ */
27608
+ impliedSources: array(ExtensionSlotSourceSchema).optional()
27609
+ }), object({
27610
+ state: literal("unreachable"),
27611
+ addonId: string(),
27612
+ reason: string()
27613
+ })]);
27614
+ var ExtensionCandidatesSchema = object({ candidates: array(object({
27615
+ deviceId: number().int(),
27616
+ name: string(),
27617
+ type: string(),
27618
+ location: string().nullable(),
27619
+ /** The container a child belongs to — "Spotlight 2" alone does not say which. */
27620
+ parentName: string().nullable(),
27621
+ /** Pre-marked by the owner's suggestion rule. NEVER saved on its own (D356). */
27622
+ suggested: boolean(),
27623
+ /** Why it is suggested ("same location"), null when it is not. */
27624
+ reason: string().nullable()
27625
+ })) });
27626
+ /**
27627
+ * What a write did. `none` — applied in place. `host` / `parent` — the owner
27628
+ * reloaded the host (or, for an accessory child, its parent) because the
27629
+ * write changed what the host REGISTERS (a graft switched on or off, slice 2).
27630
+ */
27631
+ var ExtensionSetResultSchema = object({
27632
+ applied: literal(true),
27633
+ reload: _enum([
27634
+ "none",
27635
+ "host",
27636
+ "parent"
27637
+ ])
27638
+ });
27639
+ var ExtensionSlotRefInputSchema = object({
27640
+ deviceId: number().int(),
27641
+ slotRef: string().min(1)
27642
+ });
27643
+ var ExtensionSetSlotInputSchema = ExtensionSlotRefInputSchema.extend({ value: ExtensionSlotValueSchema });
27644
+ method(object({ deviceId: number().int() }), array(ExtensionSourceSchema)), method(object({ deviceId: number().int() }), array(ExtensionSlotRowSchema)), method(ExtensionSlotRefInputSchema, ExtensionCandidatesSchema), method(ExtensionSetSlotInputSchema, ExtensionSetResultSchema, {
27645
+ kind: "mutation",
27646
+ auth: "admin"
27647
+ });
27648
+ /**
27006
27649
  * Optional client-side hints sent at session creation to help the provider
27007
27650
  * pick the best native source. All fields optional — a viewer that knows
27008
27651
  * nothing still gets a sane default. (Relocated from the retired `webrtc`
@@ -28791,17 +29434,6 @@ object({
28791
29434
  deviceId: number(),
28792
29435
  status: DeviceStatusSchema
28793
29436
  });
28794
- object({
28795
- /** Ms epoch of the last press. null = never observed since this provider started. */
28796
- lastPressedAt: number().nullable(),
28797
- /** Counter since provider start. Resets on reboot. Useful for metrics/debug. */
28798
- pressCountSinceStart: number()
28799
- });
28800
- object({
28801
- deviceId: number(),
28802
- timestamp: number()
28803
- });
28804
- DeviceType.Button, DeviceType.Camera;
28805
29437
  /**
28806
29438
  * Enum-state sensor — a string value picked from a finite option set.
28807
29439
  * Drives HA `sensor` entries with `state_class: enum` (HVAC action
@@ -35654,42 +36286,6 @@ var zoneRulesCapability = {
35654
36286
  durability: "restored"
35655
36287
  };
35656
36288
  /**
35657
- * LA tabella "quale booleano di questo tipo di device conta come ALTO", e il
35658
- * valutatore puro del suo FRONTE.
35659
- *
35660
- * Viveva dentro il builtin virtual-doorbell
35661
- * (`@camstack/system` — `builtins/doorbell/trigger-engine.ts`) e i suoi
35662
- * predicati erano privati al modulo. Il recorder ne ha bisogno per il trigger
35663
- * `RecordingTriggers.sensorDeviceIds`: copiarla avrebbe creato la SECONDA
35664
- * tabella, che diverge alla prima cap aggiunta e il cui sintomo — "il sensore
35665
- * fa suonare il campanello ma non registra" — è esattamente D62. Quindi si
35666
- * SPOSTA qui e il doorbell la ri-esporta.
35667
- *
35668
- * ⚠ NON è `DEVICE_STATE_READERS` (`catalogs/device-state-vocabulary.ts`), e le
35669
- * due non vanno unificate: quella risponde a "qual è la PAROLA di stato per una
35670
- * regola" (e include `presence`, `cover`, `alarm-panel`), questa a "qual è il
35671
- * booleano il cui FRONTE conta". Vocabolari deliberatamente diversi.
35672
- */
35673
- /**
35674
- * Known binary / switch source caps → the boolean slice field whose
35675
- * false→true rise counts as ACTIVE. Every entry is "fire on active".
35676
- * Sensors whose "active" reading is not a plain boolean (presence's string
35677
- * state, connectivity's connected flag) are deliberately excluded — a
35678
- * reconnect is not a doorbell press, and it is not a recording either.
35679
- */
35680
- var SOURCE_CAP_ACTIVE_FIELD = {
35681
- contact: "entryOpen",
35682
- binary: "on",
35683
- switch: "on",
35684
- motion: "detected",
35685
- flood: "flooded",
35686
- gas: "detected",
35687
- smoke: "detected",
35688
- "carbon-monoxide": "detected",
35689
- vibration: "detected",
35690
- tamper: "tampered"
35691
- };
35692
- /**
35693
36289
  * Accessory device helpers — shared across drivers.
35694
36290
  *
35695
36291
  * Many vendor-specific drivers register accessory child devices on
@@ -38517,6 +39113,30 @@ Object.freeze({
38517
39113
  addonId: null,
38518
39114
  access: "create"
38519
39115
  },
39116
+ "deviceExtension.listCandidates": {
39117
+ capName: "device-extension",
39118
+ capScope: "device",
39119
+ addonId: null,
39120
+ access: "view"
39121
+ },
39122
+ "deviceExtension.listSlots": {
39123
+ capName: "device-extension",
39124
+ capScope: "device",
39125
+ addonId: null,
39126
+ access: "view"
39127
+ },
39128
+ "deviceExtension.listSources": {
39129
+ capName: "device-extension",
39130
+ capScope: "device",
39131
+ addonId: null,
39132
+ access: "view"
39133
+ },
39134
+ "deviceExtension.setSlot": {
39135
+ capName: "device-extension",
39136
+ capScope: "device",
39137
+ addonId: null,
39138
+ access: "create"
39139
+ },
38520
39140
  "deviceManager.addLocation": {
38521
39141
  capName: "device-manager",
38522
39142
  capScope: "system",
@@ -38589,12 +39209,6 @@ Object.freeze({
38589
39209
  addonId: null,
38590
39210
  access: "create"
38591
39211
  },
38592
- "deviceManager.applyDeviceSettingsPatch": {
38593
- capName: "device-manager",
38594
- capScope: "system",
38595
- addonId: null,
38596
- access: "create"
38597
- },
38598
39212
  "deviceManager.applyInitialMeta": {
38599
39213
  capName: "device-manager",
38600
39214
  capScope: "system",
@@ -38697,12 +39311,6 @@ Object.freeze({
38697
39311
  addonId: null,
38698
39312
  access: "view"
38699
39313
  },
38700
- "deviceManager.getDeviceLiveContribution": {
38701
- capName: "device-manager",
38702
- capScope: "system",
38703
- addonId: null,
38704
- access: "view"
38705
- },
38706
39314
  "deviceManager.getDeviceLiveInfoAggregate": {
38707
39315
  capName: "device-manager",
38708
39316
  capScope: "system",
@@ -38715,12 +39323,6 @@ Object.freeze({
38715
39323
  addonId: null,
38716
39324
  access: "view"
38717
39325
  },
38718
- "deviceManager.getDeviceSettingsContribution": {
38719
- capName: "device-manager",
38720
- capScope: "system",
38721
- addonId: null,
38722
- access: "view"
38723
- },
38724
39326
  "deviceManager.getDeviceStatusAggregate": {
38725
39327
  capName: "device-manager",
38726
39328
  capScope: "system",
@@ -39225,24 +39827,6 @@ Object.freeze({
39225
39827
  addonId: null,
39226
39828
  access: "view"
39227
39829
  },
39228
- "doorbell.applyDeviceSettingsPatch": {
39229
- capName: "doorbell",
39230
- capScope: "device",
39231
- addonId: null,
39232
- access: "create"
39233
- },
39234
- "doorbell.getDeviceLiveContribution": {
39235
- capName: "doorbell",
39236
- capScope: "device",
39237
- addonId: null,
39238
- access: "view"
39239
- },
39240
- "doorbell.getDeviceSettingsContribution": {
39241
- capName: "doorbell",
39242
- capScope: "device",
39243
- addonId: null,
39244
- access: "view"
39245
- },
39246
39830
  "doorbell.getStatus": {
39247
39831
  capName: "doorbell",
39248
39832
  capScope: "device",
@@ -39267,6 +39851,12 @@ Object.freeze({
39267
39851
  addonId: null,
39268
39852
  access: "view"
39269
39853
  },
39854
+ "embeddingEncoder.resetFailedModels": {
39855
+ capName: "embedding-encoder",
39856
+ capScope: "system",
39857
+ addonId: null,
39858
+ access: "delete"
39859
+ },
39270
39860
  "enumSensor.getStatus": {
39271
39861
  capName: "enum-sensor",
39272
39862
  capScope: "device",
@@ -41601,6 +42191,12 @@ Object.freeze({
41601
42191
  addonId: null,
41602
42192
  access: "view"
41603
42193
  },
42194
+ "pipelineOrchestrator.getClusterModelChoice": {
42195
+ capName: "pipeline-orchestrator",
42196
+ capScope: "system",
42197
+ addonId: null,
42198
+ access: "view"
42199
+ },
41604
42200
  "pipelineOrchestrator.getDeviceLiveContribution": {
41605
42201
  capName: "pipeline-orchestrator",
41606
42202
  capScope: "system",
@@ -44426,6 +45022,26 @@ Object.freeze({
44426
45022
  form: "single",
44427
45023
  optional: false
44428
45024
  }],
45025
+ "deviceExtension.listCandidates": [{
45026
+ name: "deviceId",
45027
+ form: "single",
45028
+ optional: false
45029
+ }],
45030
+ "deviceExtension.listSlots": [{
45031
+ name: "deviceId",
45032
+ form: "single",
45033
+ optional: false
45034
+ }],
45035
+ "deviceExtension.listSources": [{
45036
+ name: "deviceId",
45037
+ form: "single",
45038
+ optional: false
45039
+ }],
45040
+ "deviceExtension.setSlot": [{
45041
+ name: "deviceId",
45042
+ form: "single",
45043
+ optional: false
45044
+ }],
44429
45045
  "deviceManager.adoptionRelease": [{
44430
45046
  name: "camDeviceId",
44431
45047
  form: "single",
@@ -46885,135 +47501,21 @@ TimelapseRuleInputSchema.extend({
46885
47501
  createdAt: number(),
46886
47502
  updatedAt: number()
46887
47503
  });
47504
+ /** True when `model` ships a build for `format`. */
47505
+ function hasBuild(model, format) {
47506
+ return Boolean(model.formats[format]);
47507
+ }
46888
47508
  /**
46889
- * A model choice has a SCOPE, and a scope change has a workflow.
46890
- *
46891
- * ## The two scopes
46892
- *
46893
- * Almost every pipeline step's model is a **node** question: which build runs
46894
- * fastest on THIS accelerator. Two nodes may legitimately answer differently —
46895
- * a Coral runs the tflite SSD, a Mac runs the coreml YOLO — because the answers
46896
- * are compared against nothing. They produce boxes, and a box is a box.
46897
- *
46898
- * Two steps are not like that. `face-embedding` and `clip-embedding` produce
46899
- * **vectors that land in ONE shared index** (`identity_samples`,
46900
- * `vec_object_clip`) and are ranked against each other by cosine similarity.
46901
- * Cosine similarity between two encoders is not a weak signal, it is a
46902
- * meaningless number — and nothing inside a vector can tell you it happened.
46903
- * So for those two steps the model is a **cluster** question with exactly one
46904
- * answer, stored in exactly one row.
46905
- *
46906
- * That is the same argument [D52](../../../../docs/decisions/adr-0052.md) makes
46907
- * for the crop convention (`detail-crop.ts`), one level up: the crop decides
46908
- * WHAT pixels the encoder sees, this decides WHICH encoder sees them. Both are
46909
- * cluster-wide for the same reason and neither may become per-node.
46910
- *
46911
- * ## Why the registry lives here and not in the step catalog
46912
- *
46913
- * The scope itself is declared on the step, in
46914
- * `detection-pipeline/registry/step-definitions.ts` (`StepDefinition.modelScope`)
46915
- * — that is where a new step declares what kind of choice its model is. But the
46916
- * catalog lives inside `addon-pipeline`, and three packages that must NOT
46917
- * import an addon need to know the set: the orchestrator (which renders and
46918
- * stores the row), post-analysis (which pins the row for a re-embed pass) and
46919
- * admin-ui (which mirrors it). So the set is mirrored here, and
46920
- * `scripts/check-cluster-model-scope.ts` fails the build when the two disagree.
46921
- * A hand-copied key that stops matching is exactly how a setting silently stops
46922
- * arriving while both sides still look correct.
46923
- *
46924
- * ## The defaults ARE today's behaviour
46925
- *
46926
- * {@link DEFAULT_CLUSTER_STEP_MODELS} reproduces the catalog `defaultModelId`
46927
- * of both steps. Declaring the scope therefore changes nothing on the day it
46928
- * ships: the cluster row starts out saying what every node was already doing.
46929
- * Changing it is legitimate — that is what the knob is for — but it invalidates
46930
- * the stored vectors of that surface, and must be followed by a re-embed pass
46931
- * (`face-reembed-pass.ts`, `embedding-rebuild.ts`).
46932
- */
46933
- /**
46934
- * The cluster-scoped steps. Mirrored from the catalog's `modelScope: 'cluster'`
46935
- * declarations and guarded by `scripts/check-cluster-model-scope.ts`.
46936
- */
46937
- var CLUSTER_MODEL_SCOPED_STEPS = [{
46938
- stepId: "face-embedding",
46939
- label: "Face recognition model",
46940
- defaultModelId: "arcface-r100",
46941
- indexName: "the enrolled face gallery",
46942
- options: [
46943
- {
46944
- id: "arcface-r100",
46945
- label: "ArcFace (ResNet34)"
46946
- },
46947
- {
46948
- id: "auraface-r100",
46949
- label: "AuraFace R100"
46950
- },
46951
- {
46952
- id: "inception-resnet-v1",
46953
- label: "Inception ResNet V1"
46954
- }
46955
- ]
46956
- }, {
46957
- stepId: "clip-embedding",
46958
- label: "Semantic search model",
46959
- defaultModelId: "mobileclip-s1",
46960
- indexName: "the object semantic-search index",
46961
- options: [{
46962
- id: "mobileclip-s1",
46963
- label: "MobileCLIP S1"
46964
- }, {
46965
- id: "mobileclip-s2",
46966
- label: "MobileCLIP S2"
46967
- }]
46968
- }];
46969
- /**
46970
- * Store identity of the cluster row inside `pipeline-orchestrator`'s GLOBAL
46971
- * (cluster-wide) settings — the same owner, and for the same reason, as the
46972
- * detail-crop convention: hub-resident and neutral between the addons that
46973
- * read it.
47509
+ * Can a pool of `format` run a step whose catalog is `models`, given the model
47510
+ * the step names (`null`/absent = unpinned, the format default)?
46974
47511
  */
46975
- var CLUSTER_MODEL_SECTION_ID = "cluster-step-models";
46976
- /**
46977
- * The settings key holding one step's cluster model choice.
46978
- *
46979
- * Step-qualified so the keys stay unique across the addon's whole schema, which
46980
- * is what lets {@link pickClusterStepModels} walk every section instead of
46981
- * trusting the section id.
46982
- */
46983
- function clusterModelSettingKey(stepId) {
46984
- return `clusterModel:${stepId}`;
46985
- }
46986
- Object.freeze(Object.fromEntries(CLUSTER_MODEL_SCOPED_STEPS.map((s) => [s.stepId, s.defaultModelId])));
46987
- string().min(1);
46988
- var CLUSTER_STEP_SETTING_FIELDS = [{
46989
- stepId: "face-embedding",
46990
- key: "minLandmarkFaceSize",
46991
- label: "Min face size for recognition (detection px)",
46992
- 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.",
46993
- type: "slider",
46994
- min: 0,
46995
- max: 64,
46996
- step: 2,
46997
- default: 24
46998
- }];
46999
- function clusterStepSettingKey(stepId, fieldKey) {
47000
- return `clusterStepSetting:${stepId}:${fieldKey}`;
47001
- }
47002
- var ClusterSettingNumberSchema = number().finite();
47003
- function readClusterStepSettings(config) {
47004
- const out = {};
47005
- for (const field of CLUSTER_STEP_SETTING_FIELDS) {
47006
- const parsed = ClusterSettingNumberSchema.safeParse(config[clusterStepSettingKey(field.stepId, field.key)]);
47007
- const value = parsed.success ? parsed.data : field.default;
47008
- const existing = out[field.stepId] ?? {};
47009
- out[field.stepId] = {
47010
- ...existing,
47011
- [field.key]: value
47012
- };
47512
+ function stepModelRunsOnFormat(models, modelId, format) {
47513
+ if (modelId !== void 0 && modelId !== null) {
47514
+ const named = models.find((m) => m.id === modelId);
47515
+ if (named !== void 0 && hasBuild(named, format)) return true;
47013
47516
  }
47014
- return out;
47517
+ return models.some((m) => m.legacy !== true && hasBuild(m, format));
47015
47518
  }
47016
- readClusterStepSettings({});
47017
47519
  /**
47018
47520
  * THE detail-crop convention — the single derivation of the rectangle a
47019
47521
  * detail/enrichment step (clip-embedding, face-detection, plate-detection…)
@@ -47358,6 +47860,63 @@ function deviceBackendToFormat(backend) {
47358
47860
  return DEVICE_BACKEND_TO_FORMAT[backend] ?? "onnx";
47359
47861
  }
47360
47862
  //#endregion
47863
+ //#region src/cluster-model-choice.ts
47864
+ /**
47865
+ * The owner's answer to "which model did the operator choose for this
47866
+ * cluster-scoped step?" (D651 fix round 3).
47867
+ *
47868
+ * The orchestrator owns `clusterModel:*`: it renders the form and stores the
47869
+ * row. A consumer that REWRITES an index on this answer (the face re-embed,
47870
+ * the CLIP rebuild) must not read the orchestrator's private store layout,
47871
+ * and must not read its hydrated settings view either — that answers
47872
+ * `value ?? default`, so a default nobody chose and a failed read both look
47873
+ * like a choice. So the owner answers, from a three-way read of its own store.
47874
+ *
47875
+ * The stored VALUE is read with the runtime's own interpretation
47876
+ * (`interpretStoredClusterModel`, fix round 4): a cleared field (`null`, `''`)
47877
+ * is absent, any non-empty string is the choice — verbatim, as the runner pins
47878
+ * it — and only a non-string is `invalid-stored`. Checking the id against the
47879
+ * dropdown here would give one stored key two meanings: the runner deliberately
47880
+ * pins an id the catalog does not list (a custom encoder), so an owner that
47881
+ * called it invalid would refuse a pass on the very model the fleet is running.
47882
+ */
47883
+ /**
47884
+ * Turn a three-way read of `clusterModel:<stepId>` into the cap answer.
47885
+ * `'owner-not-ready'` is the caller's statement that its boot has not settled.
47886
+ */
47887
+ function decideClusterModelChoice(stepId, read) {
47888
+ const step = CLUSTER_MODEL_SCOPED_STEPS.find((s) => s.stepId === stepId);
47889
+ if (step === void 0) return {
47890
+ kind: "unreadable",
47891
+ reason: "not-cluster-scoped"
47892
+ };
47893
+ if (read === "owner-not-ready") return {
47894
+ kind: "unreadable",
47895
+ reason: "owner-not-ready"
47896
+ };
47897
+ if (read.kind === "unreadable") return read.error !== void 0 ? {
47898
+ kind: "unreadable",
47899
+ reason: read.reason,
47900
+ error: read.error
47901
+ } : {
47902
+ kind: "unreadable",
47903
+ reason: read.reason
47904
+ };
47905
+ const stored = interpretStoredClusterModel(read.kind === "absent" ? void 0 : read.value);
47906
+ if (stored.kind === "absent") return {
47907
+ kind: "absent",
47908
+ defaultModelId: step.defaultModelId
47909
+ };
47910
+ if (stored.kind === "chosen") return {
47911
+ kind: "stored",
47912
+ modelId: stored.modelId
47913
+ };
47914
+ return {
47915
+ kind: "unreadable",
47916
+ reason: "invalid-stored"
47917
+ };
47918
+ }
47919
+ //#endregion
47361
47920
  //#region src/audio-load-balancer.ts
47362
47921
  function balanceAudio(input) {
47363
47922
  if (input.nodes.length === 0) return null;
@@ -48266,6 +48825,352 @@ function createDiskReconcileJobRunner(now = Date.now) {
48266
48825
  };
48267
48826
  }
48268
48827
  //#endregion
48828
+ //#region src/pipeline-resolver.ts
48829
+ /**
48830
+ * The `package-detection` root step id. Zone-driven activation
48831
+ * ({@link applyPackageZoneActivation}) is keyed on it — the step has NO
48832
+ * user-facing toggle; a drawn package zone is the sole enable signal.
48833
+ */
48834
+ var PACKAGE_DETECTION_STEP_ID = "package-detection";
48835
+ /**
48836
+ * The CAMERA ROOT: the always-on object-detection step every session's tracks
48837
+ * originate from. The ONE definition behind two gates that must agree (D650
48838
+ * round 4): the device election's root guard (`makeRootCapabilityGuard`)
48839
+ * excludes a device whose format cannot run it, and the attach backstop
48840
+ * (`ResolvedPipeline.droppedRoots`) refuses an attach that lost it. Any other
48841
+ * gated-on root the elected device cannot run (`segmentation`, an opt-in
48842
+ * refiner) is a logged skip, never a refusal: the election does not look at
48843
+ * it, so refusing on it left a camera pending forever.
48844
+ *
48845
+ * Mirrors the addon-pipeline step id — a cross-addon import is forbidden.
48846
+ */
48847
+ var OBJECT_DETECTION_STEP_ID = "object-detection";
48848
+ /**
48849
+ * Zone-driven activation for the toggle-less `package-detection` root step.
48850
+ *
48851
+ * The step runs for a device IFF it has ≥1 package zone (an enabled
48852
+ * `package`-stage zone rule referencing geometry). There is deliberately NO
48853
+ * operator toggle — drawing a package zone IS the enable, removing every
48854
+ * package zone IS the disable. This gate reconciles the resolved step tree to
48855
+ * that rule:
48856
+ *
48857
+ * - `hasPackageZone` true → the step is ensured present as an ENABLED root
48858
+ * (a pre-existing node keeps its `modelId` / `settings` — e.g. a per-node
48859
+ * model choice); default-on where a zone is present.
48860
+ * - `hasPackageZone` false → any `package-detection` node is stripped so the
48861
+ * runner never dispatches it.
48862
+ *
48863
+ * `package-detection` is a ROOT (`inputClasses: null`) so it only ever appears
48864
+ * at the top level — no recursion into children needed. Every other step is
48865
+ * left untouched. Immutable: returns a fresh array only when a change applies.
48866
+ */
48867
+ function applyPackageZoneActivation(steps, hasPackageZone) {
48868
+ const existing = steps.find((s) => s.addonId === PACKAGE_DETECTION_STEP_ID);
48869
+ if (!hasPackageZone) {
48870
+ if (!existing) return steps;
48871
+ return steps.filter((s) => s.addonId !== PACKAGE_DETECTION_STEP_ID);
48872
+ }
48873
+ if (existing) {
48874
+ if (existing.enabled === true) return steps;
48875
+ return steps.map((s) => s.addonId === "package-detection" ? {
48876
+ ...s,
48877
+ enabled: true
48878
+ } : s);
48879
+ }
48880
+ const node = {
48881
+ addonId: PACKAGE_DETECTION_STEP_ID,
48882
+ enabled: true,
48883
+ children: []
48884
+ };
48885
+ return [...steps, node];
48886
+ }
48887
+ /**
48888
+ * Steps that are OFF by the per-camera gate DEFAULT (i.e. when the camera's
48889
+ * `stepToggles[addonId]` is unset). Mirrors each step's catalog
48890
+ * `enabledByDefault: false` — the heavy / opt-in classifiers + segmentation.
48891
+ *
48892
+ * The pure resolver derives the default gate from the LIVE catalog
48893
+ * (`addon.enabledByDefault ?? true`), so it stays correct if a future
48894
+ * default-off step ships. This literal set is the catalog-FREE fallback used
48895
+ * by the one-shot gating migration ({@link migrateStepGating}), which runs at
48896
+ * boot with no guaranteed live executor catalog. The two agree for every
48897
+ * step that exists today.
48898
+ *
48899
+ * `package-detection` is deliberately NOT listed — it is ZONE-driven
48900
+ * ({@link applyPackageZoneActivation}) and never governed by the gate.
48901
+ */
48902
+ var DEFAULT_OFF_STEP_IDS = new Set([
48903
+ "animal-classifier",
48904
+ "bird-classifier",
48905
+ "vehicle-classifier",
48906
+ "segmentation"
48907
+ ]);
48908
+ /**
48909
+ * Catalog-free default gate for `addonId` — `true` unless the step is in
48910
+ * {@link DEFAULT_OFF_STEP_IDS}. Used by the migration (no live catalog); the
48911
+ * resolver prefers the catalog's own `enabledByDefault ?? true`.
48912
+ */
48913
+ function defaultStepGate(addonId) {
48914
+ return !DEFAULT_OFF_STEP_IDS.has(addonId);
48915
+ }
48916
+ /**
48917
+ * PROVISIONING presence for `(addon, format)` — the node "provides" the step
48918
+ * iff a pool of that format can run it, by the ONE placement rule every
48919
+ * placement reads (`stepModelRunsOnFormat`, D657). The resolver is
48920
+ * structure-only, so the step names no model here ("Auto"): a NON-legacy
48921
+ * model must ship the build. A legacy stub never makes a format provisioned.
48922
+ */
48923
+ function addonHasCompatibleModel(addon, format) {
48924
+ return stepModelRunsOnFormat(addon.models ?? [], null, format);
48925
+ }
48926
+ /**
48927
+ * A ROOT runs on the frame plane of the ELECTED device and is never jumped
48928
+ * (D650). Roots are the video steps with no `inputClasses`.
48929
+ */
48930
+ function isVideoRoot(addon) {
48931
+ return (addon.inputClasses ?? []).length === 0;
48932
+ }
48933
+ /**
48934
+ * The per-DEVICE provisioning gate (D650). `null` = keep the step.
48935
+ *
48936
+ * - `audio-classifier` is NOT gated here at all. It never runs on an
48937
+ * inference pool — the audio-analyzer runs its own backend (YAMNet in an
48938
+ * embedded-Python subprocess, or Apple SoundAnalysis) — so an inference
48939
+ * format says nothing about whether it can run. Only enablement applies.
48940
+ * - A video ROOT needs a build for the ELECTED device's format — the runner
48941
+ * never jumps it, so a sibling accelerator that could run it is irrelevant.
48942
+ * - A detail step is kept when the elected format OR any format of the
48943
+ * node's jump roster (`jumpFormats` — the SAME enabled∧available devices
48944
+ * the runner jumps within) has a build: the runner's step-tree jump
48945
+ * (`pipeline-runner/device-jump.ts`) then places it.
48946
+ */
48947
+ function unprovisionedReason(addon, electedFormat, jumpFormats) {
48948
+ if (addon.slot === "audio-classifier") return null;
48949
+ if (addonHasCompatibleModel(addon, electedFormat)) return null;
48950
+ if (isVideoRoot(addon)) return jumpFormats.some((f) => addonHasCompatibleModel(addon, f)) ? "root-not-runnable-on-elected-device" : "no-compatible-model";
48951
+ return jumpFormats.some((f) => addonHasCompatibleModel(addon, f)) ? null : "no-compatible-model";
48952
+ }
48953
+ /**
48954
+ * Device-aware resolver — the SINGLE per-camera gate + per-DEVICE skip check.
48955
+ *
48956
+ * Step `S` runs for camera `C` on device `D` ⇔
48957
+ * gate(C, S) — `stepToggles[S]` ?? `addon.enabledByDefault ?? true`
48958
+ * AND provided(D, S) — the catalog lists a model with a build for D's FORMAT
48959
+ * (a ROOT), or for D's format or any `jumpFormats`
48960
+ * entry (a detail step — the runner jumps it, D650);
48961
+ * `audio-classifier` is never format-gated (it runs
48962
+ * on the audio-analyzer's own backend)
48963
+ *
48964
+ * Enablement is the per-camera `stepToggles` authority ONLY. Provisioning
48965
+ * (`modelId` / `settings`) is NOT applied here — the returned tree carries bare
48966
+ * `{ addonId, enabled: true, children }` nodes; {@link applyDeviceProvisioning}
48967
+ * attaches model + settings at dispatch from the per-(node,device) base + the
48968
+ * per-camera-device override.
48969
+ *
48970
+ * A step gated ON that no device it may run on can serve is DROPPED and
48971
+ * recorded in `skipped` so the caller can surface a visible "gated on, no model
48972
+ * on this node" status at dispatch time.
48973
+ *
48974
+ * `package-detection` is excluded here — it is zone-driven
48975
+ * ({@link applyPackageZoneActivation}, applied by the caller).
48976
+ *
48977
+ * "Inference disabled for this camera" is NOT handled here — the caller
48978
+ * (resolvePipelineForDevice) consults the detection-pipeline binding and
48979
+ * short-circuits to an empty pipeline before invoking this resolver.
48980
+ */
48981
+ function resolvePipelineWithSkips(cameraSettings, agentNodeId, deviceFormat, catalog, jumpFormats = []) {
48982
+ const cam = cameraSettings ?? {};
48983
+ const wholesale = cam.pipelineByAgent?.[agentNodeId];
48984
+ if (wholesale) return {
48985
+ config: {
48986
+ steps: mergeEnabledByDefaultSteps(wholesale.steps, catalog),
48987
+ audio: wholesale.audio ? {
48988
+ modelId: wholesale.audio.modelId,
48989
+ enabled: wholesale.audio.enabled
48990
+ } : null
48991
+ },
48992
+ skipped: [],
48993
+ droppedRoots: []
48994
+ };
48995
+ const toggles = cam.stepToggles ?? {};
48996
+ const format = deviceFormat;
48997
+ const enabled = [];
48998
+ const skipped = [];
48999
+ const droppedRoots = [];
49000
+ for (const slot of catalog.slots) for (const addon of slot.addons) {
49001
+ const addonId = addon.id;
49002
+ if (addonId === "package-detection") continue;
49003
+ if (!(toggles[addonId] ?? addon.enabledByDefault ?? true)) continue;
49004
+ const reason = unprovisionedReason(addon, format, jumpFormats);
49005
+ if (reason !== null) {
49006
+ skipped.push({
49007
+ addonId,
49008
+ reason
49009
+ });
49010
+ if (addonId === "object-detection") droppedRoots.push(addonId);
49011
+ continue;
49012
+ }
49013
+ enabled.push({ addonId });
49014
+ }
49015
+ const audioAddons = [];
49016
+ const videoAddons = [];
49017
+ for (const entry of enabled) {
49018
+ const meta = findInCatalog(catalog, entry.addonId);
49019
+ if (!meta) continue;
49020
+ if (meta.slot === "audio-classifier") audioAddons.push(entry);
49021
+ else videoAddons.push(entry);
49022
+ }
49023
+ return {
49024
+ config: {
49025
+ steps: buildTreeFromAddons(videoAddons, catalog),
49026
+ audio: audioAddons.length > 0 ? { enabled: true } : null
49027
+ },
49028
+ skipped,
49029
+ droppedRoots
49030
+ };
49031
+ }
49032
+ function findInCatalog(catalog, addonId) {
49033
+ for (const slot of catalog.slots) for (const addon of slot.addons) if (addon.id === addonId) return addon;
49034
+ return null;
49035
+ }
49036
+ /**
49037
+ * Compose a step tree from a flat list of enabled video addons using
49038
+ * catalog compatibility: an addon whose `inputClasses` overlaps a
49039
+ * candidate parent's `outputClasses` attaches as the parent's child.
49040
+ * Addons with `inputClasses` null/empty become roots.
49041
+ *
49042
+ * Multiple addons in the same slot with compatible inputClasses run in
49043
+ * parallel — they attach to the same parent as siblings. Example: two
49044
+ * classifiers (bird-classifier + animal-classifier) both accept 'animal'
49045
+ * → both attach to object-detection as siblings, both receive every
49046
+ * animal crop.
49047
+ *
49048
+ * Unparented leaves (inputClasses set, but no matching parent in the
49049
+ * enabled set) are DROPPED. The runner would have nothing to feed them
49050
+ * anyway — better silent drop than dispatching a dead step.
49051
+ */
49052
+ function buildTreeFromAddons(enabled, catalog) {
49053
+ const bySlot = /* @__PURE__ */ new Map();
49054
+ for (const slot of catalog.slots) bySlot.set(slot.id, slot);
49055
+ const nodes = /* @__PURE__ */ new Map();
49056
+ const metaById = /* @__PURE__ */ new Map();
49057
+ for (const { addonId } of enabled) {
49058
+ const meta = findInCatalog(catalog, addonId);
49059
+ if (!meta) continue;
49060
+ metaById.set(addonId, meta);
49061
+ nodes.set(addonId, {
49062
+ addonId,
49063
+ enabled: true,
49064
+ children: []
49065
+ });
49066
+ }
49067
+ const roots = [];
49068
+ for (const [addonId, node] of nodes) {
49069
+ const inputs = metaById.get(addonId).inputClasses;
49070
+ if (!inputs || inputs.length === 0) {
49071
+ roots.push(node);
49072
+ continue;
49073
+ }
49074
+ let parent = null;
49075
+ for (const [candidateId, candidateNode] of nodes) {
49076
+ if (candidateId === addonId) continue;
49077
+ if (metaById.get(candidateId).outputClasses.some((c) => inputs.includes(c))) {
49078
+ parent = candidateNode;
49079
+ break;
49080
+ }
49081
+ }
49082
+ if (parent) parent.children.push(node);
49083
+ }
49084
+ return roots;
49085
+ }
49086
+ /**
49087
+ * Back-fill catalog steps EXPLICITLY flagged `backfillIntoExistingOverrides`
49088
+ * but ENTIRELY ABSENT from an existing step tree — reconciles a stored
49089
+ * wholesale `pipelineByAgent` snapshot with genuinely-new default steps.
49090
+ *
49091
+ * Why it's needed: a `pipelineByAgent` wholesale override SNAPSHOTS the full
49092
+ * tree. A step introduced AFTER the snapshot (e.g. `clip-embedding` for object
49093
+ * semantic search) would otherwise never reach that camera.
49094
+ *
49095
+ * Why the flag (not `enabledByDefault`): absence-from-a-snapshot is ambiguous —
49096
+ * it can mean "predates this step" OR "operator deliberately pruned it". A
49097
+ * broad "back-fill every default-on step" would resurrect deliberate removals
49098
+ * of long-existing steps (face-detection, etc.), an operator-intent
49099
+ * regression. Only a step that opts in via `backfillIntoExistingOverrides`
49100
+ * (set on genuinely-new default steps ONLY) re-enters existing overrides.
49101
+ *
49102
+ * Non-destructive by design:
49103
+ * - Only steps whose id is absent from the WHOLE tree are added. A step the
49104
+ * operator has already seen — present anywhere, even `enabled: false` —
49105
+ * is left exactly as-is (its enabled flag is honoured).
49106
+ * - Only `backfillIntoExistingOverrides === true` addons are candidates.
49107
+ * Every other step (including long-standing default-on members) is left
49108
+ * untouched, so a deliberately-pruned override stays pruned.
49109
+ * - Audio-classifier addons are skipped — audio lives in
49110
+ * `CameraPipelineConfig.audio`, never in the video `steps` tree.
49111
+ * - A candidate attaches by catalog compat (its `inputClasses` overlap a
49112
+ * present node's `outputClasses`), mirroring {@link buildTreeFromAddons}.
49113
+ * A candidate with non-empty `inputClasses` and NO compatible parent is
49114
+ * dropped (never re-rooted) so a crop-scoped embedder never regresses to
49115
+ * running on the full frame. Empty `inputClasses` ⇒ a genuine root step.
49116
+ */
49117
+ function mergeEnabledByDefaultSteps(steps, catalog) {
49118
+ if (steps.length === 0) return steps;
49119
+ const present = /* @__PURE__ */ new Set();
49120
+ const collect = (nodes) => {
49121
+ for (const n of nodes) {
49122
+ present.add(n.addonId);
49123
+ if (n.children?.length) collect(n.children);
49124
+ }
49125
+ };
49126
+ collect(steps);
49127
+ const catalogById = /* @__PURE__ */ new Map();
49128
+ for (const slot of catalog.slots) for (const addon of slot.addons) catalogById.set(addon.id, addon);
49129
+ const newRoots = [];
49130
+ const childrenByParent = /* @__PURE__ */ new Map();
49131
+ for (const slot of catalog.slots) {
49132
+ if (slot.id === "audio-classifier") continue;
49133
+ for (const addon of slot.addons) {
49134
+ if (addon.backfillIntoExistingOverrides !== true) continue;
49135
+ if (present.has(addon.id)) continue;
49136
+ const node = {
49137
+ addonId: addon.id,
49138
+ enabled: true,
49139
+ children: []
49140
+ };
49141
+ const inputs = addon.inputClasses;
49142
+ if (!inputs || inputs.length === 0) {
49143
+ newRoots.push(node);
49144
+ continue;
49145
+ }
49146
+ let parentId = null;
49147
+ for (const presentId of present) {
49148
+ const meta = catalogById.get(presentId);
49149
+ if (!meta) continue;
49150
+ if (meta.outputClasses.some((c) => inputs.includes(c))) {
49151
+ parentId = presentId;
49152
+ break;
49153
+ }
49154
+ }
49155
+ if (!parentId) continue;
49156
+ const list = childrenByParent.get(parentId) ?? [];
49157
+ list.push(node);
49158
+ childrenByParent.set(parentId, list);
49159
+ }
49160
+ }
49161
+ if (childrenByParent.size === 0 && newRoots.length === 0) return steps;
49162
+ const attach = (nodes) => nodes.map((n) => {
49163
+ const rebuiltChildren = attach(n.children ?? []);
49164
+ const extra = childrenByParent.get(n.addonId) ?? [];
49165
+ const mergedChildren = [...rebuiltChildren, ...extra];
49166
+ return mergedChildren.length > 0 ? {
49167
+ ...n,
49168
+ children: mergedChildren
49169
+ } : n;
49170
+ });
49171
+ return [...attach(steps), ...newRoots];
49172
+ }
49173
+ //#endregion
48269
49174
  //#region src/inference-device-model.ts
48270
49175
  /**
48271
49176
  * Per-device default object-detection model + deviceKey parsing for the
@@ -48291,20 +49196,14 @@ function createDiskReconcileJobRunner(now = Date.now) {
48291
49196
  * gates (0/3 miss recovered at the current threshold).
48292
49197
  */
48293
49198
  /**
48294
- * The always-on object-detection ROOT step id. A camera session's tracks all
48295
- * originate from this detector, so a device whose engine format can't run it
48296
- * cannot host a camera root. Mirrors the addon-pipeline step id (cross-addon
48297
- * import is forbidden — this is the same duplication rationale as the model
48298
- * defaults above).
48299
- */
48300
- var OBJECT_DETECTION_STEP_ID = "object-detection";
48301
- /**
48302
49199
  * Build the camera-root capability predicate for a node from its live catalog:
48303
49200
  * `format → canHostCameraRoot`. A format can host a camera root iff the
48304
49201
  * catalog lists at least one object-detection model with a build for that
48305
- * format — byte-for-byte the resolver's per-device skip-gate test for the root
48306
- * step (`addonHasCompatibleModel`), so a device is deemed eligible iff the root
48307
- * would ACTUALLY provision on it.
49202
+ * format — it IS the resolver's per-device skip-gate test for the root step
49203
+ * (`addonHasCompatibleModel`), so a device is deemed eligible iff the root
49204
+ * would ACTUALLY provision on it. The root is {@link OBJECT_DETECTION_STEP_ID},
49205
+ * shared with the resolver's refusal backstop (`droppedRoots`) so the two gates
49206
+ * judge the same step (D650 round 4).
48308
49207
  *
48309
49208
  * Fails OPEN when the catalog has no object-detection slot at all (never
48310
49209
  * observed in production) so a malformed/empty catalog never strands every
@@ -48313,7 +49212,7 @@ var OBJECT_DETECTION_STEP_ID = "object-detection";
48313
49212
  function makeRootCapabilityGuard(catalog) {
48314
49213
  for (const slot of catalog.slots) {
48315
49214
  const objDet = slot.addons.find((a) => a.id === OBJECT_DETECTION_STEP_ID);
48316
- if (objDet) return (format) => objDet.models.some((m) => Boolean(m.formats[format]));
49215
+ if (objDet) return (format) => addonHasCompatibleModel(objDet, format);
48317
49216
  }
48318
49217
  return () => true;
48319
49218
  }
@@ -49824,16 +50723,18 @@ var CameraStatusService = class {
49824
50723
  const audioAssignment = this.deps.getAudioAssignment(deviceId);
49825
50724
  const audioNodeId = audioAssignment?.nodeId ?? null;
49826
50725
  const audioPinned = audioAssignment?.pinned ?? false;
50726
+ const pinned = {
50727
+ detection: pipelineAssignment?.pinned ?? false,
50728
+ decoder: false,
50729
+ audio: audioPinned
50730
+ };
50731
+ const pendingReason = this.deps.getPendingReason(deviceId);
49827
50732
  return {
49828
50733
  pipelineAssignment,
49829
50734
  detectionNodeId,
49830
50735
  sourceNodeId,
49831
- pinned: {
49832
- detection: pipelineAssignment?.pinned ?? false,
49833
- decoder: false,
49834
- audio: audioPinned
49835
- },
49836
- detectionReason: pipelineAssignment !== null ? pipelineAssignment.reason : this.deps.hasCameraConfig(deviceId) ? `pending:${this.deps.getPendingReason(deviceId) ?? "pending"}` : this.deps.isSessionCamera(deviceId) ? "armed:on-motion" : void 0,
50736
+ pinned,
50737
+ 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,
49837
50738
  audioNodeId,
49838
50739
  audioPinned
49839
50740
  };
@@ -50689,48 +51590,6 @@ var DetectionBlindMonitor = class {
50689
51590
  }
50690
51591
  };
50691
51592
  //#endregion
50692
- //#region src/apply-device-provisioning.ts
50693
- /**
50694
- * Merge base then override for one step. Override keys win; undefined = inherit.
50695
- * `jumpDeviceKey` (step-tree device jump, phase 1) is a per-(node,device) BASE
50696
- * concept ONLY — the per-camera override never carries it (spec: per-camera jump
50697
- * is out of scope), so it is taken from `base` verbatim.
50698
- */
50699
- function resolveStep(base, override) {
50700
- if (!base && !override) return void 0;
50701
- const modelId = override?.modelId ?? base?.modelId;
50702
- const settings = base?.settings || override?.settings ? {
50703
- ...base?.settings,
50704
- ...override?.settings
50705
- } : void 0;
50706
- const jumpDeviceKey = base?.jumpDeviceKey;
50707
- return {
50708
- ...modelId !== void 0 ? { modelId } : {},
50709
- ...settings ? { settings } : {},
50710
- ...jumpDeviceKey !== void 0 ? { jumpDeviceKey } : {}
50711
- };
50712
- }
50713
- /**
50714
- * Attach modelId + settings onto each step from the per-(node,device) BASE and
50715
- * the per-camera-device OVERRIDE. Pure/immutable; recurses into children. Empty
50716
- * base+override ⇒ same reference.
50717
- */
50718
- function applyDeviceProvisioning(steps, base, override) {
50719
- if (Object.keys(base).length === 0 && Object.keys(override).length === 0) return steps;
50720
- return steps.map((step) => {
50721
- const cfg = resolveStep(base[step.addonId], override[step.addonId]);
50722
- const children = step.children ? applyDeviceProvisioning(step.children, base, override) : step.children;
50723
- if (!cfg && children === step.children) return step;
50724
- return {
50725
- ...step,
50726
- ...cfg?.modelId !== void 0 ? { modelId: cfg.modelId } : {},
50727
- ...cfg?.settings ? { settings: cfg.settings } : {},
50728
- ...cfg?.jumpDeviceKey !== void 0 ? { jumpDeviceKey: cfg.jumpDeviceKey } : {},
50729
- ...children !== void 0 ? { children } : {}
50730
- };
50731
- });
50732
- }
50733
- //#endregion
50734
51593
  //#region src/device-activity-source.ts
50735
51594
  /** The source's own name on the wire. One constant, used by every emit + gate. */
50736
51595
  var DEVICE_ACTIVITY_SOURCE = "device-activity";
@@ -52025,8 +52884,12 @@ var DetectionWiringController = class {
52025
52884
  }
52026
52885
  log.info("detection armed (session-time) — watching for motion", { meta: { motion: config.motionSources.join("+") } });
52027
52886
  } else {
52028
- const hydrateNodeId = this.deps.ledger.getAssignment(deviceId)?.agentNodeId ?? this.deps.localNodeId();
52029
- const pipelineConfig = await this.deps.settingsStore.resolvePipelineForDevice(deviceId);
52887
+ const placeholderNode = this.deps.ledger.getAssignment(deviceId)?.agentNodeId ?? this.deps.localNodeId();
52888
+ const placeholder = await this.deps.settingsStore.resolvePipelineForDeviceIfCatalogCached(deviceId, placeholderNode, void 0, []);
52889
+ const pipelineConfig = placeholder.kind === "resolved" ? placeholder.config : {
52890
+ steps: [],
52891
+ audio: null
52892
+ };
52030
52893
  const runnerConfig = buildRunnerConfig({
52031
52894
  deviceId,
52032
52895
  config,
@@ -52035,42 +52898,12 @@ var DetectionWiringController = class {
52035
52898
  steps: pipelineConfig.steps,
52036
52899
  audio: pipelineConfig.audio ?? null
52037
52900
  });
52038
- let dispatchedNodeId = null;
52039
52901
  try {
52040
- const result = await this.deps.placement.dispatchCamera(runnerConfig);
52041
- if (result.kind === "assigned") dispatchedNodeId = result.agentNodeId;
52902
+ await this.deps.placement.dispatchCamera(runnerConfig);
52042
52903
  } catch (err) {
52043
52904
  const msg = errMsg$1(err);
52044
52905
  log.error("dispatchCamera failed", { meta: { error: msg } });
52045
52906
  }
52046
- if (dispatchedNodeId && (dispatchedNodeId !== hydrateNodeId || (runnerConfig.steps?.length ?? 0) === 0)) try {
52047
- const dispatchedDeviceKey = this.deps.ledger.getConfig(deviceId)?.deviceKey;
52048
- const corrected = await this.deps.settingsStore.resolvePipelineForDevice(deviceId, dispatchedNodeId, dispatchedDeviceKey);
52049
- const correctedBase = await this.deps.settingsStore.buildNodeInferenceDeviceSteps(dispatchedNodeId, dispatchedDeviceKey ?? "");
52050
- const correctedOverride = dispatchedDeviceKey ? await this.deps.settingsStore.readCameraDeviceOverride(deviceId, dispatchedNodeId, dispatchedDeviceKey) : {};
52051
- const correctedConfig = {
52052
- ...runnerConfig,
52053
- ...dispatchedDeviceKey ? { deviceKey: dispatchedDeviceKey } : {},
52054
- steps: applyDeviceProvisioning(corrected.steps, correctedBase, correctedOverride),
52055
- audio: corrected.audio ?? null
52056
- };
52057
- if (JSON.stringify({
52058
- steps: runnerConfig.steps,
52059
- audio: runnerConfig.audio ?? null
52060
- }) !== JSON.stringify({
52061
- steps: correctedConfig.steps,
52062
- audio: correctedConfig.audio
52063
- })) {
52064
- await this.deps.attachOn(dispatchedNodeId, correctedConfig);
52065
- this.deps.ledger.setConfig(deviceId, correctedConfig);
52066
- log.info("startDetection: pipeline re-resolved after agent assignment", {
52067
- tags: { nodeId: dispatchedNodeId },
52068
- meta: { stepCount: corrected.steps.length }
52069
- });
52070
- }
52071
- } catch (err) {
52072
- log.warn("startDetection: post-dispatch re-resolve failed", { meta: { error: errMsg$1(err) } });
52073
- }
52074
52907
  }
52075
52908
  try {
52076
52909
  await this.deps.audio.resubscribe(deviceId, config);
@@ -52822,6 +53655,200 @@ async function migrateLegacyFlagsToBindings(deps) {
52822
53655
  } });
52823
53656
  }
52824
53657
  //#endregion
53658
+ //#region src/apply-device-provisioning.ts
53659
+ /**
53660
+ * Merge base then override for one step. Override keys win; undefined = inherit.
53661
+ * `jumpDeviceKey` (step-tree device jump, phase 1) is a per-(node,device) BASE
53662
+ * concept ONLY — the per-camera override never carries it (spec: per-camera jump
53663
+ * is out of scope), so it is taken from `base` verbatim.
53664
+ */
53665
+ function resolveStep(base, override) {
53666
+ if (!base && !override) return void 0;
53667
+ const modelId = override?.modelId ?? base?.modelId;
53668
+ const settings = base?.settings || override?.settings ? {
53669
+ ...base?.settings,
53670
+ ...override?.settings
53671
+ } : void 0;
53672
+ const jumpDeviceKey = base?.jumpDeviceKey;
53673
+ return {
53674
+ ...modelId !== void 0 ? { modelId } : {},
53675
+ ...settings ? { settings } : {},
53676
+ ...jumpDeviceKey !== void 0 ? { jumpDeviceKey } : {}
53677
+ };
53678
+ }
53679
+ /**
53680
+ * Attach modelId + settings onto each step from the per-(node,device) BASE and
53681
+ * the per-camera-device OVERRIDE. Pure/immutable; recurses into children. Empty
53682
+ * base+override ⇒ same reference.
53683
+ */
53684
+ function applyDeviceProvisioning(steps, base, override) {
53685
+ if (Object.keys(base).length === 0 && Object.keys(override).length === 0) return steps;
53686
+ return steps.map((step) => {
53687
+ const cfg = resolveStep(base[step.addonId], override[step.addonId]);
53688
+ const children = step.children ? applyDeviceProvisioning(step.children, base, override) : step.children;
53689
+ if (!cfg && children === step.children) return step;
53690
+ return {
53691
+ ...step,
53692
+ ...cfg?.modelId !== void 0 ? { modelId: cfg.modelId } : {},
53693
+ ...cfg?.settings ? { settings: cfg.settings } : {},
53694
+ ...cfg?.jumpDeviceKey !== void 0 ? { jumpDeviceKey: cfg.jumpDeviceKey } : {},
53695
+ ...children !== void 0 ? { children } : {}
53696
+ };
53697
+ });
53698
+ }
53699
+ /**
53700
+ * What each step of `steps` would run with had the camera been elected onto
53701
+ * the device whose `base` / `override` these are — as a flat
53702
+ * `stepId → { modelId?, settings? }` map (D650). It IS
53703
+ * {@link applyDeviceProvisioning} over the same resolved tree, flattened, so a
53704
+ * jump target and the elected device are provisioned by one rule, and a value
53705
+ * the resolved tree already carries (a wholesale `pipelineByAgent` snapshot)
53706
+ * survives exactly as it would on the elected device. The runner REPLACES a
53707
+ * jumped subtree's `modelId` / `settings` with these, so the elected device's
53708
+ * base and override never follow a step across. `jumpDeviceKey` is dropped: it
53709
+ * names where a step leaves the ELECTED device for, and means nothing on the
53710
+ * target. A step with neither field is absent (it runs the format default).
53711
+ */
53712
+ function provisionDeviceSteps(steps, base, override) {
53713
+ const out = {};
53714
+ const walk = (list) => {
53715
+ for (const step of list) {
53716
+ if (step.modelId !== void 0 || step.settings !== void 0) out[step.addonId] = {
53717
+ ...step.modelId !== void 0 ? { modelId: step.modelId } : {},
53718
+ ...step.settings !== void 0 ? { settings: step.settings } : {}
53719
+ };
53720
+ walk(step.children ?? []);
53721
+ }
53722
+ };
53723
+ walk(applyDeviceProvisioning(steps, base, override));
53724
+ return out;
53725
+ }
53726
+ //#endregion
53727
+ //#region src/jump-roster.ts
53728
+ /**
53729
+ * The roster (unprovisioned), or `undefined` when there is no jump to make: no
53730
+ * elected device, or fewer than two eligible devices on the node.
53731
+ */
53732
+ function buildJumpRoster(input) {
53733
+ if (!input.deviceKey || Object.keys(input.eligible).length < 2) return void 0;
53734
+ return buildInferenceDeviceRoster(input.eligible, input.caps);
53735
+ }
53736
+ /** The roster devices a detail step may jump TO — every entry except the elected one. */
53737
+ function jumpTargetKeys(roster, electedDeviceKey) {
53738
+ return (roster ?? []).map((d) => d.deviceKey).filter((k) => k !== electedDeviceKey);
53739
+ }
53740
+ //#endregion
53741
+ //#region src/attach-pipeline.ts
53742
+ /**
53743
+ * The steps whose ELECTED-device base carries a manual `jumpDeviceKey` while
53744
+ * the camera has an override for that step on the elected device (D650). A
53745
+ * jumped step runs the TARGET's provisioning, so that override does not
53746
+ * travel with it — the operator must be told, not left to wonder.
53747
+ */
53748
+ function manualJumpsLeavingOverride(base, override) {
53749
+ const out = [];
53750
+ for (const [stepId, cfg] of Object.entries(base)) {
53751
+ const jumpDeviceKey = cfg.jumpDeviceKey;
53752
+ if (jumpDeviceKey === void 0) continue;
53753
+ const own = override[stepId];
53754
+ if (own?.modelId === void 0 && own?.settings === void 0) continue;
53755
+ out.push({
53756
+ stepId,
53757
+ jumpDeviceKey
53758
+ });
53759
+ }
53760
+ return out;
53761
+ }
53762
+ /**
53763
+ * BLOCKING entry point: waits for the node's catalog (`resolvePipelineForDevice`
53764
+ * reconciles against the executor until it answers). For a FIRST placement —
53765
+ * a camera's first standing dispatch, an on-motion session, a re-attach —
53766
+ * where there is nothing better to attach than the real pipeline. See the
53767
+ * module header.
53768
+ */
53769
+ async function resolveAttachPipeline(input, store) {
53770
+ const roster = buildJumpRoster({
53771
+ deviceKey: input.deviceKey,
53772
+ eligible: input.eligible,
53773
+ caps: input.caps
53774
+ });
53775
+ const resolved = await store.resolvePipelineForDeviceWithRoots(input.deviceId, input.nodeId, input.deviceKey, jumpTargetKeys(roster, input.deviceKey));
53776
+ if (resolved.droppedRoots.length > 0) return rootRefusal(input, resolved.droppedRoots);
53777
+ return {
53778
+ kind: "resolved",
53779
+ pipeline: await provisionAttachPipeline(input, roster, resolved.config, store)
53780
+ };
53781
+ }
53782
+ /**
53783
+ * NON-BLOCKING entry point: reads the node's catalog from the cache only, and
53784
+ * answers `catalog-unavailable` instead of waiting (D650 round 2). For the
53785
+ * RETRY and re-place paths — a pending retry, a remote-health re-place, a
53786
+ * rebalance migration — which run cameras serially in one sweep: one node
53787
+ * whose executor never answers must leave ITS camera pending, not hold every
53788
+ * camera behind it (D3, D49).
53789
+ */
53790
+ async function resolveAttachPipelineIfCatalogCached(input, store) {
53791
+ const roster = buildJumpRoster({
53792
+ deviceKey: input.deviceKey,
53793
+ eligible: input.eligible,
53794
+ caps: input.caps
53795
+ });
53796
+ const outcome = await store.resolvePipelineForDeviceIfCatalogCached(input.deviceId, input.nodeId, input.deviceKey, jumpTargetKeys(roster, input.deviceKey));
53797
+ if (outcome.kind === "catalog-unavailable") return outcome;
53798
+ if (outcome.droppedRoots.length > 0) return rootRefusal(input, outcome.droppedRoots);
53799
+ return {
53800
+ kind: "resolved",
53801
+ pipeline: await provisionAttachPipeline(input, roster, outcome.config, store)
53802
+ };
53803
+ }
53804
+ function rootRefusal(input, droppedRoots) {
53805
+ return {
53806
+ kind: "root-refused",
53807
+ nodeId: input.nodeId,
53808
+ deviceKey: input.deviceKey,
53809
+ droppedRoots
53810
+ };
53811
+ }
53812
+ /** Steps 3 of the module header: provision the resolved structure. */
53813
+ async function provisionAttachPipeline(input, roster, resolved, store) {
53814
+ const { deviceId, nodeId, deviceKey } = input;
53815
+ const electedKey = deviceKey ?? "";
53816
+ const rosterKeys = (roster ?? []).map((d) => d.deviceKey);
53817
+ const sources = await store.readDeviceProvisioning(deviceId, nodeId, [...new Set([electedKey, ...rosterKeys])]);
53818
+ const elected = sources.get(electedKey);
53819
+ if (deviceKey && elected) {
53820
+ const left = manualJumpsLeavingOverride(elected.base, elected.override).filter((l) => rosterKeys.includes(l.jumpDeviceKey));
53821
+ if (left.length > 0) store.warnManualJumpsLeaveOverride(deviceId, nodeId, deviceKey, left);
53822
+ }
53823
+ const steps = applyDeviceProvisioning(resolved.steps, elected?.base ?? {}, elected?.override ?? {});
53824
+ const inferenceDevices = roster?.map((entry) => {
53825
+ const src = sources.get(entry.deviceKey);
53826
+ return {
53827
+ ...entry,
53828
+ steps: provisionDeviceSteps(resolved.steps, src?.base ?? {}, src?.override ?? {})
53829
+ };
53830
+ });
53831
+ return {
53832
+ steps,
53833
+ audio: resolved.audio ?? null,
53834
+ ...deviceKey ? { deviceKey } : {},
53835
+ ...inferenceDevices ? { inferenceDevices } : {}
53836
+ };
53837
+ }
53838
+ /**
53839
+ * `config` with its pipeline REPLACED by `pipeline`. The four pipeline fields
53840
+ * are removed first, so a previous attach's `deviceKey` / roster never survives
53841
+ * a spread when the new pipeline has none (a migration to a single-device node,
53842
+ * a re-dispatch from a ledger config).
53843
+ */
53844
+ function withAttachPipeline(config, pipeline) {
53845
+ const { deviceKey: _deviceKey, inferenceDevices: _inferenceDevices, steps: _steps, audio: _audio, ...rest } = config;
53846
+ return {
53847
+ ...rest,
53848
+ ...pipeline
53849
+ };
53850
+ }
53851
+ //#endregion
52825
53852
  //#region src/dispatch-reconcile.ts
52826
53853
  function runnerAttachmentKey(nodeId, deviceId) {
52827
53854
  return `${nodeId}:${deviceId}`;
@@ -53112,6 +54139,32 @@ function selectRunnerFrameSource(input) {
53112
54139
  //#region src/placement-service.ts
53113
54140
  /** Key under which the orchestrator stores its per-device manual pin — shared with `readPipelinePin` (index.ts) and `handleNodeDisconnect` (`node-lifecycle-handler.ts`). */
53114
54141
  var PREFERRED_AGENT_SETTING = "preferredAgent";
54142
+ /**
54143
+ * A load snapshot with this rebalance pass's own (re)assignments applied — at
54144
+ * node level, and per device so a device election in the same pass sees the
54145
+ * sessions the pass already gave each accelerator.
54146
+ */
54147
+ function tallyLoad(load, nodeDelta, perDevice) {
54148
+ if (nodeDelta === 0 && (perDevice === void 0 || perDevice.size === 0)) return load;
54149
+ const known = new Set(load.devices.map((d) => d.deviceKey));
54150
+ const devices = [...load.devices.map((d) => {
54151
+ const delta = perDevice?.get(d.deviceKey) ?? 0;
54152
+ return delta === 0 ? d : {
54153
+ ...d,
54154
+ attachedCameras: Math.max(0, d.attachedCameras + delta)
54155
+ };
54156
+ }), ...[...perDevice ?? /* @__PURE__ */ new Map()].filter(([deviceKey, delta]) => !known.has(deviceKey) && delta > 0).map(([deviceKey, delta]) => ({
54157
+ deviceKey,
54158
+ backend: deviceKey.split(":")[0] ?? deviceKey,
54159
+ attachedCameras: delta,
54160
+ queueDepthTotal: 0
54161
+ }))];
54162
+ return {
54163
+ ...load,
54164
+ attachedCameras: Math.max(0, load.attachedCameras + nodeDelta),
54165
+ devices
54166
+ };
54167
+ }
53115
54168
  var PlacementService = class {
53116
54169
  deps;
53117
54170
  /** True while an auto-rebalance pass is running (skip overlap). */
@@ -53149,7 +54202,116 @@ var PlacementService = class {
53149
54202
  });
53150
54203
  return usable;
53151
54204
  }
54205
+ /**
54206
+ * Pick which of `nodeId`'s enabled inference devices a camera runs on, and
54207
+ * log why (`inference device selection` — one line per placement, the same
54208
+ * message as the on-motion path so one grep covers both). Returns the
54209
+ * eligibility inputs too: the attach pipeline is resolved against the SAME
54210
+ * roster the device was elected from.
54211
+ */
54212
+ async electInferenceDevice(input) {
54213
+ const targetLoad = input.loads.find((load) => load.nodeId === input.nodeId);
54214
+ let eligible;
54215
+ if (input.read.kind === "await-catalog") eligible = await this.deps.getEligibleInferenceDevices(input.nodeId);
54216
+ else {
54217
+ const known = await this.deps.getEligibleInferenceDevicesIfCached(input.nodeId);
54218
+ if (known.kind === "unavailable") return known;
54219
+ eligible = known.eligible;
54220
+ }
54221
+ const caps = await this.deps.getInferenceDeviceCaps(input.nodeId);
54222
+ const pinnedDeviceKey = await this.deps.readPipelineDevicePin(input.deviceId);
54223
+ const selection = pickInferenceDevice({
54224
+ eligible,
54225
+ loads: targetLoad?.devices ?? [],
54226
+ caps,
54227
+ pinnedDeviceKey,
54228
+ currentDeviceKey: input.currentDeviceKey,
54229
+ rotation: this.deps.inferenceRotation
54230
+ });
54231
+ const deviceKey = selectedDeviceKey(selection);
54232
+ this.deps.ctx().logger.info("inference device selection", {
54233
+ tags: {
54234
+ deviceId: input.deviceId,
54235
+ nodeId: input.nodeId
54236
+ },
54237
+ meta: {
54238
+ path: input.path,
54239
+ eligible: selection.eligibleKeys,
54240
+ chosen: deviceKey ?? null,
54241
+ reason: selection.reason,
54242
+ ...selection.kind === "selected" ? {
54243
+ tied: selection.tiedKeys,
54244
+ tieBreak: selection.tieBreak
54245
+ } : {},
54246
+ ...pinnedDeviceKey ? { pin: pinnedDeviceKey } : {}
54247
+ }
54248
+ });
54249
+ return {
54250
+ kind: "elected",
54251
+ deviceKey,
54252
+ eligible,
54253
+ caps
54254
+ };
54255
+ }
54256
+ /**
54257
+ * Elect a device on `nodeId` and resolve the camera's attach pipeline for
54258
+ * it — the whole placement decision, for a first placement (`await-catalog`)
54259
+ * or a re-place (`cached-catalog`, never waits). Nothing is attached here.
54260
+ */
54261
+ async resolvePlacement(input) {
54262
+ const election = await this.electInferenceDevice(input);
54263
+ if (election.kind === "unavailable") return election;
54264
+ const attachInput = {
54265
+ deviceId: input.deviceId,
54266
+ nodeId: input.nodeId,
54267
+ deviceKey: election.deviceKey,
54268
+ eligible: election.eligible,
54269
+ caps: election.caps
54270
+ };
54271
+ if (input.read.kind === "await-catalog") return resolveAttachPipeline(attachInput, this.deps.settingsStore);
54272
+ const outcome = await resolveAttachPipelineIfCatalogCached(attachInput, this.deps.settingsStore);
54273
+ if (outcome.kind === "catalog-unavailable") return {
54274
+ kind: "unavailable",
54275
+ nodeId: outcome.nodeId,
54276
+ what: "catalog"
54277
+ };
54278
+ return outcome;
54279
+ }
54280
+ /**
54281
+ * Re-resolve a camera's attach pipeline on the device it is ALREADY
54282
+ * elected onto (`deviceKey` from its ledger config; absent ⇒ default pool)
54283
+ * — the entry for every path that replaces the steps of a live attach
54284
+ * without re-electing (executor readiness, a package-zone change). Goes
54285
+ * through the same {@link resolveAttachPipeline} as a dispatch (D650).
54286
+ */
54287
+ async resolveAttachPipelineOnDevice(input) {
54288
+ const [eligible, caps] = await Promise.all([this.deps.getEligibleInferenceDevices(input.nodeId), this.deps.getInferenceDeviceCaps(input.nodeId)]);
54289
+ return resolveAttachPipeline({
54290
+ ...input,
54291
+ eligible,
54292
+ caps
54293
+ }, this.deps.settingsStore);
54294
+ }
54295
+ /**
54296
+ * FIRST placement of a standing camera: waits for the chosen node's catalog
54297
+ * (the resolve reconciles against its executor until it answers). Callers:
54298
+ * `startDetection`'s standing path — one camera, nothing to attach instead.
54299
+ */
53152
54300
  async dispatchCamera(runnerConfig) {
54301
+ return this.placeCamera(runnerConfig, { kind: "await-catalog" });
54302
+ }
54303
+ /**
54304
+ * RE-placement from the ledger — the pending-retry sweep and the
54305
+ * remote-health re-place (D650 round 2). NEVER waits for a node: the
54306
+ * catalog is read from the cache only, and a node that has not answered
54307
+ * leaves THIS camera pending (`catalog-unavailable:<nodeId>`) for the next
54308
+ * round, instead of holding every camera after it in a serial sweep behind
54309
+ * one wedged executor (D3, D49).
54310
+ */
54311
+ async redispatchCamera(runnerConfig) {
54312
+ return this.placeCamera(runnerConfig, { kind: "cached-catalog" });
54313
+ }
54314
+ async placeCamera(runnerConfig, catalog) {
53153
54315
  if (!this.deps.ctx()) throw new Error("PipelineOrchestrator: dispatchCamera called before initialize");
53154
54316
  if (this.deps.maintenancePaused?.() === true) return {
53155
54317
  success: true,
@@ -53202,45 +54364,30 @@ var PlacementService = class {
53202
54364
  reason = decision.reason;
53203
54365
  dispatchScore = decision.score;
53204
54366
  }
53205
- const targetLoad = loads.find((load) => load.nodeId === targetNodeId);
53206
- const eligibleDevices = await this.deps.getEligibleInferenceDevices(targetNodeId);
53207
- const deviceCaps = await this.deps.getInferenceDeviceCaps(targetNodeId);
53208
- const pinnedDeviceKey = await this.deps.readPipelineDevicePin(runnerConfig.deviceId);
53209
- const selection = pickInferenceDevice({
53210
- eligible: eligibleDevices,
53211
- loads: targetLoad?.devices ?? [],
53212
- caps: deviceCaps,
53213
- pinnedDeviceKey,
54367
+ const placement = await this.resolvePlacement({
54368
+ deviceId: runnerConfig.deviceId,
54369
+ nodeId: targetNodeId,
54370
+ loads,
53214
54371
  currentDeviceKey,
53215
- rotation: this.deps.inferenceRotation
54372
+ path: "standing",
54373
+ read: catalog
53216
54374
  });
53217
- const deviceKey = selectedDeviceKey(selection);
53218
- this.deps.ctx().logger.info("inference device selection", {
53219
- tags: {
53220
- deviceId: runnerConfig.deviceId,
53221
- nodeId: targetNodeId
53222
- },
53223
- meta: {
53224
- path: "standing",
53225
- eligible: selection.eligibleKeys,
53226
- chosen: deviceKey ?? null,
53227
- reason: selection.reason,
53228
- ...selection.kind === "selected" ? {
53229
- tied: selection.tiedKeys,
53230
- tieBreak: selection.tieBreak
53231
- } : {},
53232
- ...pinnedDeviceKey ? { pin: pinnedDeviceKey } : {}
53233
- }
53234
- });
53235
- const deviceBase = await this.deps.settingsStore.buildNodeInferenceDeviceSteps(targetNodeId, deviceKey ?? "");
53236
- const deviceOverride = deviceKey ? await this.deps.settingsStore.readCameraDeviceOverride(runnerConfig.deviceId, targetNodeId, deviceKey) : {};
53237
- const inferenceDevices = deviceKey && Object.keys(eligibleDevices).length >= 2 ? buildInferenceDeviceRoster(eligibleDevices, deviceCaps) : void 0;
53238
- const attachConfig = {
53239
- ...runnerConfig,
53240
- ...deviceKey ? { deviceKey } : {},
53241
- ...inferenceDevices ? { inferenceDevices } : {},
53242
- ...runnerConfig.steps ? { steps: applyDeviceProvisioning(runnerConfig.steps, deviceBase, deviceOverride) } : {}
53243
- };
54375
+ if (placement.kind === "unavailable") {
54376
+ this.leavePendingUnanswered(runnerConfig.deviceId, placement.nodeId, placement.what);
54377
+ return {
54378
+ success: true,
54379
+ kind: "pending"
54380
+ };
54381
+ }
54382
+ if (placement.kind === "root-refused") {
54383
+ this.leavePendingRootRefused(runnerConfig.deviceId, placement);
54384
+ return {
54385
+ success: true,
54386
+ kind: "pending"
54387
+ };
54388
+ }
54389
+ const pipeline = placement.pipeline;
54390
+ const attachConfig = withAttachPipeline(runnerConfig, pipeline);
53244
54391
  const dispatchGuard = this.deps.ledger.markDispatching(runnerConfig.deviceId);
53245
54392
  try {
53246
54393
  await this.attachOn(targetNodeId, attachConfig);
@@ -53463,18 +54610,17 @@ var PlacementService = class {
53463
54610
  const nodeCaps = await this.deps.settingsStore.buildNodeCaps();
53464
54611
  const nodeWeights = await this.deps.settingsStore.buildNodeWeights();
53465
54612
  const attachedDelta = /* @__PURE__ */ new Map();
53466
- const bumpAttached = (nodeId, by) => {
54613
+ const deviceDelta = /* @__PURE__ */ new Map();
54614
+ const bumpAttached = (nodeId, by, deviceKey) => {
53467
54615
  attachedDelta.set(nodeId, (attachedDelta.get(nodeId) ?? 0) + by);
54616
+ if (deviceKey === void 0) return;
54617
+ const perDevice = deviceDelta.get(nodeId) ?? /* @__PURE__ */ new Map();
54618
+ perDevice.set(deviceKey, (perDevice.get(deviceKey) ?? 0) + by);
54619
+ deviceDelta.set(nodeId, perDevice);
53468
54620
  };
53469
- const talliedLoads = () => loads.map((load) => {
53470
- const delta = attachedDelta.get(load.nodeId) ?? 0;
53471
- if (delta === 0) return load;
53472
- return {
53473
- ...load,
53474
- attachedCameras: Math.max(0, load.attachedCameras + delta)
53475
- };
53476
- });
54621
+ const talliedLoads = () => loads.map((load) => tallyLoad(load, attachedDelta.get(load.nodeId) ?? 0, deviceDelta.get(load.nodeId)));
53477
54622
  let migrated = 0;
54623
+ const skipped = [];
53478
54624
  for (const [deviceId, config] of this.deps.ledger.configEntries()) {
53479
54625
  if (this.deps.isIdleSessionCamera(deviceId)) continue;
53480
54626
  const current = this.deps.ledger.getAssignment(deviceId);
@@ -53499,6 +54645,28 @@ var PlacementService = class {
53499
54645
  const targetLoad = tallied.find((l) => l.nodeId === decision.agentNodeId);
53500
54646
  if (currentLoad && targetLoad && capacityImprovement(currentLoad, targetLoad, nodeWeights) < minImprovement) continue;
53501
54647
  }
54648
+ const placement = await this.resolvePlacement({
54649
+ deviceId,
54650
+ nodeId: decision.agentNodeId,
54651
+ loads: talliedLoads(),
54652
+ currentDeviceKey: null,
54653
+ path: "rebalance",
54654
+ read: { kind: "cached-catalog" }
54655
+ });
54656
+ if (placement.kind !== "resolved") {
54657
+ const reason = placement.kind === "root-refused" ? `root-unrunnable:${placement.nodeId}/${placement.deviceKey ?? "default-pool"}` : `${placement.what}-unavailable:${placement.nodeId}`;
54658
+ skipped.push({
54659
+ deviceId,
54660
+ targetNodeId: decision.agentNodeId,
54661
+ reason
54662
+ });
54663
+ if (current) this.noteMigrationSkipped(deviceId, current.agentNodeId, decision.agentNodeId, reason);
54664
+ else if (placement.kind === "root-refused") this.leavePendingRootRefused(deviceId, placement);
54665
+ else this.leavePendingUnanswered(deviceId, placement.nodeId, placement.what);
54666
+ continue;
54667
+ }
54668
+ this.migrationSkips.delete(deviceId);
54669
+ const targetConfig = withAttachPipeline(config, placement.pipeline);
53502
54670
  if (current) {
53503
54671
  await this.detachOn(current.agentNodeId, deviceId).catch((err) => {
53504
54672
  const msg = errMsg$1(err);
@@ -53507,19 +54675,13 @@ var PlacementService = class {
53507
54675
  meta: { error: msg }
53508
54676
  });
53509
54677
  });
53510
- bumpAttached(current.agentNodeId, -1);
54678
+ bumpAttached(current.agentNodeId, -1, config.deviceKey);
53511
54679
  }
53512
- const reResolved = await this.deps.settingsStore.resolvePipelineForDevice(deviceId, decision.agentNodeId);
53513
- const targetConfig = reResolved.steps.length > 0 ? {
53514
- ...config,
53515
- steps: reResolved.steps,
53516
- audio: reResolved.audio ?? null
53517
- } : config;
53518
54680
  this.deps.ledger.setConfig(deviceId, targetConfig);
53519
54681
  const dispatchGuard = this.deps.ledger.markDispatching(deviceId);
53520
54682
  try {
53521
54683
  await this.attachOn(decision.agentNodeId, targetConfig);
53522
- bumpAttached(decision.agentNodeId, 1);
54684
+ bumpAttached(decision.agentNodeId, 1, targetConfig.deviceKey);
53523
54685
  this.deps.ledger.recordAssignment(deviceId, decision.agentNodeId, "rebalance", false);
53524
54686
  } finally {
53525
54687
  dispatchGuard.dispose();
@@ -53527,7 +54689,10 @@ var PlacementService = class {
53527
54689
  migrated++;
53528
54690
  }
53529
54691
  ctx.logger.info("rebalance: migrated cameras", { meta: { migrated } });
53530
- return { migrated };
54692
+ return {
54693
+ migrated,
54694
+ skipped
54695
+ };
53531
54696
  }
53532
54697
  getPipelineAssignments() {
53533
54698
  return this.deps.ledger.listAssignments();
@@ -53706,6 +54871,71 @@ var PlacementService = class {
53706
54871
  };
53707
54872
  }
53708
54873
  /**
54874
+ * A re-place found the node it chose has never answered what the placement
54875
+ * needs (its catalog, or its device probe + pool health): the camera stays
54876
+ * pending with the node NAMED, and the line fires once per change of pending
54877
+ * state — a sweep re-finding the same unanswering node every tick is not
54878
+ * news, a camera newly stuck on it (or on another node) is.
54879
+ */
54880
+ leavePendingUnanswered(deviceId, nodeId, what) {
54881
+ const reason = what === "catalog" ? `catalog-unavailable:${nodeId}` : `device-health-unavailable:${nodeId}`;
54882
+ if (!this.setPendingIfChanged(deviceId, reason)) return;
54883
+ this.deps.ctx().logger.warn("camera left pending — the chosen node has not answered", {
54884
+ tags: {
54885
+ deviceId,
54886
+ nodeId
54887
+ },
54888
+ meta: {
54889
+ reason,
54890
+ hint: "a re-place never waits for a node; the next retry round places it once the node answers"
54891
+ }
54892
+ });
54893
+ }
54894
+ /**
54895
+ * The NAMED refusal of a root-less attach (D650 round 3): the elected
54896
+ * device cannot run a gated-on root of the camera, so the attach would
54897
+ * detect nothing and — once its assignment were recorded — nothing would
54898
+ * re-place it. The camera stays pending instead, once-per-change logged.
54899
+ */
54900
+ leavePendingRootRefused(deviceId, refusal) {
54901
+ const reason = `root-unrunnable:${refusal.nodeId}/${refusal.deviceKey ?? "default-pool"}`;
54902
+ if (!this.setPendingIfChanged(deviceId, reason)) return;
54903
+ this.deps.ctx().logger.error("attach refused — the elected device cannot run the camera root; camera left pending", {
54904
+ tags: {
54905
+ deviceId,
54906
+ nodeId: refusal.nodeId
54907
+ },
54908
+ meta: {
54909
+ reason,
54910
+ deviceKey: refusal.deviceKey ?? null,
54911
+ droppedRoots: refusal.droppedRoots.join(","),
54912
+ hint: "the root is never jumped: enable a device on this node whose format ships the root model, or disable the device that cannot"
54913
+ }
54914
+ });
54915
+ }
54916
+ /** Last migration-skip reason per camera — the info line fires once per change. */
54917
+ migrationSkips = /* @__PURE__ */ new Map();
54918
+ noteMigrationSkipped(deviceId, sourceNodeId, targetNodeId, reason) {
54919
+ if (this.migrationSkips.get(deviceId) === reason) return;
54920
+ this.migrationSkips.set(deviceId, reason);
54921
+ this.deps.ctx().logger.info("rebalance: migration skipped — camera stays on its source", {
54922
+ tags: {
54923
+ deviceId,
54924
+ nodeId: targetNodeId
54925
+ },
54926
+ meta: {
54927
+ sourceNodeId,
54928
+ reason
54929
+ }
54930
+ });
54931
+ }
54932
+ /** Record `reason`; `true` when it differs from the camera's previous pending reason. */
54933
+ setPendingIfChanged(deviceId, reason) {
54934
+ const prior = this.deps.ledger.getPendingReason(deviceId);
54935
+ this.deps.ledger.setPendingReason(deviceId, reason);
54936
+ return prior !== reason;
54937
+ }
54938
+ /**
53709
54939
  * Normalizes the `decision.kind === 'pending'` → `setPendingReason` +
53710
54940
  * `warn` half shared by `dispatchCamera`/`unassignPipeline`/`rebalance`
53711
54941
  * (smell #12). Each call site keeps its own bail (return / fall-through /
@@ -53962,13 +55192,16 @@ var NodeLifecycleHandler = class {
53962
55192
  for (const deviceId of candidates) {
53963
55193
  const cached = this.deps.ledger.getConfig(deviceId);
53964
55194
  if (!cached) continue;
53965
- const resolved = await this.deps.settingsStore.resolvePipelineForDevice(deviceId, nodeId);
53966
- if (resolved.steps.length === 0) continue;
53967
- const next = {
53968
- ...cached,
53969
- steps: resolved.steps,
53970
- audio: resolved.audio ?? null
53971
- };
55195
+ if (this.isUnplacedPlaceholder(deviceId, cached, nodeId)) continue;
55196
+ const outcome = await this.deps.resolveAttachPipeline({
55197
+ deviceId,
55198
+ nodeId,
55199
+ deviceKey: cached.deviceKey
55200
+ });
55201
+ if (this.refusedReattach(deviceId, nodeId, outcome)) continue;
55202
+ const pipeline = outcome.pipeline;
55203
+ if (pipeline.steps.length === 0) continue;
55204
+ const next = withAttachPipeline(cached, pipeline);
53972
55205
  this.deps.ledger.setConfig(deviceId, next);
53973
55206
  affected.push({
53974
55207
  deviceId,
@@ -54021,6 +55254,43 @@ var NodeLifecycleHandler = class {
54021
55254
  }
54022
55255
  }
54023
55256
  /**
55257
+ * A re-attach whose elected device cannot run the camera's root is REFUSED
55258
+ * (D650 round 3): the live attach is left as it is rather than replaced by
55259
+ * one that detects nothing. Said once per pass, with the roots.
55260
+ */
55261
+ refusedReattach(deviceId, nodeId, outcome) {
55262
+ if (outcome.kind !== "root-refused") return false;
55263
+ this.deps.logger.error("re-attach refused — the elected device cannot run the camera root", {
55264
+ tags: {
55265
+ deviceId,
55266
+ nodeId
55267
+ },
55268
+ meta: {
55269
+ deviceKey: outcome.deviceKey ?? null,
55270
+ droppedRoots: outcome.droppedRoots.join(",")
55271
+ }
55272
+ });
55273
+ return true;
55274
+ }
55275
+ /**
55276
+ * A ledger config with no assignment AND no elected device is the standing
55277
+ * path's PLACEHOLDER (D650 round 2): a camera the balancer has not placed,
55278
+ * hydrated before any election. Attaching it here would place it on this
55279
+ * node behind the balancer's back, on no device — the pending-retry sweep
55280
+ * owns it. Skipped, and said so.
55281
+ */
55282
+ isUnplacedPlaceholder(deviceId, cached, nodeId) {
55283
+ if (this.deps.ledger.hasAssignment(deviceId) || cached.deviceKey !== void 0) return false;
55284
+ this.deps.logger.info("readiness: skipped an unplaced placeholder config", {
55285
+ tags: {
55286
+ deviceId,
55287
+ nodeId
55288
+ },
55289
+ meta: { reason: "no assignment and no elected device — the pending-retry sweep places it" }
55290
+ });
55291
+ return true;
55292
+ }
55293
+ /**
54024
55294
  * Walk every cached RunnerCameraConfig and re-attach any whose stored
54025
55295
  * `steps` are empty AND whose latest resolve produces a non-empty
54026
55296
  * pipeline. Catches the "camera dispatched mid-readiness" race where
@@ -54031,13 +55301,16 @@ var NodeLifecycleHandler = class {
54031
55301
  for (const [deviceId, cached] of this.deps.ledger.configEntries()) {
54032
55302
  if ((cached.steps?.length ?? 0) > 0) continue;
54033
55303
  if ((this.deps.ledger.getAssignment(deviceId)?.agentNodeId ?? this.deps.localNodeId()) !== nodeId) continue;
54034
- const resolved = await this.deps.settingsStore.resolvePipelineForDevice(deviceId, nodeId);
54035
- if (resolved.steps.length === 0) continue;
54036
- const next = {
54037
- ...cached,
54038
- steps: resolved.steps,
54039
- audio: resolved.audio ?? null
54040
- };
55304
+ if (this.isUnplacedPlaceholder(deviceId, cached, nodeId)) continue;
55305
+ const outcome = await this.deps.resolveAttachPipeline({
55306
+ deviceId,
55307
+ nodeId,
55308
+ deviceKey: cached.deviceKey
55309
+ });
55310
+ if (this.refusedReattach(deviceId, nodeId, outcome)) continue;
55311
+ const pipeline = outcome.pipeline;
55312
+ if (pipeline.steps.length === 0) continue;
55313
+ const next = withAttachPipeline(cached, pipeline);
54041
55314
  this.deps.ledger.setConfig(deviceId, next);
54042
55315
  try {
54043
55316
  await this.deps.attachOn(nodeId, next);
@@ -55420,9 +56693,11 @@ function wireOrchestratorSubscriptions(deps) {
55420
56693
  });
55421
56694
  const unsubFrameTracked = deps.eventBus.subscribe({ category: EventCategory.PipelineAnalyticsFrameTracked }, (event) => {
55422
56695
  if (!isEvent(event, EventCategory.PipelineAnalyticsFrameTracked)) return;
55423
- if (event.data.hasLiveTrack !== true) return;
55424
56696
  const deviceId = event.data.deviceId;
55425
56697
  if (typeof deviceId !== "number") return;
56698
+ const liveTrackCount = event.data.liveTrackCount;
56699
+ if (typeof liveTrackCount === "number") deps.noteSessionTrackedFrame(deviceId, liveTrackCount);
56700
+ if (event.data.hasLiveTrack !== true) return;
55426
56701
  deps.noteSessionSubjectPresent(deviceId);
55427
56702
  });
55428
56703
  const unsubMotionAnalysis = deps.eventBus.subscribe({ category: EventCategory.MotionAnalysis }, (event) => {
@@ -55564,303 +56839,54 @@ var PackageZoneGate = class {
55564
56839
  }
55565
56840
  };
55566
56841
  //#endregion
55567
- //#region src/pipeline-resolver.ts
55568
- /**
55569
- * The `package-detection` root step id. Zone-driven activation
55570
- * ({@link applyPackageZoneActivation}) is keyed on it — the step has NO
55571
- * user-facing toggle; a drawn package zone is the sole enable signal.
55572
- */
55573
- var PACKAGE_DETECTION_STEP_ID = "package-detection";
55574
- /**
55575
- * Zone-driven activation for the toggle-less `package-detection` root step.
55576
- *
55577
- * The step runs for a device IFF it has ≥1 package zone (an enabled
55578
- * `package`-stage zone rule referencing geometry). There is deliberately NO
55579
- * operator toggle — drawing a package zone IS the enable, removing every
55580
- * package zone IS the disable. This gate reconciles the resolved step tree to
55581
- * that rule:
55582
- *
55583
- * - `hasPackageZone` true → the step is ensured present as an ENABLED root
55584
- * (a pre-existing node keeps its `modelId` / `settings` — e.g. a per-node
55585
- * model choice); default-on where a zone is present.
55586
- * - `hasPackageZone` false → any `package-detection` node is stripped so the
55587
- * runner never dispatches it.
55588
- *
55589
- * `package-detection` is a ROOT (`inputClasses: null`) so it only ever appears
55590
- * at the top level — no recursion into children needed. Every other step is
55591
- * left untouched. Immutable: returns a fresh array only when a change applies.
55592
- */
55593
- function applyPackageZoneActivation(steps, hasPackageZone) {
55594
- const existing = steps.find((s) => s.addonId === PACKAGE_DETECTION_STEP_ID);
55595
- if (!hasPackageZone) {
55596
- if (!existing) return steps;
55597
- return steps.filter((s) => s.addonId !== PACKAGE_DETECTION_STEP_ID);
55598
- }
55599
- if (existing) {
55600
- if (existing.enabled === true) return steps;
55601
- return steps.map((s) => s.addonId === "package-detection" ? {
55602
- ...s,
55603
- enabled: true
55604
- } : s);
55605
- }
55606
- const node = {
55607
- addonId: PACKAGE_DETECTION_STEP_ID,
55608
- enabled: true,
55609
- children: []
55610
- };
55611
- return [...steps, node];
55612
- }
55613
- /**
55614
- * Steps that are OFF by the per-camera gate DEFAULT (i.e. when the camera's
55615
- * `stepToggles[addonId]` is unset). Mirrors each step's catalog
55616
- * `enabledByDefault: false` — the heavy / opt-in classifiers + segmentation.
55617
- *
55618
- * The pure resolver derives the default gate from the LIVE catalog
55619
- * (`addon.enabledByDefault ?? true`), so it stays correct if a future
55620
- * default-off step ships. This literal set is the catalog-FREE fallback used
55621
- * by the one-shot gating migration ({@link migrateStepGating}), which runs at
55622
- * boot with no guaranteed live executor catalog. The two agree for every
55623
- * step that exists today.
55624
- *
55625
- * `package-detection` is deliberately NOT listed — it is ZONE-driven
55626
- * ({@link applyPackageZoneActivation}) and never governed by the gate.
55627
- */
55628
- var DEFAULT_OFF_STEP_IDS = new Set([
55629
- "animal-classifier",
55630
- "bird-classifier",
55631
- "vehicle-classifier",
55632
- "segmentation"
55633
- ]);
55634
- /**
55635
- * Catalog-free default gate for `addonId` — `true` unless the step is in
55636
- * {@link DEFAULT_OFF_STEP_IDS}. Used by the migration (no live catalog); the
55637
- * resolver prefers the catalog's own `enabledByDefault ?? true`.
55638
- */
55639
- function defaultStepGate(addonId) {
55640
- return !DEFAULT_OFF_STEP_IDS.has(addonId);
55641
- }
55642
- /**
55643
- * PROVISIONING presence for `(addon, format)` — the node "provides" the step
55644
- * iff the catalog lists at least one model with a build for the node's engine
55645
- * format. Byte-for-byte the same test the admin-ui matrices use
55646
- * (`addon.models.some(m => Boolean(m.formats[fmt]))`). An absent `modelId`
55647
- * on the resolved step is still "Auto" (the node resolves its own format
55648
- * default) — provisioning does NOT require an operator-pinned model, only a
55649
- * compatible model to EXIST for the format.
55650
- */
55651
- function addonHasCompatibleModel(addon, format) {
55652
- return (addon.models ?? []).some((m) => Boolean(m.formats[format]));
55653
- }
55654
- /**
55655
- * Device-aware resolver — the SINGLE per-camera gate + per-DEVICE skip check.
55656
- *
55657
- * Step `S` runs for camera `C` on device `D` ⇔
55658
- * gate(C, S) — `stepToggles[S]` ?? `addon.enabledByDefault ?? true`
55659
- * AND provided(D, S) — the catalog lists a model with a build for D's FORMAT
55660
- *
55661
- * Enablement is the per-camera `stepToggles` authority ONLY. Provisioning
55662
- * (`modelId` / `settings`) is NOT applied here — the returned tree carries bare
55663
- * `{ addonId, enabled: true, children }` nodes; {@link applyDeviceProvisioning}
55664
- * attaches model + settings at dispatch from the per-(node,device) base + the
55665
- * per-camera-device override.
55666
- *
55667
- * A step gated ON but with NO compatible model for the DEVICE's format is
55668
- * DROPPED and recorded in `skipped` so the caller can surface a visible "gated
55669
- * on, no model on this device" status at dispatch time.
55670
- *
55671
- * `package-detection` is excluded here — it is zone-driven
55672
- * ({@link applyPackageZoneActivation}, applied by the caller).
55673
- *
55674
- * "Inference disabled for this camera" is NOT handled here — the caller
55675
- * (resolvePipelineForDevice) consults the detection-pipeline binding and
55676
- * short-circuits to an empty pipeline before invoking this resolver.
55677
- */
55678
- function resolvePipelineWithSkips(cameraSettings, agentNodeId, deviceFormat, catalog) {
55679
- const cam = cameraSettings ?? {};
55680
- const wholesale = cam.pipelineByAgent?.[agentNodeId];
55681
- if (wholesale) return {
55682
- config: {
55683
- steps: mergeEnabledByDefaultSteps(wholesale.steps, catalog),
55684
- audio: wholesale.audio ? {
55685
- modelId: wholesale.audio.modelId,
55686
- enabled: wholesale.audio.enabled
55687
- } : null
55688
- },
55689
- skipped: []
55690
- };
55691
- const toggles = cam.stepToggles ?? {};
55692
- const format = deviceFormat;
55693
- const enabled = [];
55694
- const skipped = [];
55695
- for (const slot of catalog.slots) for (const addon of slot.addons) {
55696
- const addonId = addon.id;
55697
- if (addonId === "package-detection") continue;
55698
- if (!(toggles[addonId] ?? addon.enabledByDefault ?? true)) continue;
55699
- if (!addonHasCompatibleModel(addon, format)) {
55700
- skipped.push({
55701
- addonId,
55702
- reason: "no-compatible-model"
55703
- });
55704
- continue;
55705
- }
55706
- enabled.push({ addonId });
55707
- }
55708
- const audioAddons = [];
55709
- const videoAddons = [];
55710
- for (const entry of enabled) {
55711
- const meta = findInCatalog(catalog, entry.addonId);
55712
- if (!meta) continue;
55713
- if (meta.slot === "audio-classifier") audioAddons.push(entry);
55714
- else videoAddons.push(entry);
55715
- }
55716
- return {
55717
- config: {
55718
- steps: buildTreeFromAddons(videoAddons, catalog),
55719
- audio: audioAddons.length > 0 ? { enabled: true } : null
55720
- },
55721
- skipped
55722
- };
55723
- }
55724
- function findInCatalog(catalog, addonId) {
55725
- for (const slot of catalog.slots) for (const addon of slot.addons) if (addon.id === addonId) return addon;
55726
- return null;
55727
- }
55728
- /**
55729
- * Compose a step tree from a flat list of enabled video addons using
55730
- * catalog compatibility: an addon whose `inputClasses` overlaps a
55731
- * candidate parent's `outputClasses` attaches as the parent's child.
55732
- * Addons with `inputClasses` null/empty become roots.
55733
- *
55734
- * Multiple addons in the same slot with compatible inputClasses run in
55735
- * parallel — they attach to the same parent as siblings. Example: two
55736
- * classifiers (bird-classifier + animal-classifier) both accept 'animal'
55737
- * → both attach to object-detection as siblings, both receive every
55738
- * animal crop.
55739
- *
55740
- * Unparented leaves (inputClasses set, but no matching parent in the
55741
- * enabled set) are DROPPED. The runner would have nothing to feed them
55742
- * anyway — better silent drop than dispatching a dead step.
55743
- */
55744
- function buildTreeFromAddons(enabled, catalog) {
55745
- const bySlot = /* @__PURE__ */ new Map();
55746
- for (const slot of catalog.slots) bySlot.set(slot.id, slot);
55747
- const nodes = /* @__PURE__ */ new Map();
55748
- const metaById = /* @__PURE__ */ new Map();
55749
- for (const { addonId } of enabled) {
55750
- const meta = findInCatalog(catalog, addonId);
55751
- if (!meta) continue;
55752
- metaById.set(addonId, meta);
55753
- nodes.set(addonId, {
55754
- addonId,
55755
- enabled: true,
55756
- children: []
56842
+ //#region src/package-zone-reattach.ts
56843
+ /**
56844
+ * Re-attach a placed camera after its `package` zone rules changed.
56845
+ *
56846
+ * A package zone flips the toggle-less `package-detection` root on or off
56847
+ * (`applyPackageZoneActivation`), which changes the step TREE, so the live
56848
+ * attach must be re-resolved rather than wait for the next natural attach.
56849
+ * It goes through the one owner of an attach's steps (D650) on the device the
56850
+ * camera is already elected onto — a bare re-resolve here used to gate on the
56851
+ * node DEFAULT format and drop the device's provisioning and jump roster.
56852
+ */
56853
+ /** No-op for a camera that is not placed or has no ledger config. Never throws. */
56854
+ async function reattachAfterPackageZoneChange(deps, deviceId) {
56855
+ const assignment = deps.ledger.getAssignment(deviceId);
56856
+ const cached = deps.ledger.getConfig(deviceId);
56857
+ if (!assignment || !cached) return;
56858
+ const nodeId = assignment.agentNodeId;
56859
+ try {
56860
+ const outcome = await deps.resolveAttachPipeline({
56861
+ deviceId,
56862
+ nodeId,
56863
+ deviceKey: cached.deviceKey
55757
56864
  });
55758
- }
55759
- const roots = [];
55760
- for (const [addonId, node] of nodes) {
55761
- const inputs = metaById.get(addonId).inputClasses;
55762
- if (!inputs || inputs.length === 0) {
55763
- roots.push(node);
55764
- continue;
55765
- }
55766
- let parent = null;
55767
- for (const [candidateId, candidateNode] of nodes) {
55768
- if (candidateId === addonId) continue;
55769
- if (metaById.get(candidateId).outputClasses.some((c) => inputs.includes(c))) {
55770
- parent = candidateNode;
55771
- break;
55772
- }
55773
- }
55774
- if (parent) parent.children.push(node);
55775
- }
55776
- return roots;
55777
- }
55778
- /**
55779
- * Back-fill catalog steps EXPLICITLY flagged `backfillIntoExistingOverrides`
55780
- * but ENTIRELY ABSENT from an existing step tree — reconciles a stored
55781
- * wholesale `pipelineByAgent` snapshot with genuinely-new default steps.
55782
- *
55783
- * Why it's needed: a `pipelineByAgent` wholesale override SNAPSHOTS the full
55784
- * tree. A step introduced AFTER the snapshot (e.g. `clip-embedding` for object
55785
- * semantic search) would otherwise never reach that camera.
55786
- *
55787
- * Why the flag (not `enabledByDefault`): absence-from-a-snapshot is ambiguous —
55788
- * it can mean "predates this step" OR "operator deliberately pruned it". A
55789
- * broad "back-fill every default-on step" would resurrect deliberate removals
55790
- * of long-existing steps (face-detection, etc.), an operator-intent
55791
- * regression. Only a step that opts in via `backfillIntoExistingOverrides`
55792
- * (set on genuinely-new default steps ONLY) re-enters existing overrides.
55793
- *
55794
- * Non-destructive by design:
55795
- * - Only steps whose id is absent from the WHOLE tree are added. A step the
55796
- * operator has already seen — present anywhere, even `enabled: false` —
55797
- * is left exactly as-is (its enabled flag is honoured).
55798
- * - Only `backfillIntoExistingOverrides === true` addons are candidates.
55799
- * Every other step (including long-standing default-on members) is left
55800
- * untouched, so a deliberately-pruned override stays pruned.
55801
- * - Audio-classifier addons are skipped — audio lives in
55802
- * `CameraPipelineConfig.audio`, never in the video `steps` tree.
55803
- * - A candidate attaches by catalog compat (its `inputClasses` overlap a
55804
- * present node's `outputClasses`), mirroring {@link buildTreeFromAddons}.
55805
- * A candidate with non-empty `inputClasses` and NO compatible parent is
55806
- * dropped (never re-rooted) so a crop-scoped embedder never regresses to
55807
- * running on the full frame. Empty `inputClasses` ⇒ a genuine root step.
55808
- */
55809
- function mergeEnabledByDefaultSteps(steps, catalog) {
55810
- if (steps.length === 0) return steps;
55811
- const present = /* @__PURE__ */ new Set();
55812
- const collect = (nodes) => {
55813
- for (const n of nodes) {
55814
- present.add(n.addonId);
55815
- if (n.children?.length) collect(n.children);
55816
- }
55817
- };
55818
- collect(steps);
55819
- const catalogById = /* @__PURE__ */ new Map();
55820
- for (const slot of catalog.slots) for (const addon of slot.addons) catalogById.set(addon.id, addon);
55821
- const newRoots = [];
55822
- const childrenByParent = /* @__PURE__ */ new Map();
55823
- for (const slot of catalog.slots) {
55824
- if (slot.id === "audio-classifier") continue;
55825
- for (const addon of slot.addons) {
55826
- if (addon.backfillIntoExistingOverrides !== true) continue;
55827
- if (present.has(addon.id)) continue;
55828
- const node = {
55829
- addonId: addon.id,
55830
- enabled: true,
55831
- children: []
55832
- };
55833
- const inputs = addon.inputClasses;
55834
- if (!inputs || inputs.length === 0) {
55835
- newRoots.push(node);
55836
- continue;
55837
- }
55838
- let parentId = null;
55839
- for (const presentId of present) {
55840
- const meta = catalogById.get(presentId);
55841
- if (!meta) continue;
55842
- if (meta.outputClasses.some((c) => inputs.includes(c))) {
55843
- parentId = presentId;
55844
- break;
56865
+ if (outcome.kind === "root-refused") {
56866
+ deps.logger.error("package zone-rule re-attach refused — the elected device cannot run the camera root", {
56867
+ tags: {
56868
+ deviceId,
56869
+ nodeId
56870
+ },
56871
+ meta: {
56872
+ deviceKey: outcome.deviceKey ?? null,
56873
+ droppedRoots: outcome.droppedRoots.join(",")
55845
56874
  }
55846
- }
55847
- if (!parentId) continue;
55848
- const list = childrenByParent.get(parentId) ?? [];
55849
- list.push(node);
55850
- childrenByParent.set(parentId, list);
56875
+ });
56876
+ return;
55851
56877
  }
56878
+ const next = withAttachPipeline(cached, outcome.pipeline);
56879
+ deps.ledger.setConfig(deviceId, next);
56880
+ await deps.attachOn(nodeId, next);
56881
+ } catch (err) {
56882
+ deps.logger.warn("package zone-rule change re-dispatch failed", {
56883
+ tags: {
56884
+ deviceId,
56885
+ nodeId
56886
+ },
56887
+ meta: { error: errMsg$1(err) }
56888
+ });
55852
56889
  }
55853
- if (childrenByParent.size === 0 && newRoots.length === 0) return steps;
55854
- const attach = (nodes) => nodes.map((n) => {
55855
- const rebuiltChildren = attach(n.children ?? []);
55856
- const extra = childrenByParent.get(n.addonId) ?? [];
55857
- const mergedChildren = [...rebuiltChildren, ...extra];
55858
- return mergedChildren.length > 0 ? {
55859
- ...n,
55860
- children: mergedChildren
55861
- } : n;
55862
- });
55863
- return [...attach(steps), ...newRoots];
55864
56890
  }
55865
56891
  //#endregion
55866
56892
  //#region src/node-step-settings-fold.ts
@@ -56091,6 +57117,10 @@ var PipelineSettingsStore = class PipelineSettingsStore {
56091
57117
  this.deps = deps;
56092
57118
  }
56093
57119
  disposed = false;
57120
+ /** Whether `dispose()` has run — a `null` catalog from a bounded wait means "not answered" only while this is false. */
57121
+ isDisposed() {
57122
+ return this.disposed;
57123
+ }
56094
57124
  /** Called once from the addon's `onShutdown()`. Idempotent. */
56095
57125
  dispose() {
56096
57126
  this.disposed = true;
@@ -56382,35 +57412,140 @@ var PipelineSettingsStore = class PipelineSettingsStore {
56382
57412
  }
56383
57413
  catalogCache = /* @__PURE__ */ new Map();
56384
57414
  /**
56385
- * Public, non-blocking accessor for a node's cached `PipelineSchema` (the
56386
- * balancer's root-capability guard reads the object-detection model set from
56387
- * it). Returns `null` on cold start / unreachable node — the caller
56388
- * fails-open. Never blocks (unlike {@link waitForAgentAndCatalog}).
57415
+ * A node's `PipelineSchema`: the cached answer when fresh, else ONE live
57416
+ * `getSchema` RPC (bounded by the transport's request timeout, not by this
57417
+ * store). `null` when that read fails. Unlike {@link waitForAgentAndCatalog}
57418
+ * it does not retry; unlike {@link peekCatalogForAgent} it DOES wait for the
57419
+ * node — use the peek on any path that must never wait.
56389
57420
  */
56390
57421
  async getCatalogForNode(nodeId) {
56391
57422
  return this.getCatalogForAgent(nodeId);
56392
57423
  }
57424
+ /**
57425
+ * The node's catalog for a LIVE eligibility read, in one of two explicit
57426
+ * modes (D650 round 3, bounded in the pre-ship review):
57427
+ * - `unbounded` — WAIT until the node's executor answers, the same
57428
+ * loss-proof reconcile the resolve uses ({@link awaitExecutorCatalog}).
57429
+ * For a FIRST placement, which will wait for this catalog to resolve its
57430
+ * pipeline anyway: electing against it too keeps the root guard from
57431
+ * failing open. `null` only when the store is disposed mid-wait.
57432
+ * - `one-round` — ONE reconcile round (one `getSchema`, no acquire wait).
57433
+ * `null` means "not answered". For a read nothing waits on — a background
57434
+ * refresher — where the unbounded form is a loop that outlives the node
57435
+ * it is asking (D3): a `getSchema` plus a 30 s acquire per round and a
57436
+ * WARN every round, for as long as the key is held.
57437
+ */
57438
+ async awaitCatalogForNode(nodeId, wait) {
57439
+ return this.awaitExecutorCatalog(nodeId, wait === "one-round" ? { maxRounds: 1 } : {});
57440
+ }
57441
+ /**
57442
+ * Per-node catalog generation. {@link invalidateCatalog} bumps it; a
57443
+ * refresh records its answer only if the generation it started under is
57444
+ * still current, so a read that began BEFORE an invalidation can never
57445
+ * re-install the catalog the invalidation just removed.
57446
+ */
57447
+ catalogGeneration = /* @__PURE__ */ new Map();
57448
+ /** Bumped by a full {@link invalidateCatalog}; part of every node's generation. */
57449
+ catalogEpoch = 0;
57450
+ catalogGenerationOf(nodeId) {
57451
+ return `${this.catalogEpoch}:${this.catalogGeneration.get(nodeId) ?? 0}`;
57452
+ }
57453
+ /** Nodes whose last catalog read FAILED — the warn fires once per failing stretch. */
57454
+ catalogReadFailing = /* @__PURE__ */ new Set();
57455
+ /** Nodes being served a catalog past its TTL — the warn fires once per stale stretch. */
57456
+ catalogServedStale = /* @__PURE__ */ new Set();
57457
+ /** Nodes with a background catalog refresh in flight (single-flight per node). */
57458
+ catalogRefreshInFlight = /* @__PURE__ */ new Set();
57459
+ /**
57460
+ * A catalog served this far past its TTL is WORTH a warn: its background
57461
+ * refresh has not landed in ten refresh windows. Below this, serving past
57462
+ * the TTL is by construction — the re-place sweep runs every 60 s against a
57463
+ * 30 s TTL, so every round finds the entry expired and refreshes it behind
57464
+ * the read — and a line per round said nothing (pre-ship review, M5).
57465
+ */
57466
+ static CATALOG_STALE_WARN_MS = 5 * 6e4;
57467
+ /**
57468
+ * Cache-only catalog read — NEVER awaits the node (D650 round 2). Serves
57469
+ * the last answer the node gave, even past its TTL (a stale catalog is a
57470
+ * structure that was true a minute ago; its absence is a camera left
57471
+ * pending), and starts ONE background {@link getCatalogForAgent} when the
57472
+ * entry is missing or expired, so the next caller sees a fresh answer.
57473
+ * Returns `null` only when the node has never answered.
57474
+ */
57475
+ peekCatalogForAgent(nodeId) {
57476
+ const hit = this.catalogCache.get(`${nodeId}::*`);
57477
+ const ageMs = hit === void 0 ? null : Date.now() - hit.at;
57478
+ const fresh = ageMs !== null && ageMs < PipelineSettingsStore.CATALOG_CACHE_TTL_MS;
57479
+ if (fresh) this.catalogServedStale.delete(nodeId);
57480
+ else if (ageMs !== null && ageMs >= PipelineSettingsStore.CATALOG_STALE_WARN_MS && !this.catalogServedStale.has(nodeId)) {
57481
+ this.catalogServedStale.add(nodeId);
57482
+ this.deps.logger.warn("serving a node catalog long past its TTL — its refresh has not landed", {
57483
+ tags: { nodeId },
57484
+ meta: {
57485
+ ageMs,
57486
+ ttlMs: PipelineSettingsStore.CATALOG_CACHE_TTL_MS,
57487
+ warnAfterMs: PipelineSettingsStore.CATALOG_STALE_WARN_MS
57488
+ }
57489
+ });
57490
+ }
57491
+ if (!fresh && !this.catalogRefreshInFlight.has(nodeId)) {
57492
+ this.catalogRefreshInFlight.add(nodeId);
57493
+ this.getCatalogForAgent(nodeId).finally(() => {
57494
+ this.catalogRefreshInFlight.delete(nodeId);
57495
+ });
57496
+ }
57497
+ return hit?.schema ?? null;
57498
+ }
57499
+ /**
57500
+ * How many times one {@link getCatalogForAgent} call re-reads after its
57501
+ * answer was fenced out by an invalidation, before it gives up (`null`, which
57502
+ * the callers treat as "not answered"). Invalidations come from readiness
57503
+ * epochs and operator resets — a bound, not a budget.
57504
+ */
57505
+ static CATALOG_FENCE_REREADS = 3;
56393
57506
  async getCatalogForAgent(nodeId) {
56394
57507
  const api = this.deps.api();
56395
57508
  if (!api) return null;
56396
57509
  const engineKey = `${nodeId}::*`;
56397
- const now = Date.now();
56398
- const hit = this.catalogCache.get(engineKey);
56399
- if (hit && now - hit.at < PipelineSettingsStore.CATALOG_CACHE_TTL_MS) return hit.schema;
56400
- try {
56401
- const schema = await api.pipelineExecutor.getSchema.query({ nodeId });
56402
- this.catalogCache.set(engineKey, {
56403
- schema,
56404
- at: now
56405
- });
56406
- return schema;
56407
- } catch (err) {
56408
- this.deps.logger.debug("getCatalogForAgent failed", {
57510
+ for (let reread = 0; !this.disposed; reread++) {
57511
+ const now = Date.now();
57512
+ const hit = this.catalogCache.get(engineKey);
57513
+ if (hit && now - hit.at < PipelineSettingsStore.CATALOG_CACHE_TTL_MS) return hit.schema;
57514
+ const generation = this.catalogGenerationOf(nodeId);
57515
+ let schema;
57516
+ try {
57517
+ schema = await api.pipelineExecutor.getSchema.query({ nodeId });
57518
+ } catch (err) {
57519
+ if (!this.catalogReadFailing.has(nodeId)) {
57520
+ this.catalogReadFailing.add(nodeId);
57521
+ this.deps.logger.warn("node catalog read failed — placements on it wait or stay pending", {
57522
+ tags: { nodeId },
57523
+ meta: { error: errMsg$1(err) }
57524
+ });
57525
+ }
57526
+ return null;
57527
+ }
57528
+ if (this.catalogReadFailing.delete(nodeId)) this.deps.logger.info("node catalog read recovered", { tags: { nodeId } });
57529
+ if (this.catalogGenerationOf(nodeId) === generation) {
57530
+ this.catalogCache.set(engineKey, {
57531
+ schema,
57532
+ at: now
57533
+ });
57534
+ return schema;
57535
+ }
57536
+ if (reread >= PipelineSettingsStore.CATALOG_FENCE_REREADS) {
57537
+ this.deps.logger.warn("node catalog read fenced repeatedly — giving up this read", {
57538
+ tags: { nodeId },
57539
+ meta: { rereads: reread }
57540
+ });
57541
+ return null;
57542
+ }
57543
+ this.deps.logger.info("node catalog read fenced by an invalidation — re-reading", {
56409
57544
  tags: { nodeId },
56410
- meta: { error: errMsg$1(err) }
57545
+ meta: { reread: reread + 1 }
56411
57546
  });
56412
- return null;
56413
57547
  }
57548
+ return null;
56414
57549
  }
56415
57550
  /**
56416
57551
  * Consolidated catalog-cache invalidation (smell #21). Omit `nodeId` for
@@ -56422,9 +57557,11 @@ var PipelineSettingsStore = class PipelineSettingsStore {
56422
57557
  */
56423
57558
  invalidateCatalog(nodeId) {
56424
57559
  if (nodeId === void 0) {
57560
+ this.catalogEpoch++;
56425
57561
  this.catalogCache.clear();
56426
57562
  return;
56427
57563
  }
57564
+ this.catalogGeneration.set(nodeId, (this.catalogGeneration.get(nodeId) ?? 0) + 1);
56428
57565
  for (const key of Array.from(this.catalogCache.keys())) if (key.startsWith(`${nodeId}::`)) this.catalogCache.delete(key);
56429
57566
  }
56430
57567
  /**
@@ -56496,8 +57633,40 @@ var PipelineSettingsStore = class PipelineSettingsStore {
56496
57633
  *
56497
57634
  * Everything else (boot ordering, catalog cold-start, transient cap
56498
57635
  * registration delay) loops with backoff until the catalog responds.
57636
+ *
57637
+ * `jumpDeviceKeys` (D650) are the node's OTHER enabled devices the runner
57638
+ * may jump a detail step to — `jumpTargetKeys(roster)` of the SAME roster the
57639
+ * attach carries. A detail step the elected device cannot run is kept when
57640
+ * one of them can; empty ⇒ the elected format alone decides.
57641
+ */
57642
+ async resolvePipelineForDevice(deviceId, agentNodeIdOverride, deviceKey, jumpDeviceKeys = []) {
57643
+ const outcome = await this.resolvePipelineWithCatalog(deviceId, agentNodeIdOverride, deviceKey, jumpDeviceKeys, { kind: "await" });
57644
+ if (outcome.kind === "resolved") return outcome.config;
57645
+ throw new Error(`PipelineSettingsStore.resolvePipelineForDevice: catalog unavailable for "${outcome.nodeId}"`);
57646
+ }
57647
+ /**
57648
+ * {@link resolvePipelineForDevice} with the gated-on roots the elected device
57649
+ * cannot run (D650 round 3) — what an attach needs to refuse a camera whose
57650
+ * own detector would not run. Waits for the catalog exactly as it does.
57651
+ */
57652
+ async resolvePipelineForDeviceWithRoots(deviceId, agentNodeId, deviceKey, jumpDeviceKeys) {
57653
+ const outcome = await this.resolvePipelineWithCatalog(deviceId, agentNodeId, deviceKey, jumpDeviceKeys, { kind: "await" });
57654
+ if (outcome.kind === "resolved") return outcome;
57655
+ throw new Error(`PipelineSettingsStore.resolvePipelineForDeviceWithRoots: catalog unavailable for "${outcome.nodeId}"`);
57656
+ }
57657
+ /**
57658
+ * The NON-BLOCKING sibling of {@link resolvePipelineForDevice} (D650 round
57659
+ * 2): the same resolution, but the node's catalog is read from the cache
57660
+ * only ({@link peekCatalogForAgent}). A cold or unanswering node yields
57661
+ * `catalog-unavailable` instead of a wait — the RETRY / re-place paths call
57662
+ * this, so one wedged executor can never hold a sweep (D3: no waiting for a
57663
+ * node; D49: never gate on a fallible read). A cold cache also starts one
57664
+ * background refresh, so the next retry round finds the catalog.
56499
57665
  */
56500
- async resolvePipelineForDevice(deviceId, agentNodeIdOverride, deviceKey) {
57666
+ async resolvePipelineForDeviceIfCatalogCached(deviceId, agentNodeId, deviceKey, jumpDeviceKeys) {
57667
+ return this.resolvePipelineWithCatalog(deviceId, agentNodeId, deviceKey, jumpDeviceKeys, { kind: "cached" });
57668
+ }
57669
+ async resolvePipelineWithCatalog(deviceId, agentNodeIdOverride, deviceKey, jumpDeviceKeys, catalogRead) {
56501
57670
  const agentNodeId = agentNodeIdOverride ?? this.deps.getAssignmentNode(deviceId);
56502
57671
  if (!agentNodeId) throw new Error(`PipelineSettingsStore.resolvePipelineForDevice: no node resolvable for device ${deviceId}`);
56503
57672
  if (!await this.isDetectionPipelineActive(deviceId)) {
@@ -56509,33 +57678,57 @@ var PipelineSettingsStore = class PipelineSettingsStore {
56509
57678
  meta: { reason: "detection-pipeline binding inactive" }
56510
57679
  });
56511
57680
  return {
56512
- steps: [],
56513
- audio: null
57681
+ kind: "resolved",
57682
+ config: {
57683
+ steps: [],
57684
+ audio: null
57685
+ },
57686
+ droppedRoots: []
56514
57687
  };
56515
57688
  }
56516
57689
  const hasPackageZone = await this.deps.hasPackageZone(deviceId);
56517
57690
  const cam = (await this.readCameraSettingsMap())[String(deviceId)] ?? null;
56518
57691
  const wholesale = cam?.pipelineByAgent?.[agentNodeId];
56519
57692
  if (wholesale) {
56520
- const catalog = await this.getCatalogForAgent(agentNodeId);
57693
+ const catalog = catalogRead.kind === "await" ? await this.getCatalogForAgent(agentNodeId) : this.peekCatalogForAgent(agentNodeId);
56521
57694
  const baseSteps = catalog ? mergeEnabledByDefaultSteps(wholesale.steps, catalog) : wholesale.steps;
56522
57695
  return {
56523
- steps: baseSteps.length > 0 ? applyPackageZoneActivation(baseSteps, hasPackageZone) : baseSteps,
56524
- audio: wholesale.audio ? {
56525
- modelId: wholesale.audio.modelId,
56526
- enabled: wholesale.audio.enabled
56527
- } : null
57696
+ kind: "resolved",
57697
+ droppedRoots: [],
57698
+ config: {
57699
+ steps: baseSteps.length > 0 ? applyPackageZoneActivation(baseSteps, hasPackageZone) : baseSteps,
57700
+ audio: wholesale.audio ? {
57701
+ modelId: wholesale.audio.modelId,
57702
+ enabled: wholesale.audio.enabled
57703
+ } : null
57704
+ }
57705
+ };
57706
+ }
57707
+ let catalog;
57708
+ if (catalogRead.kind === "await") catalog = (await this.waitForAgentAndCatalog(agentNodeId)).catalog;
57709
+ else {
57710
+ const cached = this.peekCatalogForAgent(agentNodeId);
57711
+ if (!cached) return {
57712
+ kind: "catalog-unavailable",
57713
+ nodeId: agentNodeId
56528
57714
  };
57715
+ catalog = cached;
56529
57716
  }
56530
- const { catalog } = await this.waitForAgentAndCatalog(agentNodeId);
56531
57717
  const deviceFormat = deviceKey ? parseDeviceKey(deviceKey).format : catalog.selectedEngine.format;
56532
- const { config: resolvedBase, skipped } = resolvePipelineWithSkips(cam, agentNodeId, deviceFormat, catalog);
57718
+ const jumpFormats = [...new Set(jumpDeviceKeys.map((k) => parseDeviceKey(k).format))];
57719
+ const { config: resolvedBase, skipped, droppedRoots } = resolvePipelineWithSkips(cam, agentNodeId, deviceFormat, catalog, jumpFormats);
56533
57720
  if (skipped.length > 0) this.deps.logger.warn("pipeline steps gated on but not provisioned on this node — skipped", {
56534
57721
  tags: {
56535
57722
  deviceId,
56536
57723
  nodeId: agentNodeId
56537
57724
  },
56538
- meta: { skipped: skipped.map((s) => `${s.addonId}:${s.reason}`).join(",") }
57725
+ meta: {
57726
+ skipped: skipped.map((s) => `${s.addonId}:${s.reason}`).join(","),
57727
+ electedDeviceKey: deviceKey ?? null,
57728
+ electedFormat: deviceFormat,
57729
+ jumpDeviceKeys: jumpDeviceKeys.join(","),
57730
+ 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)"
57731
+ }
56539
57732
  });
56540
57733
  const resolved = {
56541
57734
  ...resolvedBase,
@@ -56552,7 +57745,11 @@ var PipelineSettingsStore = class PipelineSettingsStore {
56552
57745
  catalogSlotCount: catalog.slots?.length ?? 0
56553
57746
  }
56554
57747
  });
56555
- return resolved;
57748
+ return {
57749
+ kind: "resolved",
57750
+ config: resolved,
57751
+ droppedRoots
57752
+ };
56556
57753
  }
56557
57754
  /**
56558
57755
  * Loss-proof `pipeline-executor` gate (D8 — reconcile against the
@@ -56686,6 +57883,56 @@ var PipelineSettingsStore = class PipelineSettingsStore {
56686
57883
  return foldNodeStepSettings((await this.agentSettingsState.get())[nodeId]?.inferenceDevices, deviceKey);
56687
57884
  }
56688
57885
  /**
57886
+ * The provisioning SOURCES of every device an attach may run a step on
57887
+ * (D650): for each requested `deviceKey`, its per-(node,device) base (the
57888
+ * same fold as {@link buildNodeInferenceDeviceSteps}) and the camera's
57889
+ * override for that device (the same slot as {@link readCameraDeviceOverride}).
57890
+ * ONE read of each blob for the whole set — the elected device and every
57891
+ * jump target — instead of two reads per device. `''` is the default pool:
57892
+ * its base is the node's folded settings and it has no camera override.
57893
+ */
57894
+ async readDeviceProvisioning(deviceId, nodeId, deviceKeys) {
57895
+ const [agentBlob, cameraMap] = await Promise.all([this.agentSettingsState.get(), this.readCameraSettingsMap()]);
57896
+ const devices = agentBlob[nodeId]?.inferenceDevices;
57897
+ const byDevice = cameraMap[String(deviceId)]?.stepOverridesByDevice?.[nodeId];
57898
+ const out = /* @__PURE__ */ new Map();
57899
+ for (const deviceKey of deviceKeys) {
57900
+ const override = deviceKey === "" ? void 0 : byDevice?.[deviceKey];
57901
+ out.set(deviceKey, {
57902
+ base: foldNodeStepSettings(devices, deviceKey),
57903
+ override: override ? { ...override } : {}
57904
+ });
57905
+ }
57906
+ return out;
57907
+ }
57908
+ /** `${deviceId}:${stepId}` pairs already warned by {@link warnManualJumpsLeaveOverride}. */
57909
+ warnedManualJumpOverride = /* @__PURE__ */ new Set();
57910
+ /**
57911
+ * WARN, once per (camera, step), that an operator `jumpDeviceKey` sends a
57912
+ * step off the elected device while the camera has an override for that
57913
+ * step ON the elected device (D650): the jumped step runs the TARGET's
57914
+ * provisioning, so that override is not applied when the jump is taken.
57915
+ */
57916
+ warnManualJumpsLeaveOverride(deviceId, nodeId, electedDeviceKey, steps) {
57917
+ for (const { stepId, jumpDeviceKey } of steps) {
57918
+ const key = `${deviceId}:${stepId}`;
57919
+ if (this.warnedManualJumpOverride.has(key)) continue;
57920
+ this.warnedManualJumpOverride.add(key);
57921
+ this.deps.logger.warn("manual jumpDeviceKey leaves an elected-device override behind — the jumped step runs the target device provisioning", {
57922
+ tags: {
57923
+ deviceId,
57924
+ nodeId
57925
+ },
57926
+ meta: {
57927
+ step: stepId,
57928
+ electedDeviceKey,
57929
+ jumpDeviceKey,
57930
+ hint: `move the camera's override for ${stepId} to ${jumpDeviceKey}, or drop the jumpDeviceKey`
57931
+ }
57932
+ });
57933
+ }
57934
+ }
57935
+ /**
56689
57936
  * Structural runtime check for `CameraPipelineConfig`. Persisted JSON
56690
57937
  * may be stale or partial across upgrades, so we gate reads on shape
56691
57938
  * rather than trusting the raw object. False = silently ignore that
@@ -57475,7 +58722,7 @@ var ReconcileController = class ReconcileController {
57475
58722
  const cfg = this.deps.ledger.getConfig(deviceId);
57476
58723
  if (!cfg) continue;
57477
58724
  try {
57478
- const result = await this.deps.dispatchCamera(cfg);
58725
+ const result = await this.deps.redispatchCamera(cfg);
57479
58726
  retried++;
57480
58727
  if (result.kind === "pending") stillPending++;
57481
58728
  } catch (err) {
@@ -57586,7 +58833,7 @@ var ReconcileController = class ReconcileController {
57586
58833
  });
57587
58834
  });
57588
58835
  this.deps.ledger.dropAssignment(deviceId, "silent");
57589
- await this.deps.dispatchCamera(cfg).catch((err) => {
58836
+ await this.deps.redispatchCamera(cfg).catch((err) => {
57590
58837
  this.deps.logger.warn("remote-health re-dispatch failed", {
57591
58838
  tags: { deviceId },
57592
58839
  meta: { error: errMsg$1(err) }
@@ -58069,7 +59316,7 @@ var TtlMemo = class {
58069
59316
  });
58070
59317
  if (entry && at - entry.readAt < this.ttlMs) return entry.value;
58071
59318
  try {
58072
- return await this.read(key);
59319
+ return await this.read(key, "miss");
58073
59320
  } catch (err) {
58074
59321
  if (entry) return entry.value;
58075
59322
  throw err;
@@ -58101,7 +59348,7 @@ var TtlMemo = class {
58101
59348
  ageMs: at - entry.readAt
58102
59349
  };
58103
59350
  const fallback = this.lastKnown.get(key);
58104
- const pending = this.read(key);
59351
+ const pending = this.read(key, "miss");
58105
59352
  if (fallback === void 0) return {
58106
59353
  value: await pending,
58107
59354
  stale: false,
@@ -58140,7 +59387,7 @@ var TtlMemo = class {
58140
59387
  * is what re-warms a key immediately after {@link invalidate}.
58141
59388
  */
58142
59389
  async refresh(key) {
58143
- await this.read(key).catch(() => void 0);
59390
+ await this.read(key, "refresh").catch(() => void 0);
58144
59391
  }
58145
59392
  /**
58146
59393
  * Re-read every key still in use and drop the ones nobody has asked for in
@@ -58166,12 +59413,16 @@ var TtlMemo = class {
58166
59413
  this.generations.clear();
58167
59414
  this.lastKnown.clear();
58168
59415
  }
58169
- /** One shared in-flight read per key; stores the result on success. */
58170
- read(key) {
59416
+ /**
59417
+ * One shared in-flight read per key; stores the result on success. A
59418
+ * refresh that finds a miss's read in flight joins it — the waiting caller
59419
+ * chose to wait, and the refresher never awaits the promise it is handed.
59420
+ */
59421
+ read(key, mode) {
58171
59422
  const existing = this.inFlight.get(key);
58172
59423
  if (existing) return existing;
58173
59424
  const generation = this.generations.get(key) ?? 0;
58174
- const pending = this.opts.load(key).then((value) => {
59425
+ const pending = this.opts.load(key, mode).then((value) => {
58175
59426
  const at = this.now();
58176
59427
  const stored = {
58177
59428
  value,
@@ -58365,6 +59616,23 @@ var SessionDispatchController = class {
58365
59616
  */
58366
59617
  motionWatchByDevice = /* @__PURE__ */ new Map();
58367
59618
  /**
59619
+ * The last session REFUSAL per camera (D650 rounds 3–4): the elected device
59620
+ * could not run the camera root, so no session opened and nothing was
59621
+ * recorded. Two readers:
59622
+ * - the refusal line fires once per CHANGE of this entry, not on every
59623
+ * motion edge (a camera re-elected onto the same device every few
59624
+ * seconds is one fact, not a log storm);
59625
+ * - the camera status reads it through {@link refusalReason} and shows
59626
+ * `pending:<reason>` in `reasons.detection`, where a STANDING camera's
59627
+ * pending reason already appears — an armed camera whose every session
59628
+ * is refused must not look like a healthy one waiting for motion.
59629
+ * Deliberately NOT the ledger's `pendingReasons`: an entry there counts as a
59630
+ * standing attach in {@link handleSessionMotion} and would freeze the camera
59631
+ * idle — the trap `isIdleSessionCamera` (index.ts) flags. Cleared when a
59632
+ * session opens and when the camera's detection is stopped.
59633
+ */
59634
+ sessionRefusals = /* @__PURE__ */ new Map();
59635
+ /**
58368
59636
  * Per-device wall-clock of the last frame on which the tracker still had a
58369
59637
  * subject — ANY tracked object, moving or stopped — while a session was
58370
59638
  * active. The session-hold's only liveness input.
@@ -58385,6 +59653,15 @@ var SessionDispatchController = class {
58385
59653
  */
58386
59654
  lastSubjectSeenAt = /* @__PURE__ */ new Map();
58387
59655
  /**
59656
+ * Per-device count of objects the TRACKER held on the last tracked frame of
59657
+ * the active session — moving OR still (D652). Diagnostics only: it decides
59658
+ * nothing. {@link lastSubjectSeenAt} is presence as the HOLD sees it, which
59659
+ * the producer's stillness valve deliberately subtracts a still subject from;
59660
+ * this is what lets a close say whether it ended tracks the tracker still
59661
+ * had. Fed by {@link noteTrackedFrame}; pruned with the session.
59662
+ */
59663
+ lastLiveTrackCount = /* @__PURE__ */ new Map();
59664
+ /**
58388
59665
  * Per-device count of "motion refired while the session was already
58389
59666
  * active" no-ops since the current session was dispatched. Drives the
58390
59667
  * SAMPLED debug line in the dispatch path: on a busy camera the refire
@@ -58410,7 +59687,7 @@ var SessionDispatchController = class {
58410
59687
  agentLoads = new TtlMemo({ load: () => this.deps.loadService.collectAgentLoad({ onlyEnabled: true }) });
58411
59688
  nodeCaps = new TtlMemo({ load: () => this.deps.settingsStore.buildNodeCaps() });
58412
59689
  nodeWeights = new TtlMemo({ load: () => this.deps.settingsStore.buildNodeWeights() });
58413
- eligibleInferenceDevices = new TtlMemo({ load: (nodeId) => this.deps.getEligibleInferenceDevices(nodeId) });
59690
+ eligibleInferenceDevices = new TtlMemo({ load: (nodeId, read) => this.deps.getEligibleInferenceDevices(nodeId, read === "refresh" ? "one-round" : "await-catalog") });
58414
59691
  inferenceDeviceCaps = new TtlMemo({ load: (nodeId) => this.deps.getInferenceDeviceCaps(nodeId) });
58415
59692
  /** Background refresher for {@link agentLoads} et al. Null until first use. */
58416
59693
  balancerRefreshTimer = null;
@@ -58505,6 +59782,17 @@ var SessionDispatchController = class {
58505
59782
  this.lastSubjectSeenAt.set(deviceId, at);
58506
59783
  }
58507
59784
  /**
59785
+ * Record how many objects the tracker held on this frame — the count the
59786
+ * close line reports as `liveTracks` (D652). Every tracked frame, not only
59787
+ * the ones that hold the session: a still subject is exactly the case that
59788
+ * stops holding it. No-op without an active session, which also bounds the
59789
+ * map. Telemetry-lossy (D8): a dropped frame leaves the previous count.
59790
+ */
59791
+ noteTrackedFrame(deviceId, liveTrackCount) {
59792
+ if (!this.sessionRegistry.has(deviceId)) return;
59793
+ this.lastLiveTrackCount.set(deviceId, liveTrackCount);
59794
+ }
59795
+ /**
58508
59796
  * `sessionRegistry.has(deviceId)` — the closure other controllers (Task 3's
58509
59797
  * standing-machinery guards, `index.ts`'s `isIdleSessionCamera`) consume
58510
59798
  * instead of reaching into `sessionRegistry` directly.
@@ -58551,6 +59839,7 @@ var SessionDispatchController = class {
58551
59839
  this.deps.dropAssignment(deviceId, "silent");
58552
59840
  this.sessionRegistry.deregister(deviceId);
58553
59841
  this.lastSubjectSeenAt.delete(deviceId);
59842
+ this.lastLiveTrackCount.delete(deviceId);
58554
59843
  this.notePlacementChanged();
58555
59844
  });
58556
59845
  }
@@ -58789,11 +60078,34 @@ var SessionDispatchController = class {
58789
60078
  }
58790
60079
  });
58791
60080
  const deviceSelectedAt = Date.now();
58792
- const pipelineConfig = await this.deps.settingsStore.resolvePipelineForDevice(deviceId, targetNodeId, deviceKey);
58793
- const deviceBase = await this.deps.settingsStore.buildNodeInferenceDeviceSteps(targetNodeId, deviceKey ?? "");
58794
- const deviceOverride = deviceKey ? await this.deps.settingsStore.readCameraDeviceOverride(deviceId, targetNodeId, deviceKey) : {};
58795
- const steps = applyDeviceProvisioning(pipelineConfig.steps, deviceBase, deviceOverride);
58796
- const inferenceDevices = deviceKey && Object.keys(enabledDevices).length >= 2 ? buildInferenceDeviceRoster(enabledDevices, deviceCaps) : void 0;
60081
+ const outcome = await resolveAttachPipeline({
60082
+ deviceId,
60083
+ nodeId: targetNodeId,
60084
+ deviceKey,
60085
+ eligible: enabledDevices,
60086
+ caps: deviceCaps
60087
+ }, this.deps.settingsStore);
60088
+ if (outcome.kind === "root-refused") {
60089
+ const reason = `root-unrunnable:${targetNodeId}/${outcome.deviceKey ?? "default-pool"}`;
60090
+ if (this.sessionRefusals.get(deviceId) !== reason) {
60091
+ this.sessionRefusals.set(deviceId, reason);
60092
+ log.error("detection session refused — the elected device cannot run the camera root", {
60093
+ tags: {
60094
+ deviceId,
60095
+ nodeId: targetNodeId
60096
+ },
60097
+ meta: {
60098
+ reason,
60099
+ deviceKey: outcome.deviceKey ?? null,
60100
+ droppedRoots: outcome.droppedRoots.join(","),
60101
+ 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"
60102
+ }
60103
+ });
60104
+ }
60105
+ releaseGuard();
60106
+ return null;
60107
+ }
60108
+ const pipeline = outcome.pipeline;
58797
60109
  const zones = await this.deps.listZones(deviceId);
58798
60110
  const sessionDetectionFps = detectionFpsForTrigger(config, trigger);
58799
60111
  if (sessionDetectionFps !== config.detectionFps) log.info("session opened at the device-activity rate", { meta: {
@@ -58803,8 +60115,8 @@ var SessionDispatchController = class {
58803
60115
  } });
58804
60116
  const sessionConfig = {
58805
60117
  deviceId,
58806
- ...deviceKey ? { deviceKey } : {},
58807
- ...inferenceDevices ? { inferenceDevices } : {},
60118
+ ...pipeline.deviceKey ? { deviceKey: pipeline.deviceKey } : {},
60119
+ ...pipeline.inferenceDevices ? { inferenceDevices: pipeline.inferenceDevices } : {},
58808
60120
  motionCooldownMs: config.motionCooldownMs,
58809
60121
  motionFps: config.motionFps,
58810
60122
  detectionFps: sessionDetectionFps,
@@ -58812,8 +60124,8 @@ var SessionDispatchController = class {
58812
60124
  detectionStreamId: config.detectionStreamId,
58813
60125
  motionSources: [],
58814
60126
  pipelineEnabled: config.pipelineEnabled,
58815
- steps,
58816
- audio: pipelineConfig.audio ?? null,
60127
+ steps: pipeline.steps,
60128
+ audio: pipeline.audio,
58817
60129
  zones,
58818
60130
  detectionMode: "always-on",
58819
60131
  audioMode: config.audioMode,
@@ -58832,6 +60144,13 @@ var SessionDispatchController = class {
58832
60144
  attachedAt = Date.now();
58833
60145
  this.deps.recordAssignment(deviceId, targetNodeId, decision.reason, decision.reason === "manual");
58834
60146
  this.sessionRegistry.register(deviceId, targetNodeId, Date.now(), deviceKey);
60147
+ if (this.sessionRefusals.delete(deviceId)) log.info("detection session refusal cleared — a session opened", {
60148
+ tags: {
60149
+ deviceId,
60150
+ nodeId: targetNodeId
60151
+ },
60152
+ meta: deviceKey ? { deviceKey } : {}
60153
+ });
58835
60154
  this.recheck?.noteSessionDispatched(deviceId, targetNodeId, sessionConfig);
58836
60155
  log.info("detection session: dispatched on motion", {
58837
60156
  tags: {
@@ -58840,9 +60159,7 @@ var SessionDispatchController = class {
58840
60159
  },
58841
60160
  meta: {
58842
60161
  reason: decision.reason,
58843
- ...deviceKey ? { deviceKey } : {},
58844
- ...Object.keys(deviceBase).length > 0 ? { deviceBase } : {},
58845
- ...Object.keys(deviceOverride).length > 0 ? { deviceOverride } : {}
60162
+ ...deviceKey ? { deviceKey } : {}
58846
60163
  }
58847
60164
  });
58848
60165
  } finally {
@@ -59006,6 +60323,8 @@ var SessionDispatchController = class {
59006
60323
  this.deps.dropAssignment(deviceId, "unassigned");
59007
60324
  this.sessionRegistry.deregister(deviceId);
59008
60325
  this.lastSubjectSeenAt.delete(deviceId);
60326
+ const liveTracks = this.lastLiveTrackCount.get(deviceId) ?? null;
60327
+ this.lastLiveTrackCount.delete(deviceId);
59009
60328
  this.activeRefireCountByDevice.delete(deviceId);
59010
60329
  this.notePlacementChanged();
59011
60330
  const { openForMs, sinceSubjectSeenMs, maxHoldMs } = observed;
@@ -59016,6 +60335,7 @@ var SessionDispatchController = class {
59016
60335
  openForMs,
59017
60336
  sinceSubjectSeenMs,
59018
60337
  subjectPresent,
60338
+ liveTracks,
59019
60339
  maxHoldMs,
59020
60340
  minOpenMs: MIN_SESSION_OPEN_MS,
59021
60341
  subjectStaleMs: SUBJECT_PRESENT_STALE_MS
@@ -59027,6 +60347,17 @@ var SessionDispatchController = class {
59027
60347
  });
59028
60348
  return;
59029
60349
  }
60350
+ if (liveTracks !== null && liveTracks > 0) {
60351
+ const designedValve = sinceSubjectSeenMs !== null;
60352
+ 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";
60353
+ const extras = {
60354
+ tags: { nodeId: session.nodeId },
60355
+ meta
60356
+ };
60357
+ if (designedValve) log.info(line, extras);
60358
+ else log.warn(line, extras);
60359
+ return;
60360
+ }
59030
60361
  log.info(reason === "motion_cleared" ? "detection session: closed on motion clear" : "detection session: closed after motion cooldown", {
59031
60362
  tags: { nodeId: session.nodeId },
59032
60363
  meta
@@ -59072,6 +60403,14 @@ var SessionDispatchController = class {
59072
60403
  * down too, since it reads `sessionRegistry` fresh at the top of this same
59073
60404
  * callback).
59074
60405
  */
60406
+ /**
60407
+ * Why this camera's LAST on-motion session was refused, or `undefined` when
60408
+ * it was not (or a session has opened since). The camera status renders it
60409
+ * as `pending:<reason>` — see `sessionRefusals`.
60410
+ */
60411
+ refusalReason(deviceId) {
60412
+ return this.sessionRefusals.get(deviceId);
60413
+ }
59075
60414
  async stopForDevice(deviceId) {
59076
60415
  await this.sessionLocks.run(deviceId, async () => {
59077
60416
  const session = this.sessionRegistry.get(deviceId);
@@ -59091,6 +60430,8 @@ var SessionDispatchController = class {
59091
60430
  }
59092
60431
  this.deps.dropActiveDetection(deviceId);
59093
60432
  this.lastSubjectSeenAt.delete(deviceId);
60433
+ this.lastLiveTrackCount.delete(deviceId);
60434
+ this.sessionRefusals.delete(deviceId);
59094
60435
  this.recheck?.forget(deviceId);
59095
60436
  });
59096
60437
  const watchNodeId = this.motionWatchByDevice.get(deviceId);
@@ -59131,6 +60472,8 @@ var SessionDispatchController = class {
59131
60472
  this.sessionRegistry.clear();
59132
60473
  this.motionWatchByDevice.clear();
59133
60474
  this.lastSubjectSeenAt.clear();
60475
+ this.lastLiveTrackCount.clear();
60476
+ this.sessionRefusals.clear();
59134
60477
  if (this.balancerRefreshTimer !== null) {
59135
60478
  clearInterval(this.balancerRefreshTimer);
59136
60479
  this.balancerRefreshTimer = null;
@@ -60100,7 +61443,8 @@ async function buildOrchestratorControllers(deps) {
60100
61443
  watchdog: () => pipelineWatchdog,
60101
61444
  localNodeId: () => localNodeId,
60102
61445
  readPipelinePin: (deviceId) => deps.readPipelinePin(deviceId),
60103
- getEligibleInferenceDevices: (nodeId) => deps.getEligibleInferenceDevices(nodeId),
61446
+ getEligibleInferenceDevices: (nodeId) => deps.getEligibleInferenceDevices(nodeId, "await-catalog"),
61447
+ getEligibleInferenceDevicesIfCached: (nodeId) => deps.getEligibleInferenceDevicesIfCached(nodeId),
60104
61448
  isNodeInferenceUsable: (nodeId) => deps.isNodeInferenceUsable(nodeId),
60105
61449
  getInferenceDeviceCaps: (nodeId) => deps.getInferenceDeviceCaps(nodeId),
60106
61450
  readPipelineDevicePin: (deviceId) => deps.readPipelineDevicePin(deviceId),
@@ -60136,7 +61480,7 @@ async function buildOrchestratorControllers(deps) {
60136
61480
  },
60137
61481
  listZones: async (deviceId) => await zonesProvider?.listZones({ deviceId }) ?? [],
60138
61482
  readPipelinePin: (deviceId) => deps.readPipelinePin(deviceId),
60139
- getEligibleInferenceDevices: (nodeId) => deps.getEligibleInferenceDevices(nodeId),
61483
+ getEligibleInferenceDevices: (nodeId, read) => deps.getEligibleInferenceDevices(nodeId, read),
60140
61484
  isNodeInferenceUsable: (nodeId) => deps.isNodeInferenceUsable(nodeId),
60141
61485
  getInferenceDeviceCaps: (nodeId) => deps.getInferenceDeviceCaps(nodeId),
60142
61486
  readPipelineDevicePin: (deviceId) => deps.readPipelineDevicePin(deviceId),
@@ -60179,7 +61523,7 @@ async function buildOrchestratorControllers(deps) {
60179
61523
  getAudioAssignment: (deviceId) => audio.getAssignment(deviceId),
60180
61524
  getCameraConfig: (deviceId) => ledger.getConfig(deviceId),
60181
61525
  hasCameraConfig: (deviceId) => ledger.hasConfig(deviceId),
60182
- getPendingReason: (deviceId) => ledger.getPendingReason(deviceId),
61526
+ getPendingReason: (deviceId) => ledger.getPendingReason(deviceId) ?? session.refusalReason(deviceId),
60183
61527
  assignSource: (deviceId) => topology.assignSource(deviceId),
60184
61528
  listAssignedDeviceIds: () => [...new Set([...ledger.listAssignedDeviceIds(), ...detectionWiring.activeDeviceIds()])],
60185
61529
  isSessionCamera: (deviceId) => deps.isSessionCamera(deviceId),
@@ -60197,7 +61541,7 @@ async function buildOrchestratorControllers(deps) {
60197
61541
  logger: deps.ctx().logger,
60198
61542
  handleDeviceRegistered: (deviceId) => deps.handleDeviceRegistered(deviceId),
60199
61543
  handleDeviceUnregistered: (deviceId) => deps.handleDeviceUnregistered(deviceId),
60200
- dispatchCamera: (config) => deps.dispatchCamera(config),
61544
+ redispatchCamera: (config) => placement.redispatchCamera(config),
60201
61545
  detachOn: (nodeId, deviceId) => deps.detachOn(nodeId, deviceId),
60202
61546
  localNodeId: () => localNodeId,
60203
61547
  activeDeviceIds: () => detectionWiring.activeDeviceIds(),
@@ -60227,6 +61571,7 @@ async function buildOrchestratorControllers(deps) {
60227
61571
  loadService,
60228
61572
  settingsStore,
60229
61573
  attachOn: (nodeId, config) => deps.attachOn(nodeId, config),
61574
+ resolveAttachPipeline: (input) => placement.resolveAttachPipelineOnDevice(input),
60230
61575
  rebalance: (opts) => deps.rebalance(opts),
60231
61576
  scheduleReconcile: () => reconcile.scheduleReconcile(),
60232
61577
  schedulePendingRetry: () => reconcile.schedulePendingRetry(),
@@ -60245,6 +61590,7 @@ async function buildOrchestratorControllers(deps) {
60245
61590
  if (!nodeId.includes("/")) topology.removeKnownRunnerNode(nodeId);
60246
61591
  nodeLifecycle.unsubscribeDetectionPipelineReadiness(nodeId);
60247
61592
  audio.markNodeOffline(nodeId);
61593
+ deps.forgetInferenceDeviceProbe(nodeId);
60248
61594
  nodeLifecycle.handleNodeDisconnect(nodeId).catch((err) => {
60249
61595
  const msg = errMsg$1(err);
60250
61596
  deps.ctx().logger.error("failover after disconnect failed", {
@@ -60360,26 +61706,12 @@ async function buildOrchestratorControllers(deps) {
60360
61706
  fetchDevice: (deviceId) => deps.ctx().fetchDevice(deviceId),
60361
61707
  onRulesChanged: (deviceId, stage) => {
60362
61708
  if (stage !== "package") return;
60363
- const assignment = ledger.getAssignment(deviceId);
60364
- const cached = ledger.getConfig(deviceId);
60365
- if (!assignment || !cached) return;
60366
- settingsStore.resolvePipelineForDevice(deviceId, assignment.agentNodeId).then((resolved) => {
60367
- const next = {
60368
- ...cached,
60369
- steps: resolved.steps,
60370
- audio: resolved.audio ?? null
60371
- };
60372
- ledger.setConfig(deviceId, next);
60373
- return deps.attachOn(assignment.agentNodeId, next);
60374
- }).catch((err) => {
60375
- deps.ctx().logger.warn("package zone-rule change re-dispatch failed", {
60376
- tags: {
60377
- deviceId,
60378
- nodeId: assignment.agentNodeId
60379
- },
60380
- meta: { error: errMsg$1(err) }
60381
- });
60382
- });
61709
+ reattachAfterPackageZoneChange({
61710
+ ledger,
61711
+ resolveAttachPipeline: (input) => placement.resolveAttachPipelineOnDevice(input),
61712
+ attachOn: (nodeId, config) => deps.attachOn(nodeId, config),
61713
+ logger: deps.ctx().logger
61714
+ }, deviceId);
60383
61715
  }
60384
61716
  });
60385
61717
  zoneRulesProviderRef = zoneRulesProvider;
@@ -60443,6 +61775,7 @@ async function buildOrchestratorControllers(deps) {
60443
61775
  getActiveDetectionConfig: (deviceId) => detectionWiring.getActiveDetectionConfig(deviceId),
60444
61776
  handleSessionMotion: (deviceId, detected, emittedAt) => session.handleSessionMotion(deviceId, detected, emittedAt),
60445
61777
  noteSessionSubjectPresent: (deviceId) => session.noteSubjectPresent(deviceId),
61778
+ noteSessionTrackedFrame: (deviceId, liveTrackCount) => session.noteTrackedFrame(deviceId, liveTrackCount),
60446
61779
  noteWatchdogSignal: (deviceId, stage) => pipelineWatchdog?.noteSignal(deviceId, stage)
60447
61780
  });
60448
61781
  reconcile.scheduleReconcile();
@@ -61167,6 +62500,15 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
61167
62500
  /** This node's Moleculer nodeId (from this.ctx.kernel.localNodeId). */
61168
62501
  localNodeId = "hub";
61169
62502
  /**
62503
+ * A shutdown latch for {@link getClusterModelChoice} (D651): cleared on the
62504
+ * first line of `onShutdown`, so an owner that is tearing down stops
62505
+ * answering. Set at the end of `onInitialize`; it is also false before that,
62506
+ * but nothing can call the method then — the cap provider is registered
62507
+ * from what `onInitialize` RETURNS, so the pre-init state is unreachable and
62508
+ * not a claim this flag makes.
62509
+ */
62510
+ clusterModelChoiceReady = false;
62511
+ /**
61170
62512
  * Single owner of the shared placement state (`assignments`/`cameraConfigs`/
61171
62513
  * `pendingReasons`/`dispatchingDevices`) — see `placement-ledger.ts` (S6
61172
62514
  * extraction). Constructed FIRST in `onInitialize` (several other
@@ -61411,7 +62753,9 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
61411
62753
  attachOn: (nodeId, config) => this.attachOn(nodeId, config),
61412
62754
  detachOn: (nodeId, deviceId) => this.detachOn(nodeId, deviceId),
61413
62755
  readPipelinePin: (deviceId) => this.readPipelinePin(deviceId),
61414
- getEligibleInferenceDevices: (nodeId) => this.resolveEligibleInferenceDevices(nodeId),
62756
+ getEligibleInferenceDevices: (nodeId, read) => this.resolveEligibleInferenceDevices(nodeId, read),
62757
+ getEligibleInferenceDevicesIfCached: (nodeId) => this.resolveEligibleInferenceDevicesIfCached(nodeId),
62758
+ forgetInferenceDeviceProbe: (nodeId) => this.forgetInferenceDeviceProbe(nodeId),
61415
62759
  isNodeInferenceUsable: (nodeId) => this.isNodeInferenceUsable(nodeId),
61416
62760
  getInferenceDeviceCaps: (nodeId) => this.getInferenceDeviceCaps(nodeId),
61417
62761
  readPipelineDevicePin: (deviceId) => this.readPipelineDevicePin(deviceId),
@@ -61438,7 +62782,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
61438
62782
  state: this.state.bind(this),
61439
62783
  rebalance: (opts) => this.rebalance(opts),
61440
62784
  setCameraPipelineForAgent: (input) => this.setCameraPipelineForAgent(input),
61441
- dispatchCamera: (config) => this.dispatchCamera(config),
61442
62785
  maintenancePaused: () => this.storageMigrationLeaseId !== null,
61443
62786
  setTopology: (topology) => {
61444
62787
  this.topology = topology;
@@ -61483,6 +62826,8 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
61483
62826
  this.autoRebalanceTimer = controllers.autoRebalanceTimer;
61484
62827
  this.nodeStressLts = controllers.nodeStressLts;
61485
62828
  this.nodeStressLtsTimer = controllers.nodeStressLtsTimer;
62829
+ const widgetsProvider = { listWidgets: async () => pipelineOrchestratorWidgets };
62830
+ this.clusterModelChoiceReady = true;
61486
62831
  return {
61487
62832
  providers: [
61488
62833
  {
@@ -61503,7 +62848,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
61503
62848
  },
61504
62849
  {
61505
62850
  capability: addonWidgetsSourceCapability,
61506
- provider: { listWidgets: async () => pipelineOrchestratorWidgets }
62851
+ provider: widgetsProvider
61507
62852
  },
61508
62853
  {
61509
62854
  capability: viewerUiCapability,
@@ -61552,6 +62897,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
61552
62897
  };
61553
62898
  }
61554
62899
  async onShutdown() {
62900
+ this.clusterModelChoiceReady = false;
61555
62901
  this.audio.beginShutdown();
61556
62902
  this.session.shutdown();
61557
62903
  if (this.pendingRetryTimer !== null) {
@@ -61697,6 +63043,16 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
61697
63043
  * a server restart that rehydrates assignments from disk without
61698
63044
  * re-dispatching).
61699
63045
  */
63046
+ /**
63047
+ * The operator's stored model choice for a cluster-scoped step (D651).
63048
+ * Answers `owner-not-ready` from the first line of `onShutdown`: an owner
63049
+ * that is tearing down must not answer at all. (Before `onInitialize`
63050
+ * returns nobody can reach this method — see `clusterModelChoiceReady`.)
63051
+ */
63052
+ async getClusterModelChoice(input) {
63053
+ if (!this.clusterModelChoiceReady) return decideClusterModelChoice(input.stepId, "owner-not-ready");
63054
+ return decideClusterModelChoice(input.stepId, await this.readStoredGlobalKey(clusterModelSettingKey(input.stepId)));
63055
+ }
61700
63056
  async getAgentLoad() {
61701
63057
  await this.loadService.refreshDecodeHwaccels();
61702
63058
  await this.loadService.collectAgentLoad();
@@ -61813,6 +63169,14 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
61813
63169
  async evaluateRemoteAssignmentHealth() {
61814
63170
  return this.reconcile.evaluateRemoteAssignmentHealth();
61815
63171
  }
63172
+ /**
63173
+ * Thin seam onto `ReconcileController.retryPendingDispatches` — the same
63174
+ * `protected` pattern, for `pending-retry-catalog-wedge.spec.ts` to run one
63175
+ * sweep and bound it.
63176
+ */
63177
+ async retryPendingDispatches() {
63178
+ return this.reconcile.retryPendingDispatches();
63179
+ }
61816
63180
  async getCapabilityBindings(input) {
61817
63181
  if (!this.ctx?.settings) return {};
61818
63182
  return this.settingsStore.getCapabilityBindings(input.nodeId);
@@ -61895,6 +63259,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
61895
63259
  success: true,
61896
63260
  removed: false
61897
63261
  };
63262
+ this.forgetInferenceDeviceProbe(input.agentNodeId);
61898
63263
  await this.topology.refreshNodeCapabilities();
61899
63264
  this.ctx.logger.info("agentSettings entry removed", { tags: { nodeId: input.agentNodeId } });
61900
63265
  return {
@@ -62096,12 +63461,110 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
62096
63461
  * the runner uses its single default accelerator. NEVER throws (an
62097
63462
  * unreachable probe yields no eligible devices).
62098
63463
  */
62099
- async resolveEligibleInferenceDevices(nodeId) {
62100
- const stored = (await this.settingsStore.readAgentSettingsMap())[nodeId]?.inferenceDevices ?? {};
62101
- const [{ probed }, poolHealth] = await Promise.all([this.probeNodeInferenceDevices(nodeId), this.probeNodeInferenceDeviceHealth(nodeId)]);
62102
- this.noteInferenceDeviceHealth(nodeId, poolHealth, probed.map((d) => d.key));
62103
- const catalog = await this.settingsStore.getCatalogForNode(nodeId);
62104
- const eligibility = resolveInferenceDeviceEligibility(probed, stored, catalog ? makeRootCapabilityGuard(catalog) : void 0, (deviceKey) => this.inferenceDeviceUsability.isUsable(nodeId, deviceKey));
63464
+ async resolveEligibleInferenceDevices(nodeId, read) {
63465
+ const [probe, poolHealth] = await Promise.all([this.probeNodeInferenceDevices(nodeId), this.probeNodeInferenceDeviceHealth(nodeId)]);
63466
+ this.noteInferenceDeviceProbe(nodeId, probe, poolHealth);
63467
+ const probed = probe.probed;
63468
+ const catalog = await this.settingsStore.awaitCatalogForNode(nodeId, read === "one-round" ? "one-round" : "unbounded");
63469
+ if (catalog === null && read === "one-round" && !this.settingsStore.isDisposed()) {
63470
+ this.ctx.logger.debug("inference device eligibility: node catalog not answered in one round — last answer kept", {
63471
+ tags: { nodeId },
63472
+ meta: { nodeId }
63473
+ });
63474
+ throw new Error(`inference device eligibility: node "${nodeId}" catalog not answered`);
63475
+ }
63476
+ return this.electableDevices(nodeId, probed, catalog);
63477
+ }
63478
+ /**
63479
+ * Forget a node's last device probe (pre-ship review, M4). Called when the
63480
+ * node leaves (`AgentOffline`) and when the operator removes its settings: a
63481
+ * node that comes back with a different accelerator set must be probed
63482
+ * again, not elected against the list it left with.
63483
+ */
63484
+ forgetInferenceDeviceProbe(nodeId) {
63485
+ if (!this.lastInferenceDeviceProbe.delete(nodeId)) return;
63486
+ this.ctx.logger.info("inference device probe forgotten — the node left", {
63487
+ tags: { nodeId },
63488
+ meta: { nodeId }
63489
+ });
63490
+ }
63491
+ /**
63492
+ * The NON-BLOCKING sibling of {@link resolveEligibleInferenceDevices}, for
63493
+ * the RE-place paths (D650 round 3). Makes no live call to the node: the
63494
+ * device list and the pool health are the last ones it answered, the
63495
+ * catalog is the cached one. A node that has never answered any of them is
63496
+ * `unavailable` — the camera stays pending, named, and ONE background
63497
+ * refresh per node is started so the next round can place it. A cold cache
63498
+ * never fails open here: failing open on a re-place is how a camera gets
63499
+ * attached with no root.
63500
+ */
63501
+ async resolveEligibleInferenceDevicesIfCached(nodeId) {
63502
+ const known = this.lastInferenceDeviceProbe.get(nodeId);
63503
+ if (known === void 0) {
63504
+ this.refreshInferenceDeviceProbe(nodeId);
63505
+ return {
63506
+ kind: "unavailable",
63507
+ nodeId,
63508
+ what: "device-health"
63509
+ };
63510
+ }
63511
+ const catalog = this.settingsStore.peekCatalogForAgent(nodeId);
63512
+ if (catalog === null) return {
63513
+ kind: "unavailable",
63514
+ nodeId,
63515
+ what: "catalog"
63516
+ };
63517
+ this.refreshInferenceDeviceProbe(nodeId);
63518
+ return {
63519
+ kind: "eligible",
63520
+ eligible: await this.electableDevices(nodeId, known, catalog)
63521
+ };
63522
+ }
63523
+ /**
63524
+ * The last device list each node's probe returned — what the re-place paths
63525
+ * elect against, with the pool-health mirror that probe updated. Written
63526
+ * when a probe COMPLETES, so a cold entry means "the node's probe has never
63527
+ * come back", never "came back with nothing".
63528
+ */
63529
+ lastInferenceDeviceProbe = /* @__PURE__ */ new Map();
63530
+ /** Nodes with a background device probe in flight (single-flight per node). */
63531
+ inferenceProbeInFlight = /* @__PURE__ */ new Set();
63532
+ refreshInferenceDeviceProbe(nodeId) {
63533
+ if (this.inferenceProbeInFlight.has(nodeId)) return;
63534
+ this.inferenceProbeInFlight.add(nodeId);
63535
+ Promise.all([this.probeNodeInferenceDevices(nodeId), this.probeNodeInferenceDeviceHealth(nodeId)]).then(([probe, poolHealth]) => this.noteInferenceDeviceProbe(nodeId, probe, poolHealth)).finally(() => {
63536
+ this.inferenceProbeInFlight.delete(nodeId);
63537
+ });
63538
+ }
63539
+ /**
63540
+ * Fold one COMPLETED probe into the pool-health mirror and the last-known
63541
+ * list. "Completed" means the node ANSWERED: a probe that could not reach
63542
+ * it (`reachable: false`) changes nothing (D49, round 4) — its empty device
63543
+ * list is not an answer, and recording it as one both wiped a good
63544
+ * last-known list and turned a never-answered node into an "answered with
63545
+ * nothing" one, so `device-health-unavailable` stopped firing after a single
63546
+ * failure. A health read that failed (`null`) changes no verdict in the
63547
+ * mirror either, but it did come back — a node whose read never comes back
63548
+ * at all is the one a re-place must not wait on, and that node never reaches
63549
+ * this line.
63550
+ */
63551
+ noteInferenceDeviceProbe(nodeId, probe, poolHealth) {
63552
+ if (!probe.reachable) {
63553
+ this.ctx.logger.debug("inference device probe unreachable — last known device list kept", {
63554
+ tags: { nodeId },
63555
+ meta: {
63556
+ nodeId,
63557
+ known: this.lastInferenceDeviceProbe.has(nodeId)
63558
+ }
63559
+ });
63560
+ return;
63561
+ }
63562
+ this.noteInferenceDeviceHealth(nodeId, poolHealth, probe.probed.map((d) => d.key));
63563
+ this.lastInferenceDeviceProbe.set(nodeId, probe.probed);
63564
+ }
63565
+ /** Eligibility from a device list + catalog; records usability like every dispatch read. */
63566
+ async electableDevices(nodeId, probed, catalog) {
63567
+ const eligibility = resolveInferenceDeviceEligibility(probed, (await this.settingsStore.readAgentSettingsMap())[nodeId]?.inferenceDevices ?? {}, catalog ? makeRootCapabilityGuard(catalog) : void 0, (deviceKey) => this.inferenceDeviceUsability.isUsable(nodeId, deviceKey));
62105
63568
  const { eligible, excluded } = eligibility;
62106
63569
  this.logInferenceDeviceShrinkage(nodeId, eligible, excluded);
62107
63570
  this.noteNodeInferenceUsability(nodeId, resolveNodeInferenceUsability(eligibility));