@camstack/addon-pipeline-orchestrator 1.2.257 → 1.2.258
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/_stub.js +2 -2
- package/dist/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-DGNougi5.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-y1ddVXzH.mjs} +2 -2
- package/dist/_virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BWAmGyYA.mjs +26 -0
- package/dist/_virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-D34U1xfa.mjs +26 -0
- package/dist/{hostInit-DkYM1F8P.mjs → hostInit-3a_B-rGa.mjs} +2 -2
- package/dist/index.js +2184 -729
- package/dist/index.mjs +2184 -729
- package/dist/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/_virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-KOxZj_G5.mjs +0 -26
- package/dist/_virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-3sU_SREF.mjs +0 -26
package/dist/index.mjs
CHANGED
|
@@ -5363,7 +5363,7 @@ var ZodIssueCode = {
|
|
|
5363
5363
|
var ZodFirstPartyTypeKind;
|
|
5364
5364
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5365
5365
|
//#endregion
|
|
5366
|
-
//#region ../types/dist/sleep-
|
|
5366
|
+
//#region ../types/dist/sleep-BXv0ZcRa.mjs
|
|
5367
5367
|
/**
|
|
5368
5368
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5369
5369
|
* window to float samples (D455).
|
|
@@ -6164,6 +6164,40 @@ var BaseAddon = class {
|
|
|
6164
6164
|
const node = normalizeNodeId(nodeId ?? this._ctx?.kernel?.localNodeId);
|
|
6165
6165
|
return keys.size > 0 ? projectStore(raw, keys, node) : raw;
|
|
6166
6166
|
}
|
|
6167
|
+
/**
|
|
6168
|
+
* Whether ONE global key of this addon's store was written, read three-way
|
|
6169
|
+
* (D651). For a question a hydrated view cannot answer: `getGlobalSettings`
|
|
6170
|
+
* reports `value ?? default`, so "the operator chose the default" and "nobody
|
|
6171
|
+
* chose" look the same, and a failed read looks like both. A `perNode` key is
|
|
6172
|
+
* not supported here: its stored name is scoped.
|
|
6173
|
+
*/
|
|
6174
|
+
async readStoredGlobalKey(key) {
|
|
6175
|
+
const read = this._ctx?.settings?.readAddonStoreResult;
|
|
6176
|
+
if (read === void 0) return {
|
|
6177
|
+
kind: "unreadable",
|
|
6178
|
+
reason: "three-way-read-unavailable"
|
|
6179
|
+
};
|
|
6180
|
+
let result;
|
|
6181
|
+
try {
|
|
6182
|
+
result = await read.call(this._ctx?.settings);
|
|
6183
|
+
} catch (err) {
|
|
6184
|
+
return {
|
|
6185
|
+
kind: "unreadable",
|
|
6186
|
+
reason: "store-read-failed",
|
|
6187
|
+
error: err instanceof Error ? err.message : String(err)
|
|
6188
|
+
};
|
|
6189
|
+
}
|
|
6190
|
+
if (result.kind === "failed") return {
|
|
6191
|
+
kind: "unreadable",
|
|
6192
|
+
reason: "store-read-failed",
|
|
6193
|
+
error: result.error
|
|
6194
|
+
};
|
|
6195
|
+
if (result.kind === "missing" || !Object.prototype.hasOwnProperty.call(result.value, key)) return { kind: "absent" };
|
|
6196
|
+
return {
|
|
6197
|
+
kind: "present",
|
|
6198
|
+
value: result.value[key]
|
|
6199
|
+
};
|
|
6200
|
+
}
|
|
6167
6201
|
async updateGlobalSettings(patch, nodeId) {
|
|
6168
6202
|
const keys = this.perNodeKeys();
|
|
6169
6203
|
const localNode = normalizeNodeId(this._ctx?.kernel?.localNodeId);
|
|
@@ -10291,6 +10325,43 @@ var LogChannelWindowSchema = object({
|
|
|
10291
10325
|
/** `null` = every camera. A non-empty list narrows to those numeric ids. */
|
|
10292
10326
|
deviceIds: array(number().int()).readonly().nullable()
|
|
10293
10327
|
});
|
|
10328
|
+
var ClipModelMetaSchema = object({
|
|
10329
|
+
/** The paired text encoder's catalog id (the embedding-encoder's text catalog). */
|
|
10330
|
+
textModelId: string().min(1),
|
|
10331
|
+
/** Output dimension of BOTH towers. A vector of any other length is refused. */
|
|
10332
|
+
embeddingDim: number().int().positive(),
|
|
10333
|
+
/**
|
|
10334
|
+
* The `vector-store` index this model's vectors live in. ONE feature space
|
|
10335
|
+
* per index: two models share an index only when their vectors are ranked
|
|
10336
|
+
* against each other, which for CLIP means never across families. MobileCLIP
|
|
10337
|
+
* S1 and S2 share `object-clip` for history (separated by the row's
|
|
10338
|
+
* `modelId`); any other model gets its own.
|
|
10339
|
+
*/
|
|
10340
|
+
vectorIndex: string().regex(/^object-clip(-[a-z0-9][a-z0-9-]*)?$/),
|
|
10341
|
+
/**
|
|
10342
|
+
* The text encoder's tokenizer, a declared SIBLING file of its onnx. The name
|
|
10343
|
+
* must be unique per tokenizer: every model's siblings land in ONE flat
|
|
10344
|
+
* models directory, so two different tokenizers under the same name would
|
|
10345
|
+
* silently overwrite each other (whichever model downloaded last wins, and
|
|
10346
|
+
* the other one tokenises with the wrong vocabulary).
|
|
10347
|
+
*/
|
|
10348
|
+
tokenizerFile: string().min(1),
|
|
10349
|
+
/** Token window the text graph takes (SigLIP2: 64; CLIP BPE: 77). */
|
|
10350
|
+
contextLength: number().int().positive(),
|
|
10351
|
+
/** Id the tokenizer pads with. */
|
|
10352
|
+
padId: number().int().nonnegative(),
|
|
10353
|
+
/**
|
|
10354
|
+
* The search cosine floor at `strictness: 'loose'` — calibrated per model,
|
|
10355
|
+
* because cosine bands are a property of the model: 0.2 keeps 96% of matched
|
|
10356
|
+
* COCO caption-image pairs on MobileCLIP S1 and 2.7% on SigLIP2.
|
|
10357
|
+
*/
|
|
10358
|
+
searchMinScore: number().min(0).max(1)
|
|
10359
|
+
});
|
|
10360
|
+
var ClipSearchStrictnessSchema = _enum([
|
|
10361
|
+
"loose",
|
|
10362
|
+
"balanced",
|
|
10363
|
+
"strict"
|
|
10364
|
+
]);
|
|
10294
10365
|
var MODEL_FORMATS = [
|
|
10295
10366
|
"onnx",
|
|
10296
10367
|
"coreml",
|
|
@@ -10494,7 +10565,14 @@ var ModelCatalogEntrySchema = object({
|
|
|
10494
10565
|
* applies (Frigate / COCO public catalog). Set on a custom model whose raw
|
|
10495
10566
|
* labels already ARE the CamStack macros (Scrypted identity map).
|
|
10496
10567
|
*/
|
|
10497
|
-
classMap: DetectionCatalogClassMapSchema.optional()
|
|
10568
|
+
classMap: DetectionCatalogClassMapSchema.optional(),
|
|
10569
|
+
/**
|
|
10570
|
+
* The model's CLIP feature-space contract (text encoder, dimension, vector
|
|
10571
|
+
* index, token window, search floor) — see `clip-model.ts`. Present ONLY on a
|
|
10572
|
+
* CLIP image encoder; its absence is what "not a CLIP model" means to the
|
|
10573
|
+
* embedding encoder and the semantic-search store (D649).
|
|
10574
|
+
*/
|
|
10575
|
+
clip: ClipModelMetaSchema.optional()
|
|
10498
10576
|
});
|
|
10499
10577
|
var ConvertTargetSchema = discriminatedUnion("format", [object({
|
|
10500
10578
|
format: literal("openvino"),
|
|
@@ -14769,6 +14847,13 @@ var LinkedDeviceSchema = object({
|
|
|
14769
14847
|
features: array(string()),
|
|
14770
14848
|
producesTrackedEvents: boolean().optional()
|
|
14771
14849
|
});
|
|
14850
|
+
/**
|
|
14851
|
+
* The `linked-devices` extension slot's editable value (D648): the operator's
|
|
14852
|
+
* pick list, exactly what `_linkedDeviceIds` persists. Tree children are NOT
|
|
14853
|
+
* in it — they are always linked and are not a pick.
|
|
14854
|
+
*/
|
|
14855
|
+
var LinkedDevicesSlotValueSchema = object({ deviceIds: array(number().int().positive()) });
|
|
14856
|
+
DeviceType.Camera;
|
|
14772
14857
|
/** One camera's resolved linked set, tagged with the camera it belongs to.
|
|
14773
14858
|
* The batch answer needs the tag; the single-device answer already has it
|
|
14774
14859
|
* from the input, which is why `getLinkedDevices` keeps the untagged shape. */
|
|
@@ -15294,16 +15379,101 @@ var EmbeddingResultSchema = object({
|
|
|
15294
15379
|
embedding: array(number()),
|
|
15295
15380
|
inferenceMs: number()
|
|
15296
15381
|
});
|
|
15382
|
+
/** The two CLIP towers the encoder runs; a crash budget is kept per tower. */
|
|
15383
|
+
var EmbeddingTowerSchema = _enum(["image", "text"]);
|
|
15384
|
+
/**
|
|
15385
|
+
* A tower in the terminal `failed` state (D649, D6): it crashed or failed to
|
|
15386
|
+
* load `crashes` times within `windowMs`, and is refused by name until an
|
|
15387
|
+
* operator `resetFailedModels` or a model change — never on a timer.
|
|
15388
|
+
*
|
|
15389
|
+
* `nodeId` is the node whose tower failed. The state lives in THAT node's
|
|
15390
|
+
* encoder process (in memory — a runner respawn clears it), and one node's
|
|
15391
|
+
* answer says nothing about another's.
|
|
15392
|
+
*/
|
|
15393
|
+
var EmbeddingFailedModelSchema = object({
|
|
15394
|
+
modelId: string(),
|
|
15395
|
+
tower: EmbeddingTowerSchema,
|
|
15396
|
+
nodeId: string(),
|
|
15397
|
+
crashes: number(),
|
|
15398
|
+
windowMs: number(),
|
|
15399
|
+
sinceMs: number(),
|
|
15400
|
+
lastReason: string()
|
|
15401
|
+
});
|
|
15402
|
+
/**
|
|
15403
|
+
* A model whose REQUIREMENTS can never be met on this node (D649 round 5):
|
|
15404
|
+
* an id the catalog does not know, a model URL answering 404/410, a declared
|
|
15405
|
+
* sibling (the tokenizer) that is not there. After `attempts` failed builds
|
|
15406
|
+
* it is terminally `requirements-failed`, refused by name until an operator
|
|
15407
|
+
* `resetFailedModels` or a model change — the same exits as a crashed tower.
|
|
15408
|
+
*/
|
|
15409
|
+
var EmbeddingFailedRequirementSchema = object({
|
|
15410
|
+
modelId: string(),
|
|
15411
|
+
tower: EmbeddingTowerSchema,
|
|
15412
|
+
nodeId: string(),
|
|
15413
|
+
attempts: number(),
|
|
15414
|
+
sinceMs: number(),
|
|
15415
|
+
lastReason: string()
|
|
15416
|
+
});
|
|
15297
15417
|
var EmbeddingInfoSchema = object({
|
|
15298
15418
|
modelId: string(),
|
|
15299
15419
|
embeddingDim: number(),
|
|
15300
|
-
ready: boolean()
|
|
15301
|
-
|
|
15420
|
+
ready: boolean(),
|
|
15421
|
+
/**
|
|
15422
|
+
* The node that answered. Absent only from an encoder older than D649's
|
|
15423
|
+
* crash budget — whose `failedModels` is then UNKNOWN (D315), never "none".
|
|
15424
|
+
*/
|
|
15425
|
+
nodeId: string().optional(),
|
|
15426
|
+
/**
|
|
15427
|
+
* Towers in `failed` on the answering node, each row naming it. `undefined`
|
|
15428
|
+
* means the node could not say (pre-budget encoder); a node that has the
|
|
15429
|
+
* budget always answers an array, empty included.
|
|
15430
|
+
*/
|
|
15431
|
+
failedModels: array(EmbeddingFailedModelSchema).optional(),
|
|
15432
|
+
/** Models terminally `requirements-failed` on the answering node; same reading as above. */
|
|
15433
|
+
failedRequirements: array(EmbeddingFailedRequirementSchema).optional()
|
|
15434
|
+
});
|
|
15435
|
+
/**
|
|
15436
|
+
* The THREE codes an encoder refusal can carry (D649, D6). Same shape as
|
|
15437
|
+
* `VECTOR_INDEX_DIM_CONFLICT`: the code rides in the MESSAGE, because an
|
|
15438
|
+
* Error's own properties do not survive the UDS/tRPC hop and its message does,
|
|
15439
|
+
* and both sides go through the helpers below — the encoder builds the
|
|
15440
|
+
* refusal, the live detail plane classifies on it to count refusals per
|
|
15441
|
+
* camera instead of logging one warning per track. The bracketed token
|
|
15442
|
+
* carries the code, the model, the tower and the node so a consumer never
|
|
15443
|
+
* parses prose.
|
|
15444
|
+
*
|
|
15445
|
+
* - `EMBEDDING_MODEL_FAILED`: the tower crashed / failed to load past the crash
|
|
15446
|
+
* budget — terminal until `resetFailedModels` or a model change.
|
|
15447
|
+
* - `EMBEDDING_REQUIREMENTS_UNAVAILABLE`: files or runtime not there right now
|
|
15448
|
+
* (a download or Python that failed, or the cooldown after one) — the next
|
|
15449
|
+
* request past the cooldown tries again.
|
|
15450
|
+
* - `EMBEDDING_REQUIREMENTS_FAILED`: the requirements can never be met (unknown
|
|
15451
|
+
* id, 404/410, missing sibling) and the bounded attempts are spent —
|
|
15452
|
+
* terminal, same exits as a crashed tower.
|
|
15453
|
+
*/
|
|
15454
|
+
var EMBEDDING_MODEL_FAILED = "embedding-model-failed";
|
|
15455
|
+
var EMBEDDING_REQUIREMENTS_UNAVAILABLE = "embedding-requirements-unavailable";
|
|
15456
|
+
var EMBEDDING_REQUIREMENTS_FAILED = "embedding-requirements-failed";
|
|
15457
|
+
_enum([
|
|
15458
|
+
EMBEDDING_MODEL_FAILED,
|
|
15459
|
+
EMBEDDING_REQUIREMENTS_UNAVAILABLE,
|
|
15460
|
+
EMBEDDING_REQUIREMENTS_FAILED
|
|
15461
|
+
]);
|
|
15462
|
+
new RegExp(`\\[(${EMBEDDING_MODEL_FAILED}|${EMBEDDING_REQUIREMENTS_UNAVAILABLE}|${EMBEDDING_REQUIREMENTS_FAILED}) model=([^\\s\\]]+) tower=(image|text) node=([^\\s\\]]+)\\]`);
|
|
15302
15463
|
method(object({
|
|
15303
15464
|
crop: _instanceof(Uint8Array),
|
|
15304
15465
|
width: number(),
|
|
15305
15466
|
height: number()
|
|
15306
|
-
}), EmbeddingResultSchema, { auth: "admin" }), method(object({
|
|
15467
|
+
}), EmbeddingResultSchema, { auth: "admin" }), method(object({
|
|
15468
|
+
text: string(),
|
|
15469
|
+
modelId: string().min(1).optional()
|
|
15470
|
+
}), EmbeddingResultSchema, { auth: "admin" }), method(_void(), EmbeddingInfoSchema, { auth: "admin" }), method(object({ modelId: string().min(1).optional() }), object({
|
|
15471
|
+
cleared: array(string()),
|
|
15472
|
+
nodeId: string()
|
|
15473
|
+
}), {
|
|
15474
|
+
kind: "mutation",
|
|
15475
|
+
auth: "admin"
|
|
15476
|
+
});
|
|
15307
15477
|
/**
|
|
15308
15478
|
* `failure-contribution` — the capability an addon reports its OWN losses
|
|
15309
15479
|
* through, per camera, with the denominator attached. It stores nothing.
|
|
@@ -18709,7 +18879,12 @@ var NcMediaPolicySchema = object({
|
|
|
18709
18879
|
* the degrade engine, which drops the video and keeps the still.
|
|
18710
18880
|
*/
|
|
18711
18881
|
clip: boolean().optional(),
|
|
18712
|
-
/**
|
|
18882
|
+
/**
|
|
18883
|
+
* Seconds of footage BEFORE / AFTER the event instant. Absent =
|
|
18884
|
+
* {@link NC_CLIP_PRE_ROLL_DEFAULT_SEC} / {@link NC_CLIP_POST_ROLL_DEFAULT_SEC}
|
|
18885
|
+
* (4 / 6), resolved by the Notification Center at dispatch — deliberately not
|
|
18886
|
+
* a Zod default, so a rule that never chose keeps following the hub's.
|
|
18887
|
+
*/
|
|
18713
18888
|
clipPreRollSec: number().int().min(0).max(30).optional(),
|
|
18714
18889
|
clipPostRollSec: number().int().min(0).max(30).optional(),
|
|
18715
18890
|
/**
|
|
@@ -20087,6 +20262,163 @@ var OauthIntegrationDescriptorSchema = object({
|
|
|
20087
20262
|
});
|
|
20088
20263
|
method(_void(), OauthIntegrationDescriptorSchema, { auth: "admin" });
|
|
20089
20264
|
/**
|
|
20265
|
+
* A model choice has a SCOPE, and a scope change has a workflow.
|
|
20266
|
+
*
|
|
20267
|
+
* ## The two scopes
|
|
20268
|
+
*
|
|
20269
|
+
* Almost every pipeline step's model is a **node** question: which build runs
|
|
20270
|
+
* fastest on THIS accelerator. Two nodes may legitimately answer differently —
|
|
20271
|
+
* a Coral runs the tflite SSD, a Mac runs the coreml YOLO — because the answers
|
|
20272
|
+
* are compared against nothing. They produce boxes, and a box is a box.
|
|
20273
|
+
*
|
|
20274
|
+
* Two steps are not like that. `face-embedding` and `clip-embedding` produce
|
|
20275
|
+
* **vectors that land in ONE shared index** (`identity_samples`,
|
|
20276
|
+
* `vec_object_clip`) and are ranked against each other by cosine similarity.
|
|
20277
|
+
* Cosine similarity between two encoders is not a weak signal, it is a
|
|
20278
|
+
* meaningless number — and nothing inside a vector can tell you it happened.
|
|
20279
|
+
* So for those two steps the model is a **cluster** question with exactly one
|
|
20280
|
+
* answer, stored in exactly one row.
|
|
20281
|
+
*
|
|
20282
|
+
* That is the same argument [D52](../../../../docs/decisions/adr-0052.md) makes
|
|
20283
|
+
* for the crop convention (`detail-crop.ts`), one level up: the crop decides
|
|
20284
|
+
* WHAT pixels the encoder sees, this decides WHICH encoder sees them. Both are
|
|
20285
|
+
* cluster-wide for the same reason and neither may become per-node.
|
|
20286
|
+
*
|
|
20287
|
+
* ## Why the registry lives here and not in the step catalog
|
|
20288
|
+
*
|
|
20289
|
+
* The scope itself is declared on the step, in
|
|
20290
|
+
* `detection-pipeline/registry/step-definitions.ts` (`StepDefinition.modelScope`)
|
|
20291
|
+
* — that is where a new step declares what kind of choice its model is. But the
|
|
20292
|
+
* catalog lives inside `addon-pipeline`, and three packages that must NOT
|
|
20293
|
+
* import an addon need to know the set: the orchestrator (which renders and
|
|
20294
|
+
* stores the row), post-analysis (which pins the row for a re-embed pass) and
|
|
20295
|
+
* admin-ui (which mirrors it). So the set is mirrored here, and
|
|
20296
|
+
* `scripts/check-cluster-model-scope.ts` fails the build when the two disagree.
|
|
20297
|
+
* A hand-copied key that stops matching is exactly how a setting silently stops
|
|
20298
|
+
* arriving while both sides still look correct.
|
|
20299
|
+
*
|
|
20300
|
+
* ## The defaults ARE today's behaviour
|
|
20301
|
+
*
|
|
20302
|
+
* {@link DEFAULT_CLUSTER_STEP_MODELS} reproduces the catalog `defaultModelId`
|
|
20303
|
+
* of both steps. Declaring the scope therefore changes nothing on the day it
|
|
20304
|
+
* ships: the cluster row starts out saying what every node was already doing.
|
|
20305
|
+
* Changing it is legitimate — that is what the knob is for — but it invalidates
|
|
20306
|
+
* the stored vectors of that surface, and must be followed by a re-embed pass
|
|
20307
|
+
* (`face-reembed-pass.ts`, `embedding-rebuild.ts`).
|
|
20308
|
+
*/
|
|
20309
|
+
/**
|
|
20310
|
+
* Why the orchestrator cannot say which model the operator chose for a
|
|
20311
|
+
* cluster-scoped step (D651).
|
|
20312
|
+
*/
|
|
20313
|
+
var CLUSTER_MODEL_CHOICE_UNREADABLE_REASONS = [
|
|
20314
|
+
"owner-not-ready",
|
|
20315
|
+
"store-read-failed",
|
|
20316
|
+
"three-way-read-unavailable",
|
|
20317
|
+
"invalid-stored",
|
|
20318
|
+
"not-cluster-scoped"
|
|
20319
|
+
];
|
|
20320
|
+
/**
|
|
20321
|
+
* The cluster-scoped steps. Mirrored from the catalog's `modelScope: 'cluster'`
|
|
20322
|
+
* declarations and guarded by `scripts/check-cluster-model-scope.ts`.
|
|
20323
|
+
*/
|
|
20324
|
+
var CLUSTER_MODEL_SCOPED_STEPS = [{
|
|
20325
|
+
stepId: "face-embedding",
|
|
20326
|
+
label: "Face recognition model",
|
|
20327
|
+
defaultModelId: "auraface-r100",
|
|
20328
|
+
indexName: "the enrolled face gallery",
|
|
20329
|
+
options: [
|
|
20330
|
+
{
|
|
20331
|
+
id: "auraface-r100",
|
|
20332
|
+
label: "AuraFace R100"
|
|
20333
|
+
},
|
|
20334
|
+
{
|
|
20335
|
+
id: "arcface-r100",
|
|
20336
|
+
label: "ArcFace (ResNet34)"
|
|
20337
|
+
},
|
|
20338
|
+
{
|
|
20339
|
+
id: "inception-resnet-v1",
|
|
20340
|
+
label: "Inception ResNet V1"
|
|
20341
|
+
}
|
|
20342
|
+
]
|
|
20343
|
+
}, {
|
|
20344
|
+
stepId: "clip-embedding",
|
|
20345
|
+
label: "Semantic search model",
|
|
20346
|
+
defaultModelId: "mobileclip-s1",
|
|
20347
|
+
indexName: "the object semantic-search index",
|
|
20348
|
+
options: [
|
|
20349
|
+
{
|
|
20350
|
+
id: "mobileclip-s1",
|
|
20351
|
+
label: "MobileCLIP S1"
|
|
20352
|
+
},
|
|
20353
|
+
{
|
|
20354
|
+
id: "mobileclip-s2",
|
|
20355
|
+
label: "MobileCLIP S2"
|
|
20356
|
+
},
|
|
20357
|
+
{
|
|
20358
|
+
id: "siglip2-b16-224",
|
|
20359
|
+
label: "SigLIP2 B/16 (Apache-2.0)"
|
|
20360
|
+
}
|
|
20361
|
+
]
|
|
20362
|
+
}];
|
|
20363
|
+
/**
|
|
20364
|
+
* Store identity of the cluster row inside `pipeline-orchestrator`'s GLOBAL
|
|
20365
|
+
* (cluster-wide) settings — the same owner, and for the same reason, as the
|
|
20366
|
+
* detail-crop convention: hub-resident and neutral between the addons that
|
|
20367
|
+
* read it.
|
|
20368
|
+
*/
|
|
20369
|
+
var CLUSTER_MODEL_SECTION_ID = "cluster-step-models";
|
|
20370
|
+
/**
|
|
20371
|
+
* The settings key holding one step's cluster model choice.
|
|
20372
|
+
*
|
|
20373
|
+
* Step-qualified so the keys stay unique across the addon's whole schema, which
|
|
20374
|
+
* is what lets {@link pickClusterStepModels} walk every section instead of
|
|
20375
|
+
* trusting the section id.
|
|
20376
|
+
*/
|
|
20377
|
+
function clusterModelSettingKey(stepId) {
|
|
20378
|
+
return `clusterModel:${stepId}`;
|
|
20379
|
+
}
|
|
20380
|
+
Object.freeze(Object.fromEntries(CLUSTER_MODEL_SCOPED_STEPS.map((s) => [s.stepId, s.defaultModelId])));
|
|
20381
|
+
function interpretStoredClusterModel(raw) {
|
|
20382
|
+
if (raw === void 0 || raw === null || raw === "") return { kind: "absent" };
|
|
20383
|
+
if (typeof raw === "string") return {
|
|
20384
|
+
kind: "chosen",
|
|
20385
|
+
modelId: raw
|
|
20386
|
+
};
|
|
20387
|
+
return {
|
|
20388
|
+
kind: "invalid",
|
|
20389
|
+
value: raw
|
|
20390
|
+
};
|
|
20391
|
+
}
|
|
20392
|
+
var CLUSTER_STEP_SETTING_FIELDS = [{
|
|
20393
|
+
stepId: "face-embedding",
|
|
20394
|
+
key: "minLandmarkFaceSize",
|
|
20395
|
+
label: "Min face size for recognition (detection px)",
|
|
20396
|
+
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.",
|
|
20397
|
+
type: "slider",
|
|
20398
|
+
min: 0,
|
|
20399
|
+
max: 64,
|
|
20400
|
+
step: 2,
|
|
20401
|
+
default: 24
|
|
20402
|
+
}];
|
|
20403
|
+
function clusterStepSettingKey(stepId, fieldKey) {
|
|
20404
|
+
return `clusterStepSetting:${stepId}:${fieldKey}`;
|
|
20405
|
+
}
|
|
20406
|
+
var ClusterSettingNumberSchema = number().finite();
|
|
20407
|
+
function readClusterStepSettings(config) {
|
|
20408
|
+
const out = {};
|
|
20409
|
+
for (const field of CLUSTER_STEP_SETTING_FIELDS) {
|
|
20410
|
+
const parsed = ClusterSettingNumberSchema.safeParse(config[clusterStepSettingKey(field.stepId, field.key)]);
|
|
20411
|
+
const value = parsed.success ? parsed.data : field.default;
|
|
20412
|
+
const existing = out[field.stepId] ?? {};
|
|
20413
|
+
out[field.stepId] = {
|
|
20414
|
+
...existing,
|
|
20415
|
+
[field.key]: value
|
|
20416
|
+
};
|
|
20417
|
+
}
|
|
20418
|
+
return out;
|
|
20419
|
+
}
|
|
20420
|
+
readClusterStepSettings({});
|
|
20421
|
+
/**
|
|
20090
20422
|
* pipeline-analytics — device-scoped wrapper cap. Refines raw
|
|
20091
20423
|
* per-frame detections emitted by the pipeline runner into tracked
|
|
20092
20424
|
* objects, per-kind event collections (motion / object / audio), and
|
|
@@ -20902,7 +21234,8 @@ var MediaFileKindEnum = _enum([
|
|
|
20902
21234
|
"thumbnailSmall",
|
|
20903
21235
|
"confirmationCrop",
|
|
20904
21236
|
"debugCrop",
|
|
20905
|
-
"sceneFrame"
|
|
21237
|
+
"sceneFrame",
|
|
21238
|
+
"lastSceneFrame"
|
|
20906
21239
|
]);
|
|
20907
21240
|
/**
|
|
20908
21241
|
* One media row ON THE WIRE: what it is, how big it is, and WHERE ITS BYTES
|
|
@@ -21454,7 +21787,23 @@ var SearchObjectEventsInput = object({
|
|
|
21454
21787
|
until: number().optional(),
|
|
21455
21788
|
classFilter: string().optional(),
|
|
21456
21789
|
limit: number().default(50),
|
|
21457
|
-
|
|
21790
|
+
/**
|
|
21791
|
+
* An ABSOLUTE cosine floor. Optional since D649: a floor is a property of the
|
|
21792
|
+
* model (0.2 kept 96% of matched pairs on MobileCLIP S1 and 2.7% on SigLIP2),
|
|
21793
|
+
* so an absent one resolves to the searched model's own `searchMinScore`
|
|
21794
|
+
* scaled by `strictness`. A number sent here is honoured verbatim.
|
|
21795
|
+
*/
|
|
21796
|
+
minScore: number().min(0).max(1).optional(),
|
|
21797
|
+
/** Model-relative floor — see `resolveClipSearchMinScore`. Default `loose`. */
|
|
21798
|
+
strictness: ClipSearchStrictnessSchema.optional(),
|
|
21799
|
+
/**
|
|
21800
|
+
* Search THIS CLIP model's index instead of the cluster row's. Every index a
|
|
21801
|
+
* model ever wrote stays searchable until it is wiped (the MobileCLIP index
|
|
21802
|
+
* through a SigLIP2 cutover and its rollback window). An id with no CLIP
|
|
21803
|
+
* metadata is refused; an id other than the cluster row's is ADMIN-only
|
|
21804
|
+
* (`model-override-requires-admin`).
|
|
21805
|
+
*/
|
|
21806
|
+
modelId: string().min(1).optional()
|
|
21458
21807
|
});
|
|
21459
21808
|
var TrackCascadeCountsSchema = object({
|
|
21460
21809
|
/** Persisted track roots deleted (authoritative). */
|
|
@@ -21589,7 +21938,41 @@ var RebuildObjectEmbeddingsInput = object({
|
|
|
21589
21938
|
* comparable, so wiping and rebuilding is the only way to be sure every vector
|
|
21590
21939
|
* means the same thing.
|
|
21591
21940
|
*/
|
|
21592
|
-
var WipeObjectEmbeddingsResultSchema = object({
|
|
21941
|
+
var WipeObjectEmbeddingsResultSchema = object({
|
|
21942
|
+
deleted: number(),
|
|
21943
|
+
/**
|
|
21944
|
+
* CLIP indexes that could NOT be emptied (unreachable, or refused at their
|
|
21945
|
+
* dimension) — not a clean slate. Additive and optional (D649).
|
|
21946
|
+
*/
|
|
21947
|
+
failedIndexes: array(string()).optional()
|
|
21948
|
+
});
|
|
21949
|
+
/**
|
|
21950
|
+
* Why a rebuild did not start, BY NAME — ONE vocabulary for both gates in front
|
|
21951
|
+
* of a pass (D649 over D651). A bare `started: false` read the same for "the
|
|
21952
|
+
* stores are booting" and "the cluster row names a model with no CLIP
|
|
21953
|
+
* metadata", which need different fixes.
|
|
21954
|
+
* - `stores-unavailable` — the analytics stores or the API are not up yet.
|
|
21955
|
+
* - the owner's confirmation of the cluster model (D651, `ClusterPinConfirmer`):
|
|
21956
|
+
* a pass that rewrites an index needs two agreeing reads of the OWNER's
|
|
21957
|
+
* answer (`pipelineOrchestrator.getClusterModelChoice`) — a pass that cannot
|
|
21958
|
+
* name its model does not run (D54):
|
|
21959
|
+
* - the owner's own `unreadable` reasons (`owner-not-ready`,
|
|
21960
|
+
* `store-read-failed`, `three-way-read-unavailable`, `invalid-stored`,
|
|
21961
|
+
* `not-cluster-scoped`);
|
|
21962
|
+
* - `method-unavailable` — the orchestrator predates the method;
|
|
21963
|
+
* - `read-failed` — the call failed or answered something unparseable;
|
|
21964
|
+
* - `reads-disagree` — the two reads named different choices.
|
|
21965
|
+
* - `not-a-clip-model` — the CONFIRMED id has no CLIP metadata, so there is no
|
|
21966
|
+
* index to fill and no dimension to check against (D649).
|
|
21967
|
+
*/
|
|
21968
|
+
var RebuildObjectEmbeddingsRefusalSchema = _enum([
|
|
21969
|
+
"stores-unavailable",
|
|
21970
|
+
...CLUSTER_MODEL_CHOICE_UNREADABLE_REASONS,
|
|
21971
|
+
"method-unavailable",
|
|
21972
|
+
"read-failed",
|
|
21973
|
+
"reads-disagree",
|
|
21974
|
+
"not-a-clip-model"
|
|
21975
|
+
]);
|
|
21593
21976
|
/**
|
|
21594
21977
|
* Acknowledgement that a rebuild STARTED.
|
|
21595
21978
|
*
|
|
@@ -21602,7 +21985,12 @@ var WipeObjectEmbeddingsResultSchema = object({ deleted: number() });
|
|
|
21602
21985
|
var RebuildObjectEmbeddingsResultSchema = object({
|
|
21603
21986
|
started: boolean(),
|
|
21604
21987
|
/** True when a pass was already running; the new request is ignored. */
|
|
21605
|
-
alreadyRunning: boolean()
|
|
21988
|
+
alreadyRunning: boolean(),
|
|
21989
|
+
/** Present when the pass refused to start — see `RebuildObjectEmbeddingsRefusalSchema`. */
|
|
21990
|
+
refusal: RebuildObjectEmbeddingsRefusalSchema.optional(),
|
|
21991
|
+
/** The CLIP model the pass pinned, and the index it fills (when it started). */
|
|
21992
|
+
modelId: string().optional(),
|
|
21993
|
+
index: string().optional()
|
|
21606
21994
|
});
|
|
21607
21995
|
var RebuildStatusSchema = object({
|
|
21608
21996
|
running: boolean(),
|
|
@@ -21636,7 +22024,11 @@ var RebuildStatusSchema = object({
|
|
|
21636
22024
|
startedAtMs: number().nullable(),
|
|
21637
22025
|
finishedAtMs: number().nullable(),
|
|
21638
22026
|
/** Present when the pass ended by throwing. */
|
|
21639
|
-
error: string().nullable()
|
|
22027
|
+
error: string().nullable(),
|
|
22028
|
+
/** The CLIP model the current / last pass pinned (D54), `null` before any. */
|
|
22029
|
+
modelId: string().nullable(),
|
|
22030
|
+
/** The index that pass fills — `ClipModelMeta.vectorIndex` of `modelId` (D649). */
|
|
22031
|
+
index: string().nullable()
|
|
21640
22032
|
});
|
|
21641
22033
|
/**
|
|
21642
22034
|
* Acknowledgement that a debug-media reclaim STARTED.
|
|
@@ -21957,7 +22349,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
21957
22349
|
deviceId: number(),
|
|
21958
22350
|
ownerKind: MediaPresenceOwnerKindSchema,
|
|
21959
22351
|
ownerIds: array(string()).max(5e3)
|
|
21960
|
-
}), array(string()).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
|
|
22352
|
+
}), array(string()).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly(), { caller: "required" }), method(object({}), WipeObjectEmbeddingsResultSchema, {
|
|
21961
22353
|
kind: "mutation",
|
|
21962
22354
|
auth: "admin"
|
|
21963
22355
|
}), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
|
|
@@ -23228,6 +23620,17 @@ var RunnerFrameSourceSchema = discriminatedUnion("kind", [object({ kind: literal
|
|
|
23228
23620
|
hubHostnameOverride: string().optional()
|
|
23229
23621
|
})]).describe("Per-camera frame-source mode for the runner (P2c)");
|
|
23230
23622
|
/**
|
|
23623
|
+
* What ONE step runs with on ONE roster device for THIS camera (D650): the
|
|
23624
|
+
* device's per-(node,device) base with the camera's override for that device
|
|
23625
|
+
* on top — the same `override ?? base` merge the orchestrator applies to the
|
|
23626
|
+
* elected device (`applyDeviceProvisioning`). Absent `modelId` ⇒ the executor
|
|
23627
|
+
* resolves the device format's default, exactly as on the elected device.
|
|
23628
|
+
*/
|
|
23629
|
+
var RunnerJumpStepProvisioningSchema = object({
|
|
23630
|
+
modelId: string().optional(),
|
|
23631
|
+
settings: record(string(), unknown()).optional()
|
|
23632
|
+
});
|
|
23633
|
+
/**
|
|
23231
23634
|
* One ENABLED inference device on the runner's node, as the step-tree
|
|
23232
23635
|
* device-jump resolver sees it (phase 1). The orchestrator populates this
|
|
23233
23636
|
* roster on the attach payload whenever the camera is elected onto a specific
|
|
@@ -23239,7 +23642,16 @@ var RunnerFrameSourceSchema = discriminatedUnion("kind", [object({ kind: literal
|
|
|
23239
23642
|
var RunnerInferenceDeviceSchema = object({
|
|
23240
23643
|
deviceKey: string(),
|
|
23241
23644
|
weight: number().positive().default(1),
|
|
23242
|
-
maxSessions: number().int().positive().nullable().default(null)
|
|
23645
|
+
maxSessions: number().int().positive().nullable().default(null),
|
|
23646
|
+
/**
|
|
23647
|
+
* Per-step provisioning for this camera on THIS device (D650), keyed by step
|
|
23648
|
+
* id. A step the runner jumps here runs with these fields, never with the
|
|
23649
|
+
* elected device's: `modelId` / `settings` are REPLACED on the jumped subtree
|
|
23650
|
+
* (a step absent from the map runs with neither — the target format default).
|
|
23651
|
+
* Absent field ⇒ an orchestrator that predates D650: the jumped step keeps
|
|
23652
|
+
* the elected device's provisioning, as before.
|
|
23653
|
+
*/
|
|
23654
|
+
steps: record(string(), RunnerJumpStepProvisioningSchema).optional()
|
|
23243
23655
|
});
|
|
23244
23656
|
/**
|
|
23245
23657
|
* Camera assignment payload sent by `addon-pipeline-orchestrator` to a
|
|
@@ -24315,6 +24727,28 @@ var NodeInferenceDeviceSchema = object({
|
|
|
24315
24727
|
*/
|
|
24316
24728
|
exclusion: InferenceDeviceExclusionReasonSchema.nullable()
|
|
24317
24729
|
});
|
|
24730
|
+
/**
|
|
24731
|
+
* The operator's model choice for one cluster-scoped step, answered by the
|
|
24732
|
+
* addon that OWNS the `clusterModel:*` settings (D651). Three answers, never
|
|
24733
|
+
* folded: a written choice, a key nobody wrote (the cluster runs the default
|
|
24734
|
+
* BY ABSENCE), or no answer. A caller that rewrites an index acts only on the
|
|
24735
|
+
* first two, read twice and agreeing.
|
|
24736
|
+
*/
|
|
24737
|
+
var ClusterModelChoiceSchema = discriminatedUnion("kind", [
|
|
24738
|
+
object({
|
|
24739
|
+
kind: literal("stored"),
|
|
24740
|
+
modelId: string()
|
|
24741
|
+
}),
|
|
24742
|
+
object({
|
|
24743
|
+
kind: literal("absent"),
|
|
24744
|
+
defaultModelId: string()
|
|
24745
|
+
}),
|
|
24746
|
+
object({
|
|
24747
|
+
kind: literal("unreadable"),
|
|
24748
|
+
reason: _enum(CLUSTER_MODEL_CHOICE_UNREADABLE_REASONS),
|
|
24749
|
+
error: string().optional()
|
|
24750
|
+
})
|
|
24751
|
+
]);
|
|
24318
24752
|
var NodeInferenceDevicesSchema = object({
|
|
24319
24753
|
nodeId: string(),
|
|
24320
24754
|
/** False when the node's platform-probe was unreachable (no live device set). */
|
|
@@ -24411,9 +24845,19 @@ var pipelineOrchestratorCapability = {
|
|
|
24411
24845
|
* Trigger a full rebalance pass. Iterates over all assigned cameras,
|
|
24412
24846
|
* recomputes the optimal agent for each one based on current load +
|
|
24413
24847
|
* hardware affinity, and migrates cameras whose current agent is no
|
|
24414
|
-
* longer optimal. Pinned cameras are never migrated.
|
|
24848
|
+
* longer optimal. Pinned cameras are never migrated. `skipped` names the
|
|
24849
|
+
* cameras the pass wanted to move and could not, with why — a target node
|
|
24850
|
+
* that has not answered its catalog / device health yet, or a device that
|
|
24851
|
+
* cannot run the camera's root (D650).
|
|
24415
24852
|
*/
|
|
24416
|
-
rebalance: method(_void(), object({
|
|
24853
|
+
rebalance: method(_void(), object({
|
|
24854
|
+
migrated: number(),
|
|
24855
|
+
skipped: array(object({
|
|
24856
|
+
deviceId: number(),
|
|
24857
|
+
targetNodeId: string(),
|
|
24858
|
+
reason: string()
|
|
24859
|
+
})).default([])
|
|
24860
|
+
}), {
|
|
24417
24861
|
kind: "mutation",
|
|
24418
24862
|
auth: "admin"
|
|
24419
24863
|
}),
|
|
@@ -24423,6 +24867,13 @@ var pipelineOrchestratorCapability = {
|
|
|
24423
24867
|
getPipelineAssignment: method(object({ deviceId: number() }), PipelineAssignmentSchema.nullable()),
|
|
24424
24868
|
/** Get per-agent load summaries used by the load balancer + dashboards. */
|
|
24425
24869
|
getAgentLoad: method(_void(), array(AgentLoadSummarySchema).readonly()),
|
|
24870
|
+
/**
|
|
24871
|
+
* The operator's STORED model choice for one cluster-scoped step (D651).
|
|
24872
|
+
* Read three-way from this addon's own store, only once its boot has
|
|
24873
|
+
* settled. Unlike `addonSettings.getGlobalSettings`, it never answers a
|
|
24874
|
+
* default as though someone chose it.
|
|
24875
|
+
*/
|
|
24876
|
+
getClusterModelChoice: method(object({ stepId: string() }), ClusterModelChoiceSchema),
|
|
24426
24877
|
/** Aggregate global metrics across the whole detection cluster. */
|
|
24427
24878
|
getGlobalMetrics: method(_void(), GlobalMetricsSchema),
|
|
24428
24879
|
/**
|
|
@@ -26975,6 +27426,167 @@ DeviceType.Camera, method(object({
|
|
|
26975
27426
|
auth: "protected"
|
|
26976
27427
|
});
|
|
26977
27428
|
/**
|
|
27429
|
+
* LA tabella "quale booleano di questo tipo di device conta come ALTO", e il
|
|
27430
|
+
* valutatore puro del suo FRONTE.
|
|
27431
|
+
*
|
|
27432
|
+
* Viveva dentro il builtin virtual-doorbell
|
|
27433
|
+
* (`@camstack/system` — `builtins/doorbell/trigger-engine.ts`) e i suoi
|
|
27434
|
+
* predicati erano privati al modulo. Il recorder ne ha bisogno per il trigger
|
|
27435
|
+
* `RecordingTriggers.sensorDeviceIds`: copiarla avrebbe creato la SECONDA
|
|
27436
|
+
* tabella, che diverge alla prima cap aggiunta e il cui sintomo — "il sensore
|
|
27437
|
+
* fa suonare il campanello ma non registra" — è esattamente D62. Quindi si
|
|
27438
|
+
* SPOSTA qui e il doorbell la ri-esporta.
|
|
27439
|
+
*
|
|
27440
|
+
* ⚠ NON è `DEVICE_STATE_READERS` (`catalogs/device-state-vocabulary.ts`), e le
|
|
27441
|
+
* due non vanno unificate: quella risponde a "qual è la PAROLA di stato per una
|
|
27442
|
+
* regola" (e include `presence`, `cover`, `alarm-panel`), questa a "qual è il
|
|
27443
|
+
* booleano il cui FRONTE conta". Vocabolari deliberatamente diversi.
|
|
27444
|
+
*/
|
|
27445
|
+
/**
|
|
27446
|
+
* Known binary / switch source caps → the boolean slice field whose
|
|
27447
|
+
* false→true rise counts as ACTIVE. Every entry is "fire on active".
|
|
27448
|
+
* Sensors whose "active" reading is not a plain boolean (presence's string
|
|
27449
|
+
* state, connectivity's connected flag) are deliberately excluded — a
|
|
27450
|
+
* reconnect is not a doorbell press, and it is not a recording either.
|
|
27451
|
+
*/
|
|
27452
|
+
var SOURCE_CAP_ACTIVE_FIELD = {
|
|
27453
|
+
contact: "entryOpen",
|
|
27454
|
+
binary: "on",
|
|
27455
|
+
switch: "on",
|
|
27456
|
+
motion: "detected",
|
|
27457
|
+
flood: "flooded",
|
|
27458
|
+
gas: "detected",
|
|
27459
|
+
smoke: "detected",
|
|
27460
|
+
"carbon-monoxide": "detected",
|
|
27461
|
+
vibration: "detected",
|
|
27462
|
+
tamper: "tampered"
|
|
27463
|
+
};
|
|
27464
|
+
object({
|
|
27465
|
+
/** Ms epoch of the last press. null = never observed since this provider started. */
|
|
27466
|
+
lastPressedAt: number().nullable(),
|
|
27467
|
+
/** Counter since provider start. Resets on reboot. Useful for metrics/debug. */
|
|
27468
|
+
pressCountSinceStart: number()
|
|
27469
|
+
});
|
|
27470
|
+
object({
|
|
27471
|
+
deviceId: number(),
|
|
27472
|
+
timestamp: number()
|
|
27473
|
+
});
|
|
27474
|
+
/**
|
|
27475
|
+
* The `doorbell-source` extension slot's editable value (D648): the camera's
|
|
27476
|
+
* virtual-doorbell trigger devices, exactly what the wrapper's
|
|
27477
|
+
* `doorbellSources` persists (as `{ deviceId }` records).
|
|
27478
|
+
*/
|
|
27479
|
+
var DoorbellSourceSlotValueSchema = object({ deviceIds: array(number().int().positive()) });
|
|
27480
|
+
DeviceType.Camera;
|
|
27481
|
+
DeviceType.Button, DeviceType.Camera;
|
|
27482
|
+
var ExtensionSlotIdSchema = _enum(["doorbell-source", "linked-devices"]);
|
|
27483
|
+
DeviceType.Camera, DeviceType.Camera;
|
|
27484
|
+
/** A slot's editable value, discriminated on `slot`. */
|
|
27485
|
+
var ExtensionSlotValueSchema = discriminatedUnion("slot", [DoorbellSourceSlotValueSchema.extend({ slot: literal("doorbell-source") }), LinkedDevicesSlotValueSchema.extend({ slot: literal("linked-devices") })]);
|
|
27486
|
+
DeviceType.Camera;
|
|
27487
|
+
var ExtensionSourceAvailabilitySchema = discriminatedUnion("state", [object({ state: literal("ok") }), object({
|
|
27488
|
+
state: literal("unreachable"),
|
|
27489
|
+
reason: string()
|
|
27490
|
+
})]);
|
|
27491
|
+
/** One provider of slots — the D554 source-listing row. */
|
|
27492
|
+
var ExtensionSourceSchema = object({
|
|
27493
|
+
/** The routing prefix of every `slotRef` this provider serves. */
|
|
27494
|
+
source: string(),
|
|
27495
|
+
label: string(),
|
|
27496
|
+
/** Stamped by the dispatcher from the registry (D557). */
|
|
27497
|
+
addonId: string().optional(),
|
|
27498
|
+
availability: ExtensionSourceAvailabilitySchema
|
|
27499
|
+
});
|
|
27500
|
+
/**
|
|
27501
|
+
* How one picked source stands. `missing` — no such device (deleted, or an id
|
|
27502
|
+
* that never existed); `foreign` — a device the slot's owner cannot read from
|
|
27503
|
+
* (slice 2's cross-owner graft source); `unavailable` — it exists but is not a
|
|
27504
|
+
* valid source now (a container, which is never a pick). A source in any of
|
|
27505
|
+
* these states is SHOWN, never silently dropped or re-pointed.
|
|
27506
|
+
*/
|
|
27507
|
+
var ExtensionSourceHealthSchema = _enum([
|
|
27508
|
+
"ok",
|
|
27509
|
+
"missing",
|
|
27510
|
+
"foreign",
|
|
27511
|
+
"unavailable"
|
|
27512
|
+
]);
|
|
27513
|
+
var ExtensionSlotSourceSchema = object({
|
|
27514
|
+
deviceId: number().int(),
|
|
27515
|
+
/** The device's name, or null when it could not be resolved (`missing`). */
|
|
27516
|
+
name: string().nullable(),
|
|
27517
|
+
health: ExtensionSourceHealthSchema,
|
|
27518
|
+
detail: string().optional()
|
|
27519
|
+
});
|
|
27520
|
+
/**
|
|
27521
|
+
* A DIFFERENT authority that gates whether the slot's sources do anything —
|
|
27522
|
+
* reported read-only, named, never written through this contract (D62). The
|
|
27523
|
+
* virtual doorbell's sources ring only while the wrapper is bound to the
|
|
27524
|
+
* camera, and that binding belongs to `deviceManager.setWrapperActive`.
|
|
27525
|
+
* `unknown` means the gate could not be read; it is not "inactive" (D315).
|
|
27526
|
+
*/
|
|
27527
|
+
var ExtensionSlotActivationSchema = object({
|
|
27528
|
+
state: _enum([
|
|
27529
|
+
"active",
|
|
27530
|
+
"inactive",
|
|
27531
|
+
"unknown"
|
|
27532
|
+
]),
|
|
27533
|
+
authority: string(),
|
|
27534
|
+
detail: string().optional()
|
|
27535
|
+
});
|
|
27536
|
+
var ExtensionSlotRowSchema = discriminatedUnion("state", [object({
|
|
27537
|
+
state: literal("ok"),
|
|
27538
|
+
/** `<source>:<slotId>` — the routing key for `listCandidates` / `setSlot`. */
|
|
27539
|
+
slotRef: string(),
|
|
27540
|
+
slot: ExtensionSlotIdSchema,
|
|
27541
|
+
/** Names the store written (D62), e.g. `virtual-doorbell:doorbellSources`. */
|
|
27542
|
+
authority: string(),
|
|
27543
|
+
/** graft: its switch. association: null — removing every source is "off". */
|
|
27544
|
+
enabled: boolean().nullable(),
|
|
27545
|
+
/** graft: the cap registered on the host. association: null. */
|
|
27546
|
+
projects: string().nullable(),
|
|
27547
|
+
activation: ExtensionSlotActivationSchema.nullable(),
|
|
27548
|
+
value: ExtensionSlotValueSchema,
|
|
27549
|
+
sources: array(ExtensionSlotSourceSchema)
|
|
27550
|
+
}), object({
|
|
27551
|
+
state: literal("unreachable"),
|
|
27552
|
+
addonId: string(),
|
|
27553
|
+
reason: string()
|
|
27554
|
+
})]);
|
|
27555
|
+
var ExtensionCandidatesSchema = object({ candidates: array(object({
|
|
27556
|
+
deviceId: number().int(),
|
|
27557
|
+
name: string(),
|
|
27558
|
+
type: string(),
|
|
27559
|
+
location: string().nullable(),
|
|
27560
|
+
/** The container a child belongs to — "Spotlight 2" alone does not say which. */
|
|
27561
|
+
parentName: string().nullable(),
|
|
27562
|
+
/** Pre-marked by the owner's suggestion rule. NEVER saved on its own (D356). */
|
|
27563
|
+
suggested: boolean(),
|
|
27564
|
+
/** Why it is suggested ("same location"), null when it is not. */
|
|
27565
|
+
reason: string().nullable()
|
|
27566
|
+
})) });
|
|
27567
|
+
/**
|
|
27568
|
+
* What a write did. `none` — applied in place. `host` / `parent` — the owner
|
|
27569
|
+
* reloaded the host (or, for an accessory child, its parent) because the
|
|
27570
|
+
* write changed what the host REGISTERS (a graft switched on or off, slice 2).
|
|
27571
|
+
*/
|
|
27572
|
+
var ExtensionSetResultSchema = object({
|
|
27573
|
+
applied: literal(true),
|
|
27574
|
+
reload: _enum([
|
|
27575
|
+
"none",
|
|
27576
|
+
"host",
|
|
27577
|
+
"parent"
|
|
27578
|
+
])
|
|
27579
|
+
});
|
|
27580
|
+
var ExtensionSlotRefInputSchema = object({
|
|
27581
|
+
deviceId: number().int(),
|
|
27582
|
+
slotRef: string().min(1)
|
|
27583
|
+
});
|
|
27584
|
+
var ExtensionSetSlotInputSchema = ExtensionSlotRefInputSchema.extend({ value: ExtensionSlotValueSchema });
|
|
27585
|
+
method(object({ deviceId: number().int() }), array(ExtensionSourceSchema)), method(object({ deviceId: number().int() }), array(ExtensionSlotRowSchema)), method(ExtensionSlotRefInputSchema, ExtensionCandidatesSchema), method(ExtensionSetSlotInputSchema, ExtensionSetResultSchema, {
|
|
27586
|
+
kind: "mutation",
|
|
27587
|
+
auth: "admin"
|
|
27588
|
+
});
|
|
27589
|
+
/**
|
|
26978
27590
|
* Optional client-side hints sent at session creation to help the provider
|
|
26979
27591
|
* pick the best native source. All fields optional — a viewer that knows
|
|
26980
27592
|
* nothing still gets a sane default. (Relocated from the retired `webrtc`
|
|
@@ -28763,17 +29375,6 @@ object({
|
|
|
28763
29375
|
deviceId: number(),
|
|
28764
29376
|
status: DeviceStatusSchema
|
|
28765
29377
|
});
|
|
28766
|
-
object({
|
|
28767
|
-
/** Ms epoch of the last press. null = never observed since this provider started. */
|
|
28768
|
-
lastPressedAt: number().nullable(),
|
|
28769
|
-
/** Counter since provider start. Resets on reboot. Useful for metrics/debug. */
|
|
28770
|
-
pressCountSinceStart: number()
|
|
28771
|
-
});
|
|
28772
|
-
object({
|
|
28773
|
-
deviceId: number(),
|
|
28774
|
-
timestamp: number()
|
|
28775
|
-
});
|
|
28776
|
-
DeviceType.Button, DeviceType.Camera;
|
|
28777
29378
|
/**
|
|
28778
29379
|
* Enum-state sensor — a string value picked from a finite option set.
|
|
28779
29380
|
* Drives HA `sensor` entries with `state_class: enum` (HVAC action
|
|
@@ -35626,42 +36227,6 @@ var zoneRulesCapability = {
|
|
|
35626
36227
|
durability: "restored"
|
|
35627
36228
|
};
|
|
35628
36229
|
/**
|
|
35629
|
-
* LA tabella "quale booleano di questo tipo di device conta come ALTO", e il
|
|
35630
|
-
* valutatore puro del suo FRONTE.
|
|
35631
|
-
*
|
|
35632
|
-
* Viveva dentro il builtin virtual-doorbell
|
|
35633
|
-
* (`@camstack/system` — `builtins/doorbell/trigger-engine.ts`) e i suoi
|
|
35634
|
-
* predicati erano privati al modulo. Il recorder ne ha bisogno per il trigger
|
|
35635
|
-
* `RecordingTriggers.sensorDeviceIds`: copiarla avrebbe creato la SECONDA
|
|
35636
|
-
* tabella, che diverge alla prima cap aggiunta e il cui sintomo — "il sensore
|
|
35637
|
-
* fa suonare il campanello ma non registra" — è esattamente D62. Quindi si
|
|
35638
|
-
* SPOSTA qui e il doorbell la ri-esporta.
|
|
35639
|
-
*
|
|
35640
|
-
* ⚠ NON è `DEVICE_STATE_READERS` (`catalogs/device-state-vocabulary.ts`), e le
|
|
35641
|
-
* due non vanno unificate: quella risponde a "qual è la PAROLA di stato per una
|
|
35642
|
-
* regola" (e include `presence`, `cover`, `alarm-panel`), questa a "qual è il
|
|
35643
|
-
* booleano il cui FRONTE conta". Vocabolari deliberatamente diversi.
|
|
35644
|
-
*/
|
|
35645
|
-
/**
|
|
35646
|
-
* Known binary / switch source caps → the boolean slice field whose
|
|
35647
|
-
* false→true rise counts as ACTIVE. Every entry is "fire on active".
|
|
35648
|
-
* Sensors whose "active" reading is not a plain boolean (presence's string
|
|
35649
|
-
* state, connectivity's connected flag) are deliberately excluded — a
|
|
35650
|
-
* reconnect is not a doorbell press, and it is not a recording either.
|
|
35651
|
-
*/
|
|
35652
|
-
var SOURCE_CAP_ACTIVE_FIELD = {
|
|
35653
|
-
contact: "entryOpen",
|
|
35654
|
-
binary: "on",
|
|
35655
|
-
switch: "on",
|
|
35656
|
-
motion: "detected",
|
|
35657
|
-
flood: "flooded",
|
|
35658
|
-
gas: "detected",
|
|
35659
|
-
smoke: "detected",
|
|
35660
|
-
"carbon-monoxide": "detected",
|
|
35661
|
-
vibration: "detected",
|
|
35662
|
-
tamper: "tampered"
|
|
35663
|
-
};
|
|
35664
|
-
/**
|
|
35665
36230
|
* Accessory device helpers — shared across drivers.
|
|
35666
36231
|
*
|
|
35667
36232
|
* Many vendor-specific drivers register accessory child devices on
|
|
@@ -38489,6 +39054,30 @@ Object.freeze({
|
|
|
38489
39054
|
addonId: null,
|
|
38490
39055
|
access: "create"
|
|
38491
39056
|
},
|
|
39057
|
+
"deviceExtension.listCandidates": {
|
|
39058
|
+
capName: "device-extension",
|
|
39059
|
+
capScope: "device",
|
|
39060
|
+
addonId: null,
|
|
39061
|
+
access: "view"
|
|
39062
|
+
},
|
|
39063
|
+
"deviceExtension.listSlots": {
|
|
39064
|
+
capName: "device-extension",
|
|
39065
|
+
capScope: "device",
|
|
39066
|
+
addonId: null,
|
|
39067
|
+
access: "view"
|
|
39068
|
+
},
|
|
39069
|
+
"deviceExtension.listSources": {
|
|
39070
|
+
capName: "device-extension",
|
|
39071
|
+
capScope: "device",
|
|
39072
|
+
addonId: null,
|
|
39073
|
+
access: "view"
|
|
39074
|
+
},
|
|
39075
|
+
"deviceExtension.setSlot": {
|
|
39076
|
+
capName: "device-extension",
|
|
39077
|
+
capScope: "device",
|
|
39078
|
+
addonId: null,
|
|
39079
|
+
access: "create"
|
|
39080
|
+
},
|
|
38492
39081
|
"deviceManager.addLocation": {
|
|
38493
39082
|
capName: "device-manager",
|
|
38494
39083
|
capScope: "system",
|
|
@@ -39239,6 +39828,12 @@ Object.freeze({
|
|
|
39239
39828
|
addonId: null,
|
|
39240
39829
|
access: "view"
|
|
39241
39830
|
},
|
|
39831
|
+
"embeddingEncoder.resetFailedModels": {
|
|
39832
|
+
capName: "embedding-encoder",
|
|
39833
|
+
capScope: "system",
|
|
39834
|
+
addonId: null,
|
|
39835
|
+
access: "delete"
|
|
39836
|
+
},
|
|
39242
39837
|
"enumSensor.getStatus": {
|
|
39243
39838
|
capName: "enum-sensor",
|
|
39244
39839
|
capScope: "device",
|
|
@@ -41573,6 +42168,12 @@ Object.freeze({
|
|
|
41573
42168
|
addonId: null,
|
|
41574
42169
|
access: "view"
|
|
41575
42170
|
},
|
|
42171
|
+
"pipelineOrchestrator.getClusterModelChoice": {
|
|
42172
|
+
capName: "pipeline-orchestrator",
|
|
42173
|
+
capScope: "system",
|
|
42174
|
+
addonId: null,
|
|
42175
|
+
access: "view"
|
|
42176
|
+
},
|
|
41576
42177
|
"pipelineOrchestrator.getDeviceLiveContribution": {
|
|
41577
42178
|
capName: "pipeline-orchestrator",
|
|
41578
42179
|
capScope: "system",
|
|
@@ -44398,6 +44999,26 @@ Object.freeze({
|
|
|
44398
44999
|
form: "single",
|
|
44399
45000
|
optional: false
|
|
44400
45001
|
}],
|
|
45002
|
+
"deviceExtension.listCandidates": [{
|
|
45003
|
+
name: "deviceId",
|
|
45004
|
+
form: "single",
|
|
45005
|
+
optional: false
|
|
45006
|
+
}],
|
|
45007
|
+
"deviceExtension.listSlots": [{
|
|
45008
|
+
name: "deviceId",
|
|
45009
|
+
form: "single",
|
|
45010
|
+
optional: false
|
|
45011
|
+
}],
|
|
45012
|
+
"deviceExtension.listSources": [{
|
|
45013
|
+
name: "deviceId",
|
|
45014
|
+
form: "single",
|
|
45015
|
+
optional: false
|
|
45016
|
+
}],
|
|
45017
|
+
"deviceExtension.setSlot": [{
|
|
45018
|
+
name: "deviceId",
|
|
45019
|
+
form: "single",
|
|
45020
|
+
optional: false
|
|
45021
|
+
}],
|
|
44401
45022
|
"deviceManager.adoptionRelease": [{
|
|
44402
45023
|
name: "camDeviceId",
|
|
44403
45024
|
form: "single",
|
|
@@ -46858,135 +47479,6 @@ TimelapseRuleInputSchema.extend({
|
|
|
46858
47479
|
updatedAt: number()
|
|
46859
47480
|
});
|
|
46860
47481
|
/**
|
|
46861
|
-
* A model choice has a SCOPE, and a scope change has a workflow.
|
|
46862
|
-
*
|
|
46863
|
-
* ## The two scopes
|
|
46864
|
-
*
|
|
46865
|
-
* Almost every pipeline step's model is a **node** question: which build runs
|
|
46866
|
-
* fastest on THIS accelerator. Two nodes may legitimately answer differently —
|
|
46867
|
-
* a Coral runs the tflite SSD, a Mac runs the coreml YOLO — because the answers
|
|
46868
|
-
* are compared against nothing. They produce boxes, and a box is a box.
|
|
46869
|
-
*
|
|
46870
|
-
* Two steps are not like that. `face-embedding` and `clip-embedding` produce
|
|
46871
|
-
* **vectors that land in ONE shared index** (`identity_samples`,
|
|
46872
|
-
* `vec_object_clip`) and are ranked against each other by cosine similarity.
|
|
46873
|
-
* Cosine similarity between two encoders is not a weak signal, it is a
|
|
46874
|
-
* meaningless number — and nothing inside a vector can tell you it happened.
|
|
46875
|
-
* So for those two steps the model is a **cluster** question with exactly one
|
|
46876
|
-
* answer, stored in exactly one row.
|
|
46877
|
-
*
|
|
46878
|
-
* That is the same argument [D52](../../../../docs/decisions/adr-0052.md) makes
|
|
46879
|
-
* for the crop convention (`detail-crop.ts`), one level up: the crop decides
|
|
46880
|
-
* WHAT pixels the encoder sees, this decides WHICH encoder sees them. Both are
|
|
46881
|
-
* cluster-wide for the same reason and neither may become per-node.
|
|
46882
|
-
*
|
|
46883
|
-
* ## Why the registry lives here and not in the step catalog
|
|
46884
|
-
*
|
|
46885
|
-
* The scope itself is declared on the step, in
|
|
46886
|
-
* `detection-pipeline/registry/step-definitions.ts` (`StepDefinition.modelScope`)
|
|
46887
|
-
* — that is where a new step declares what kind of choice its model is. But the
|
|
46888
|
-
* catalog lives inside `addon-pipeline`, and three packages that must NOT
|
|
46889
|
-
* import an addon need to know the set: the orchestrator (which renders and
|
|
46890
|
-
* stores the row), post-analysis (which pins the row for a re-embed pass) and
|
|
46891
|
-
* admin-ui (which mirrors it). So the set is mirrored here, and
|
|
46892
|
-
* `scripts/check-cluster-model-scope.ts` fails the build when the two disagree.
|
|
46893
|
-
* A hand-copied key that stops matching is exactly how a setting silently stops
|
|
46894
|
-
* arriving while both sides still look correct.
|
|
46895
|
-
*
|
|
46896
|
-
* ## The defaults ARE today's behaviour
|
|
46897
|
-
*
|
|
46898
|
-
* {@link DEFAULT_CLUSTER_STEP_MODELS} reproduces the catalog `defaultModelId`
|
|
46899
|
-
* of both steps. Declaring the scope therefore changes nothing on the day it
|
|
46900
|
-
* ships: the cluster row starts out saying what every node was already doing.
|
|
46901
|
-
* Changing it is legitimate — that is what the knob is for — but it invalidates
|
|
46902
|
-
* the stored vectors of that surface, and must be followed by a re-embed pass
|
|
46903
|
-
* (`face-reembed-pass.ts`, `embedding-rebuild.ts`).
|
|
46904
|
-
*/
|
|
46905
|
-
/**
|
|
46906
|
-
* The cluster-scoped steps. Mirrored from the catalog's `modelScope: 'cluster'`
|
|
46907
|
-
* declarations and guarded by `scripts/check-cluster-model-scope.ts`.
|
|
46908
|
-
*/
|
|
46909
|
-
var CLUSTER_MODEL_SCOPED_STEPS = [{
|
|
46910
|
-
stepId: "face-embedding",
|
|
46911
|
-
label: "Face recognition model",
|
|
46912
|
-
defaultModelId: "arcface-r100",
|
|
46913
|
-
indexName: "the enrolled face gallery",
|
|
46914
|
-
options: [
|
|
46915
|
-
{
|
|
46916
|
-
id: "arcface-r100",
|
|
46917
|
-
label: "ArcFace (ResNet34)"
|
|
46918
|
-
},
|
|
46919
|
-
{
|
|
46920
|
-
id: "auraface-r100",
|
|
46921
|
-
label: "AuraFace R100"
|
|
46922
|
-
},
|
|
46923
|
-
{
|
|
46924
|
-
id: "inception-resnet-v1",
|
|
46925
|
-
label: "Inception ResNet V1"
|
|
46926
|
-
}
|
|
46927
|
-
]
|
|
46928
|
-
}, {
|
|
46929
|
-
stepId: "clip-embedding",
|
|
46930
|
-
label: "Semantic search model",
|
|
46931
|
-
defaultModelId: "mobileclip-s1",
|
|
46932
|
-
indexName: "the object semantic-search index",
|
|
46933
|
-
options: [{
|
|
46934
|
-
id: "mobileclip-s1",
|
|
46935
|
-
label: "MobileCLIP S1"
|
|
46936
|
-
}, {
|
|
46937
|
-
id: "mobileclip-s2",
|
|
46938
|
-
label: "MobileCLIP S2"
|
|
46939
|
-
}]
|
|
46940
|
-
}];
|
|
46941
|
-
/**
|
|
46942
|
-
* Store identity of the cluster row inside `pipeline-orchestrator`'s GLOBAL
|
|
46943
|
-
* (cluster-wide) settings — the same owner, and for the same reason, as the
|
|
46944
|
-
* detail-crop convention: hub-resident and neutral between the addons that
|
|
46945
|
-
* read it.
|
|
46946
|
-
*/
|
|
46947
|
-
var CLUSTER_MODEL_SECTION_ID = "cluster-step-models";
|
|
46948
|
-
/**
|
|
46949
|
-
* The settings key holding one step's cluster model choice.
|
|
46950
|
-
*
|
|
46951
|
-
* Step-qualified so the keys stay unique across the addon's whole schema, which
|
|
46952
|
-
* is what lets {@link pickClusterStepModels} walk every section instead of
|
|
46953
|
-
* trusting the section id.
|
|
46954
|
-
*/
|
|
46955
|
-
function clusterModelSettingKey(stepId) {
|
|
46956
|
-
return `clusterModel:${stepId}`;
|
|
46957
|
-
}
|
|
46958
|
-
Object.freeze(Object.fromEntries(CLUSTER_MODEL_SCOPED_STEPS.map((s) => [s.stepId, s.defaultModelId])));
|
|
46959
|
-
string().min(1);
|
|
46960
|
-
var CLUSTER_STEP_SETTING_FIELDS = [{
|
|
46961
|
-
stepId: "face-embedding",
|
|
46962
|
-
key: "minLandmarkFaceSize",
|
|
46963
|
-
label: "Min face size for recognition (detection px)",
|
|
46964
|
-
description: "Refuse to embed a face smaller than this IN THE DETECTION FRAME. Applies to every node — the enrolled gallery is one index, and two admission floors would fill it from two policies. 0 disables the gate.",
|
|
46965
|
-
type: "slider",
|
|
46966
|
-
min: 0,
|
|
46967
|
-
max: 64,
|
|
46968
|
-
step: 2,
|
|
46969
|
-
default: 24
|
|
46970
|
-
}];
|
|
46971
|
-
function clusterStepSettingKey(stepId, fieldKey) {
|
|
46972
|
-
return `clusterStepSetting:${stepId}:${fieldKey}`;
|
|
46973
|
-
}
|
|
46974
|
-
var ClusterSettingNumberSchema = number().finite();
|
|
46975
|
-
function readClusterStepSettings(config) {
|
|
46976
|
-
const out = {};
|
|
46977
|
-
for (const field of CLUSTER_STEP_SETTING_FIELDS) {
|
|
46978
|
-
const parsed = ClusterSettingNumberSchema.safeParse(config[clusterStepSettingKey(field.stepId, field.key)]);
|
|
46979
|
-
const value = parsed.success ? parsed.data : field.default;
|
|
46980
|
-
const existing = out[field.stepId] ?? {};
|
|
46981
|
-
out[field.stepId] = {
|
|
46982
|
-
...existing,
|
|
46983
|
-
[field.key]: value
|
|
46984
|
-
};
|
|
46985
|
-
}
|
|
46986
|
-
return out;
|
|
46987
|
-
}
|
|
46988
|
-
readClusterStepSettings({});
|
|
46989
|
-
/**
|
|
46990
47482
|
* THE detail-crop convention — the single derivation of the rectangle a
|
|
46991
47483
|
* detail/enrichment step (clip-embedding, face-detection, plate-detection…)
|
|
46992
47484
|
* is fed.
|
|
@@ -47330,6 +47822,63 @@ function deviceBackendToFormat(backend) {
|
|
|
47330
47822
|
return DEVICE_BACKEND_TO_FORMAT[backend] ?? "onnx";
|
|
47331
47823
|
}
|
|
47332
47824
|
//#endregion
|
|
47825
|
+
//#region src/cluster-model-choice.ts
|
|
47826
|
+
/**
|
|
47827
|
+
* The owner's answer to "which model did the operator choose for this
|
|
47828
|
+
* cluster-scoped step?" (D651 fix round 3).
|
|
47829
|
+
*
|
|
47830
|
+
* The orchestrator owns `clusterModel:*`: it renders the form and stores the
|
|
47831
|
+
* row. A consumer that REWRITES an index on this answer (the face re-embed,
|
|
47832
|
+
* the CLIP rebuild) must not read the orchestrator's private store layout,
|
|
47833
|
+
* and must not read its hydrated settings view either — that answers
|
|
47834
|
+
* `value ?? default`, so a default nobody chose and a failed read both look
|
|
47835
|
+
* like a choice. So the owner answers, from a three-way read of its own store.
|
|
47836
|
+
*
|
|
47837
|
+
* The stored VALUE is read with the runtime's own interpretation
|
|
47838
|
+
* (`interpretStoredClusterModel`, fix round 4): a cleared field (`null`, `''`)
|
|
47839
|
+
* is absent, any non-empty string is the choice — verbatim, as the runner pins
|
|
47840
|
+
* it — and only a non-string is `invalid-stored`. Checking the id against the
|
|
47841
|
+
* dropdown here would give one stored key two meanings: the runner deliberately
|
|
47842
|
+
* pins an id the catalog does not list (a custom encoder), so an owner that
|
|
47843
|
+
* called it invalid would refuse a pass on the very model the fleet is running.
|
|
47844
|
+
*/
|
|
47845
|
+
/**
|
|
47846
|
+
* Turn a three-way read of `clusterModel:<stepId>` into the cap answer.
|
|
47847
|
+
* `'owner-not-ready'` is the caller's statement that its boot has not settled.
|
|
47848
|
+
*/
|
|
47849
|
+
function decideClusterModelChoice(stepId, read) {
|
|
47850
|
+
const step = CLUSTER_MODEL_SCOPED_STEPS.find((s) => s.stepId === stepId);
|
|
47851
|
+
if (step === void 0) return {
|
|
47852
|
+
kind: "unreadable",
|
|
47853
|
+
reason: "not-cluster-scoped"
|
|
47854
|
+
};
|
|
47855
|
+
if (read === "owner-not-ready") return {
|
|
47856
|
+
kind: "unreadable",
|
|
47857
|
+
reason: "owner-not-ready"
|
|
47858
|
+
};
|
|
47859
|
+
if (read.kind === "unreadable") return read.error !== void 0 ? {
|
|
47860
|
+
kind: "unreadable",
|
|
47861
|
+
reason: read.reason,
|
|
47862
|
+
error: read.error
|
|
47863
|
+
} : {
|
|
47864
|
+
kind: "unreadable",
|
|
47865
|
+
reason: read.reason
|
|
47866
|
+
};
|
|
47867
|
+
const stored = interpretStoredClusterModel(read.kind === "absent" ? void 0 : read.value);
|
|
47868
|
+
if (stored.kind === "absent") return {
|
|
47869
|
+
kind: "absent",
|
|
47870
|
+
defaultModelId: step.defaultModelId
|
|
47871
|
+
};
|
|
47872
|
+
if (stored.kind === "chosen") return {
|
|
47873
|
+
kind: "stored",
|
|
47874
|
+
modelId: stored.modelId
|
|
47875
|
+
};
|
|
47876
|
+
return {
|
|
47877
|
+
kind: "unreadable",
|
|
47878
|
+
reason: "invalid-stored"
|
|
47879
|
+
};
|
|
47880
|
+
}
|
|
47881
|
+
//#endregion
|
|
47333
47882
|
//#region src/audio-load-balancer.ts
|
|
47334
47883
|
function balanceAudio(input) {
|
|
47335
47884
|
if (input.nodes.length === 0) return null;
|
|
@@ -48238,6 +48787,354 @@ function createDiskReconcileJobRunner(now = Date.now) {
|
|
|
48238
48787
|
};
|
|
48239
48788
|
}
|
|
48240
48789
|
//#endregion
|
|
48790
|
+
//#region src/pipeline-resolver.ts
|
|
48791
|
+
/**
|
|
48792
|
+
* The `package-detection` root step id. Zone-driven activation
|
|
48793
|
+
* ({@link applyPackageZoneActivation}) is keyed on it — the step has NO
|
|
48794
|
+
* user-facing toggle; a drawn package zone is the sole enable signal.
|
|
48795
|
+
*/
|
|
48796
|
+
var PACKAGE_DETECTION_STEP_ID = "package-detection";
|
|
48797
|
+
/**
|
|
48798
|
+
* The CAMERA ROOT: the always-on object-detection step every session's tracks
|
|
48799
|
+
* originate from. The ONE definition behind two gates that must agree (D650
|
|
48800
|
+
* round 4): the device election's root guard (`makeRootCapabilityGuard`)
|
|
48801
|
+
* excludes a device whose format cannot run it, and the attach backstop
|
|
48802
|
+
* (`ResolvedPipeline.droppedRoots`) refuses an attach that lost it. Any other
|
|
48803
|
+
* gated-on root the elected device cannot run (`segmentation`, an opt-in
|
|
48804
|
+
* refiner) is a logged skip, never a refusal: the election does not look at
|
|
48805
|
+
* it, so refusing on it left a camera pending forever.
|
|
48806
|
+
*
|
|
48807
|
+
* Mirrors the addon-pipeline step id — a cross-addon import is forbidden.
|
|
48808
|
+
*/
|
|
48809
|
+
var OBJECT_DETECTION_STEP_ID = "object-detection";
|
|
48810
|
+
/**
|
|
48811
|
+
* Zone-driven activation for the toggle-less `package-detection` root step.
|
|
48812
|
+
*
|
|
48813
|
+
* The step runs for a device IFF it has ≥1 package zone (an enabled
|
|
48814
|
+
* `package`-stage zone rule referencing geometry). There is deliberately NO
|
|
48815
|
+
* operator toggle — drawing a package zone IS the enable, removing every
|
|
48816
|
+
* package zone IS the disable. This gate reconciles the resolved step tree to
|
|
48817
|
+
* that rule:
|
|
48818
|
+
*
|
|
48819
|
+
* - `hasPackageZone` true → the step is ensured present as an ENABLED root
|
|
48820
|
+
* (a pre-existing node keeps its `modelId` / `settings` — e.g. a per-node
|
|
48821
|
+
* model choice); default-on where a zone is present.
|
|
48822
|
+
* - `hasPackageZone` false → any `package-detection` node is stripped so the
|
|
48823
|
+
* runner never dispatches it.
|
|
48824
|
+
*
|
|
48825
|
+
* `package-detection` is a ROOT (`inputClasses: null`) so it only ever appears
|
|
48826
|
+
* at the top level — no recursion into children needed. Every other step is
|
|
48827
|
+
* left untouched. Immutable: returns a fresh array only when a change applies.
|
|
48828
|
+
*/
|
|
48829
|
+
function applyPackageZoneActivation(steps, hasPackageZone) {
|
|
48830
|
+
const existing = steps.find((s) => s.addonId === PACKAGE_DETECTION_STEP_ID);
|
|
48831
|
+
if (!hasPackageZone) {
|
|
48832
|
+
if (!existing) return steps;
|
|
48833
|
+
return steps.filter((s) => s.addonId !== PACKAGE_DETECTION_STEP_ID);
|
|
48834
|
+
}
|
|
48835
|
+
if (existing) {
|
|
48836
|
+
if (existing.enabled === true) return steps;
|
|
48837
|
+
return steps.map((s) => s.addonId === "package-detection" ? {
|
|
48838
|
+
...s,
|
|
48839
|
+
enabled: true
|
|
48840
|
+
} : s);
|
|
48841
|
+
}
|
|
48842
|
+
const node = {
|
|
48843
|
+
addonId: PACKAGE_DETECTION_STEP_ID,
|
|
48844
|
+
enabled: true,
|
|
48845
|
+
children: []
|
|
48846
|
+
};
|
|
48847
|
+
return [...steps, node];
|
|
48848
|
+
}
|
|
48849
|
+
/**
|
|
48850
|
+
* Steps that are OFF by the per-camera gate DEFAULT (i.e. when the camera's
|
|
48851
|
+
* `stepToggles[addonId]` is unset). Mirrors each step's catalog
|
|
48852
|
+
* `enabledByDefault: false` — the heavy / opt-in classifiers + segmentation.
|
|
48853
|
+
*
|
|
48854
|
+
* The pure resolver derives the default gate from the LIVE catalog
|
|
48855
|
+
* (`addon.enabledByDefault ?? true`), so it stays correct if a future
|
|
48856
|
+
* default-off step ships. This literal set is the catalog-FREE fallback used
|
|
48857
|
+
* by the one-shot gating migration ({@link migrateStepGating}), which runs at
|
|
48858
|
+
* boot with no guaranteed live executor catalog. The two agree for every
|
|
48859
|
+
* step that exists today.
|
|
48860
|
+
*
|
|
48861
|
+
* `package-detection` is deliberately NOT listed — it is ZONE-driven
|
|
48862
|
+
* ({@link applyPackageZoneActivation}) and never governed by the gate.
|
|
48863
|
+
*/
|
|
48864
|
+
var DEFAULT_OFF_STEP_IDS = new Set([
|
|
48865
|
+
"animal-classifier",
|
|
48866
|
+
"bird-classifier",
|
|
48867
|
+
"vehicle-classifier",
|
|
48868
|
+
"segmentation"
|
|
48869
|
+
]);
|
|
48870
|
+
/**
|
|
48871
|
+
* Catalog-free default gate for `addonId` — `true` unless the step is in
|
|
48872
|
+
* {@link DEFAULT_OFF_STEP_IDS}. Used by the migration (no live catalog); the
|
|
48873
|
+
* resolver prefers the catalog's own `enabledByDefault ?? true`.
|
|
48874
|
+
*/
|
|
48875
|
+
function defaultStepGate(addonId) {
|
|
48876
|
+
return !DEFAULT_OFF_STEP_IDS.has(addonId);
|
|
48877
|
+
}
|
|
48878
|
+
/**
|
|
48879
|
+
* PROVISIONING presence for `(addon, format)` — the node "provides" the step
|
|
48880
|
+
* iff the catalog lists at least one model with a build for the node's engine
|
|
48881
|
+
* format. Byte-for-byte the same test the admin-ui matrices use
|
|
48882
|
+
* (`addon.models.some(m => Boolean(m.formats[fmt]))`). An absent `modelId`
|
|
48883
|
+
* on the resolved step is still "Auto" (the node resolves its own format
|
|
48884
|
+
* default) — provisioning does NOT require an operator-pinned model, only a
|
|
48885
|
+
* compatible model to EXIST for the format.
|
|
48886
|
+
*/
|
|
48887
|
+
function addonHasCompatibleModel(addon, format) {
|
|
48888
|
+
return (addon.models ?? []).some((m) => Boolean(m.formats[format]));
|
|
48889
|
+
}
|
|
48890
|
+
/**
|
|
48891
|
+
* A ROOT runs on the frame plane of the ELECTED device and is never jumped
|
|
48892
|
+
* (D650). Roots are the video steps with no `inputClasses`.
|
|
48893
|
+
*/
|
|
48894
|
+
function isVideoRoot(addon) {
|
|
48895
|
+
return (addon.inputClasses ?? []).length === 0;
|
|
48896
|
+
}
|
|
48897
|
+
/**
|
|
48898
|
+
* The per-DEVICE provisioning gate (D650). `null` = keep the step.
|
|
48899
|
+
*
|
|
48900
|
+
* - `audio-classifier` is NOT gated here at all. It never runs on an
|
|
48901
|
+
* inference pool — the audio-analyzer runs its own backend (YAMNet in an
|
|
48902
|
+
* embedded-Python subprocess, or Apple SoundAnalysis) — so an inference
|
|
48903
|
+
* format says nothing about whether it can run. Only enablement applies.
|
|
48904
|
+
* - A video ROOT needs a build for the ELECTED device's format — the runner
|
|
48905
|
+
* never jumps it, so a sibling accelerator that could run it is irrelevant.
|
|
48906
|
+
* - A detail step is kept when the elected format OR any format of the
|
|
48907
|
+
* node's jump roster (`jumpFormats` — the SAME enabled∧available devices
|
|
48908
|
+
* the runner jumps within) has a build: the runner's step-tree jump
|
|
48909
|
+
* (`pipeline-runner/device-jump.ts`) then places it.
|
|
48910
|
+
*/
|
|
48911
|
+
function unprovisionedReason(addon, electedFormat, jumpFormats) {
|
|
48912
|
+
if (addon.slot === "audio-classifier") return null;
|
|
48913
|
+
if (addonHasCompatibleModel(addon, electedFormat)) return null;
|
|
48914
|
+
if (isVideoRoot(addon)) return jumpFormats.some((f) => addonHasCompatibleModel(addon, f)) ? "root-not-runnable-on-elected-device" : "no-compatible-model";
|
|
48915
|
+
return jumpFormats.some((f) => addonHasCompatibleModel(addon, f)) ? null : "no-compatible-model";
|
|
48916
|
+
}
|
|
48917
|
+
/**
|
|
48918
|
+
* Device-aware resolver — the SINGLE per-camera gate + per-DEVICE skip check.
|
|
48919
|
+
*
|
|
48920
|
+
* Step `S` runs for camera `C` on device `D` ⇔
|
|
48921
|
+
* gate(C, S) — `stepToggles[S]` ?? `addon.enabledByDefault ?? true`
|
|
48922
|
+
* AND provided(D, S) — the catalog lists a model with a build for D's FORMAT
|
|
48923
|
+
* (a ROOT), or for D's format or any `jumpFormats`
|
|
48924
|
+
* entry (a detail step — the runner jumps it, D650);
|
|
48925
|
+
* `audio-classifier` is never format-gated (it runs
|
|
48926
|
+
* on the audio-analyzer's own backend)
|
|
48927
|
+
*
|
|
48928
|
+
* Enablement is the per-camera `stepToggles` authority ONLY. Provisioning
|
|
48929
|
+
* (`modelId` / `settings`) is NOT applied here — the returned tree carries bare
|
|
48930
|
+
* `{ addonId, enabled: true, children }` nodes; {@link applyDeviceProvisioning}
|
|
48931
|
+
* attaches model + settings at dispatch from the per-(node,device) base + the
|
|
48932
|
+
* per-camera-device override.
|
|
48933
|
+
*
|
|
48934
|
+
* A step gated ON that no device it may run on can serve is DROPPED and
|
|
48935
|
+
* recorded in `skipped` so the caller can surface a visible "gated on, no model
|
|
48936
|
+
* on this node" status at dispatch time.
|
|
48937
|
+
*
|
|
48938
|
+
* `package-detection` is excluded here — it is zone-driven
|
|
48939
|
+
* ({@link applyPackageZoneActivation}, applied by the caller).
|
|
48940
|
+
*
|
|
48941
|
+
* "Inference disabled for this camera" is NOT handled here — the caller
|
|
48942
|
+
* (resolvePipelineForDevice) consults the detection-pipeline binding and
|
|
48943
|
+
* short-circuits to an empty pipeline before invoking this resolver.
|
|
48944
|
+
*/
|
|
48945
|
+
function resolvePipelineWithSkips(cameraSettings, agentNodeId, deviceFormat, catalog, jumpFormats = []) {
|
|
48946
|
+
const cam = cameraSettings ?? {};
|
|
48947
|
+
const wholesale = cam.pipelineByAgent?.[agentNodeId];
|
|
48948
|
+
if (wholesale) return {
|
|
48949
|
+
config: {
|
|
48950
|
+
steps: mergeEnabledByDefaultSteps(wholesale.steps, catalog),
|
|
48951
|
+
audio: wholesale.audio ? {
|
|
48952
|
+
modelId: wholesale.audio.modelId,
|
|
48953
|
+
enabled: wholesale.audio.enabled
|
|
48954
|
+
} : null
|
|
48955
|
+
},
|
|
48956
|
+
skipped: [],
|
|
48957
|
+
droppedRoots: []
|
|
48958
|
+
};
|
|
48959
|
+
const toggles = cam.stepToggles ?? {};
|
|
48960
|
+
const format = deviceFormat;
|
|
48961
|
+
const enabled = [];
|
|
48962
|
+
const skipped = [];
|
|
48963
|
+
const droppedRoots = [];
|
|
48964
|
+
for (const slot of catalog.slots) for (const addon of slot.addons) {
|
|
48965
|
+
const addonId = addon.id;
|
|
48966
|
+
if (addonId === "package-detection") continue;
|
|
48967
|
+
if (!(toggles[addonId] ?? addon.enabledByDefault ?? true)) continue;
|
|
48968
|
+
const reason = unprovisionedReason(addon, format, jumpFormats);
|
|
48969
|
+
if (reason !== null) {
|
|
48970
|
+
skipped.push({
|
|
48971
|
+
addonId,
|
|
48972
|
+
reason
|
|
48973
|
+
});
|
|
48974
|
+
if (addonId === "object-detection") droppedRoots.push(addonId);
|
|
48975
|
+
continue;
|
|
48976
|
+
}
|
|
48977
|
+
enabled.push({ addonId });
|
|
48978
|
+
}
|
|
48979
|
+
const audioAddons = [];
|
|
48980
|
+
const videoAddons = [];
|
|
48981
|
+
for (const entry of enabled) {
|
|
48982
|
+
const meta = findInCatalog(catalog, entry.addonId);
|
|
48983
|
+
if (!meta) continue;
|
|
48984
|
+
if (meta.slot === "audio-classifier") audioAddons.push(entry);
|
|
48985
|
+
else videoAddons.push(entry);
|
|
48986
|
+
}
|
|
48987
|
+
return {
|
|
48988
|
+
config: {
|
|
48989
|
+
steps: buildTreeFromAddons(videoAddons, catalog),
|
|
48990
|
+
audio: audioAddons.length > 0 ? { enabled: true } : null
|
|
48991
|
+
},
|
|
48992
|
+
skipped,
|
|
48993
|
+
droppedRoots
|
|
48994
|
+
};
|
|
48995
|
+
}
|
|
48996
|
+
function findInCatalog(catalog, addonId) {
|
|
48997
|
+
for (const slot of catalog.slots) for (const addon of slot.addons) if (addon.id === addonId) return addon;
|
|
48998
|
+
return null;
|
|
48999
|
+
}
|
|
49000
|
+
/**
|
|
49001
|
+
* Compose a step tree from a flat list of enabled video addons using
|
|
49002
|
+
* catalog compatibility: an addon whose `inputClasses` overlaps a
|
|
49003
|
+
* candidate parent's `outputClasses` attaches as the parent's child.
|
|
49004
|
+
* Addons with `inputClasses` null/empty become roots.
|
|
49005
|
+
*
|
|
49006
|
+
* Multiple addons in the same slot with compatible inputClasses run in
|
|
49007
|
+
* parallel — they attach to the same parent as siblings. Example: two
|
|
49008
|
+
* classifiers (bird-classifier + animal-classifier) both accept 'animal'
|
|
49009
|
+
* → both attach to object-detection as siblings, both receive every
|
|
49010
|
+
* animal crop.
|
|
49011
|
+
*
|
|
49012
|
+
* Unparented leaves (inputClasses set, but no matching parent in the
|
|
49013
|
+
* enabled set) are DROPPED. The runner would have nothing to feed them
|
|
49014
|
+
* anyway — better silent drop than dispatching a dead step.
|
|
49015
|
+
*/
|
|
49016
|
+
function buildTreeFromAddons(enabled, catalog) {
|
|
49017
|
+
const bySlot = /* @__PURE__ */ new Map();
|
|
49018
|
+
for (const slot of catalog.slots) bySlot.set(slot.id, slot);
|
|
49019
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
49020
|
+
const metaById = /* @__PURE__ */ new Map();
|
|
49021
|
+
for (const { addonId } of enabled) {
|
|
49022
|
+
const meta = findInCatalog(catalog, addonId);
|
|
49023
|
+
if (!meta) continue;
|
|
49024
|
+
metaById.set(addonId, meta);
|
|
49025
|
+
nodes.set(addonId, {
|
|
49026
|
+
addonId,
|
|
49027
|
+
enabled: true,
|
|
49028
|
+
children: []
|
|
49029
|
+
});
|
|
49030
|
+
}
|
|
49031
|
+
const roots = [];
|
|
49032
|
+
for (const [addonId, node] of nodes) {
|
|
49033
|
+
const inputs = metaById.get(addonId).inputClasses;
|
|
49034
|
+
if (!inputs || inputs.length === 0) {
|
|
49035
|
+
roots.push(node);
|
|
49036
|
+
continue;
|
|
49037
|
+
}
|
|
49038
|
+
let parent = null;
|
|
49039
|
+
for (const [candidateId, candidateNode] of nodes) {
|
|
49040
|
+
if (candidateId === addonId) continue;
|
|
49041
|
+
if (metaById.get(candidateId).outputClasses.some((c) => inputs.includes(c))) {
|
|
49042
|
+
parent = candidateNode;
|
|
49043
|
+
break;
|
|
49044
|
+
}
|
|
49045
|
+
}
|
|
49046
|
+
if (parent) parent.children.push(node);
|
|
49047
|
+
}
|
|
49048
|
+
return roots;
|
|
49049
|
+
}
|
|
49050
|
+
/**
|
|
49051
|
+
* Back-fill catalog steps EXPLICITLY flagged `backfillIntoExistingOverrides`
|
|
49052
|
+
* but ENTIRELY ABSENT from an existing step tree — reconciles a stored
|
|
49053
|
+
* wholesale `pipelineByAgent` snapshot with genuinely-new default steps.
|
|
49054
|
+
*
|
|
49055
|
+
* Why it's needed: a `pipelineByAgent` wholesale override SNAPSHOTS the full
|
|
49056
|
+
* tree. A step introduced AFTER the snapshot (e.g. `clip-embedding` for object
|
|
49057
|
+
* semantic search) would otherwise never reach that camera.
|
|
49058
|
+
*
|
|
49059
|
+
* Why the flag (not `enabledByDefault`): absence-from-a-snapshot is ambiguous —
|
|
49060
|
+
* it can mean "predates this step" OR "operator deliberately pruned it". A
|
|
49061
|
+
* broad "back-fill every default-on step" would resurrect deliberate removals
|
|
49062
|
+
* of long-existing steps (face-detection, etc.), an operator-intent
|
|
49063
|
+
* regression. Only a step that opts in via `backfillIntoExistingOverrides`
|
|
49064
|
+
* (set on genuinely-new default steps ONLY) re-enters existing overrides.
|
|
49065
|
+
*
|
|
49066
|
+
* Non-destructive by design:
|
|
49067
|
+
* - Only steps whose id is absent from the WHOLE tree are added. A step the
|
|
49068
|
+
* operator has already seen — present anywhere, even `enabled: false` —
|
|
49069
|
+
* is left exactly as-is (its enabled flag is honoured).
|
|
49070
|
+
* - Only `backfillIntoExistingOverrides === true` addons are candidates.
|
|
49071
|
+
* Every other step (including long-standing default-on members) is left
|
|
49072
|
+
* untouched, so a deliberately-pruned override stays pruned.
|
|
49073
|
+
* - Audio-classifier addons are skipped — audio lives in
|
|
49074
|
+
* `CameraPipelineConfig.audio`, never in the video `steps` tree.
|
|
49075
|
+
* - A candidate attaches by catalog compat (its `inputClasses` overlap a
|
|
49076
|
+
* present node's `outputClasses`), mirroring {@link buildTreeFromAddons}.
|
|
49077
|
+
* A candidate with non-empty `inputClasses` and NO compatible parent is
|
|
49078
|
+
* dropped (never re-rooted) so a crop-scoped embedder never regresses to
|
|
49079
|
+
* running on the full frame. Empty `inputClasses` ⇒ a genuine root step.
|
|
49080
|
+
*/
|
|
49081
|
+
function mergeEnabledByDefaultSteps(steps, catalog) {
|
|
49082
|
+
if (steps.length === 0) return steps;
|
|
49083
|
+
const present = /* @__PURE__ */ new Set();
|
|
49084
|
+
const collect = (nodes) => {
|
|
49085
|
+
for (const n of nodes) {
|
|
49086
|
+
present.add(n.addonId);
|
|
49087
|
+
if (n.children?.length) collect(n.children);
|
|
49088
|
+
}
|
|
49089
|
+
};
|
|
49090
|
+
collect(steps);
|
|
49091
|
+
const catalogById = /* @__PURE__ */ new Map();
|
|
49092
|
+
for (const slot of catalog.slots) for (const addon of slot.addons) catalogById.set(addon.id, addon);
|
|
49093
|
+
const newRoots = [];
|
|
49094
|
+
const childrenByParent = /* @__PURE__ */ new Map();
|
|
49095
|
+
for (const slot of catalog.slots) {
|
|
49096
|
+
if (slot.id === "audio-classifier") continue;
|
|
49097
|
+
for (const addon of slot.addons) {
|
|
49098
|
+
if (addon.backfillIntoExistingOverrides !== true) continue;
|
|
49099
|
+
if (present.has(addon.id)) continue;
|
|
49100
|
+
const node = {
|
|
49101
|
+
addonId: addon.id,
|
|
49102
|
+
enabled: true,
|
|
49103
|
+
children: []
|
|
49104
|
+
};
|
|
49105
|
+
const inputs = addon.inputClasses;
|
|
49106
|
+
if (!inputs || inputs.length === 0) {
|
|
49107
|
+
newRoots.push(node);
|
|
49108
|
+
continue;
|
|
49109
|
+
}
|
|
49110
|
+
let parentId = null;
|
|
49111
|
+
for (const presentId of present) {
|
|
49112
|
+
const meta = catalogById.get(presentId);
|
|
49113
|
+
if (!meta) continue;
|
|
49114
|
+
if (meta.outputClasses.some((c) => inputs.includes(c))) {
|
|
49115
|
+
parentId = presentId;
|
|
49116
|
+
break;
|
|
49117
|
+
}
|
|
49118
|
+
}
|
|
49119
|
+
if (!parentId) continue;
|
|
49120
|
+
const list = childrenByParent.get(parentId) ?? [];
|
|
49121
|
+
list.push(node);
|
|
49122
|
+
childrenByParent.set(parentId, list);
|
|
49123
|
+
}
|
|
49124
|
+
}
|
|
49125
|
+
if (childrenByParent.size === 0 && newRoots.length === 0) return steps;
|
|
49126
|
+
const attach = (nodes) => nodes.map((n) => {
|
|
49127
|
+
const rebuiltChildren = attach(n.children ?? []);
|
|
49128
|
+
const extra = childrenByParent.get(n.addonId) ?? [];
|
|
49129
|
+
const mergedChildren = [...rebuiltChildren, ...extra];
|
|
49130
|
+
return mergedChildren.length > 0 ? {
|
|
49131
|
+
...n,
|
|
49132
|
+
children: mergedChildren
|
|
49133
|
+
} : n;
|
|
49134
|
+
});
|
|
49135
|
+
return [...attach(steps), ...newRoots];
|
|
49136
|
+
}
|
|
49137
|
+
//#endregion
|
|
48241
49138
|
//#region src/inference-device-model.ts
|
|
48242
49139
|
/**
|
|
48243
49140
|
* Per-device default object-detection model + deviceKey parsing for the
|
|
@@ -48263,20 +49160,14 @@ function createDiskReconcileJobRunner(now = Date.now) {
|
|
|
48263
49160
|
* gates (0/3 miss recovered at the current threshold).
|
|
48264
49161
|
*/
|
|
48265
49162
|
/**
|
|
48266
|
-
* The always-on object-detection ROOT step id. A camera session's tracks all
|
|
48267
|
-
* originate from this detector, so a device whose engine format can't run it
|
|
48268
|
-
* cannot host a camera root. Mirrors the addon-pipeline step id (cross-addon
|
|
48269
|
-
* import is forbidden — this is the same duplication rationale as the model
|
|
48270
|
-
* defaults above).
|
|
48271
|
-
*/
|
|
48272
|
-
var OBJECT_DETECTION_STEP_ID = "object-detection";
|
|
48273
|
-
/**
|
|
48274
49163
|
* Build the camera-root capability predicate for a node from its live catalog:
|
|
48275
49164
|
* `format → canHostCameraRoot`. A format can host a camera root iff the
|
|
48276
49165
|
* catalog lists at least one object-detection model with a build for that
|
|
48277
49166
|
* format — byte-for-byte the resolver's per-device skip-gate test for the root
|
|
48278
49167
|
* step (`addonHasCompatibleModel`), so a device is deemed eligible iff the root
|
|
48279
|
-
* would ACTUALLY provision on it.
|
|
49168
|
+
* would ACTUALLY provision on it. The root is {@link OBJECT_DETECTION_STEP_ID},
|
|
49169
|
+
* shared with the resolver's refusal backstop (`droppedRoots`) so the two gates
|
|
49170
|
+
* judge the same step (D650 round 4).
|
|
48280
49171
|
*
|
|
48281
49172
|
* Fails OPEN when the catalog has no object-detection slot at all (never
|
|
48282
49173
|
* observed in production) so a malformed/empty catalog never strands every
|
|
@@ -49796,16 +50687,18 @@ var CameraStatusService = class {
|
|
|
49796
50687
|
const audioAssignment = this.deps.getAudioAssignment(deviceId);
|
|
49797
50688
|
const audioNodeId = audioAssignment?.nodeId ?? null;
|
|
49798
50689
|
const audioPinned = audioAssignment?.pinned ?? false;
|
|
50690
|
+
const pinned = {
|
|
50691
|
+
detection: pipelineAssignment?.pinned ?? false,
|
|
50692
|
+
decoder: false,
|
|
50693
|
+
audio: audioPinned
|
|
50694
|
+
};
|
|
50695
|
+
const pendingReason = this.deps.getPendingReason(deviceId);
|
|
49799
50696
|
return {
|
|
49800
50697
|
pipelineAssignment,
|
|
49801
50698
|
detectionNodeId,
|
|
49802
50699
|
sourceNodeId,
|
|
49803
|
-
pinned
|
|
49804
|
-
|
|
49805
|
-
decoder: false,
|
|
49806
|
-
audio: audioPinned
|
|
49807
|
-
},
|
|
49808
|
-
detectionReason: pipelineAssignment !== null ? pipelineAssignment.reason : this.deps.hasCameraConfig(deviceId) ? `pending:${this.deps.getPendingReason(deviceId) ?? "pending"}` : this.deps.isSessionCamera(deviceId) ? "armed:on-motion" : void 0,
|
|
50700
|
+
pinned,
|
|
50701
|
+
detectionReason: pipelineAssignment !== null ? pipelineAssignment.reason : this.deps.hasCameraConfig(deviceId) ? `pending:${pendingReason ?? "pending"}` : this.deps.isSessionCamera(deviceId) ? pendingReason !== void 0 ? `pending:${pendingReason}` : "armed:on-motion" : void 0,
|
|
49809
50702
|
audioNodeId,
|
|
49810
50703
|
audioPinned
|
|
49811
50704
|
};
|
|
@@ -50661,48 +51554,6 @@ var DetectionBlindMonitor = class {
|
|
|
50661
51554
|
}
|
|
50662
51555
|
};
|
|
50663
51556
|
//#endregion
|
|
50664
|
-
//#region src/apply-device-provisioning.ts
|
|
50665
|
-
/**
|
|
50666
|
-
* Merge base then override for one step. Override keys win; undefined = inherit.
|
|
50667
|
-
* `jumpDeviceKey` (step-tree device jump, phase 1) is a per-(node,device) BASE
|
|
50668
|
-
* concept ONLY — the per-camera override never carries it (spec: per-camera jump
|
|
50669
|
-
* is out of scope), so it is taken from `base` verbatim.
|
|
50670
|
-
*/
|
|
50671
|
-
function resolveStep(base, override) {
|
|
50672
|
-
if (!base && !override) return void 0;
|
|
50673
|
-
const modelId = override?.modelId ?? base?.modelId;
|
|
50674
|
-
const settings = base?.settings || override?.settings ? {
|
|
50675
|
-
...base?.settings,
|
|
50676
|
-
...override?.settings
|
|
50677
|
-
} : void 0;
|
|
50678
|
-
const jumpDeviceKey = base?.jumpDeviceKey;
|
|
50679
|
-
return {
|
|
50680
|
-
...modelId !== void 0 ? { modelId } : {},
|
|
50681
|
-
...settings ? { settings } : {},
|
|
50682
|
-
...jumpDeviceKey !== void 0 ? { jumpDeviceKey } : {}
|
|
50683
|
-
};
|
|
50684
|
-
}
|
|
50685
|
-
/**
|
|
50686
|
-
* Attach modelId + settings onto each step from the per-(node,device) BASE and
|
|
50687
|
-
* the per-camera-device OVERRIDE. Pure/immutable; recurses into children. Empty
|
|
50688
|
-
* base+override ⇒ same reference.
|
|
50689
|
-
*/
|
|
50690
|
-
function applyDeviceProvisioning(steps, base, override) {
|
|
50691
|
-
if (Object.keys(base).length === 0 && Object.keys(override).length === 0) return steps;
|
|
50692
|
-
return steps.map((step) => {
|
|
50693
|
-
const cfg = resolveStep(base[step.addonId], override[step.addonId]);
|
|
50694
|
-
const children = step.children ? applyDeviceProvisioning(step.children, base, override) : step.children;
|
|
50695
|
-
if (!cfg && children === step.children) return step;
|
|
50696
|
-
return {
|
|
50697
|
-
...step,
|
|
50698
|
-
...cfg?.modelId !== void 0 ? { modelId: cfg.modelId } : {},
|
|
50699
|
-
...cfg?.settings ? { settings: cfg.settings } : {},
|
|
50700
|
-
...cfg?.jumpDeviceKey !== void 0 ? { jumpDeviceKey: cfg.jumpDeviceKey } : {},
|
|
50701
|
-
...children !== void 0 ? { children } : {}
|
|
50702
|
-
};
|
|
50703
|
-
});
|
|
50704
|
-
}
|
|
50705
|
-
//#endregion
|
|
50706
51557
|
//#region src/device-activity-source.ts
|
|
50707
51558
|
/** The source's own name on the wire. One constant, used by every emit + gate. */
|
|
50708
51559
|
var DEVICE_ACTIVITY_SOURCE = "device-activity";
|
|
@@ -51997,8 +52848,12 @@ var DetectionWiringController = class {
|
|
|
51997
52848
|
}
|
|
51998
52849
|
log.info("detection armed (session-time) — watching for motion", { meta: { motion: config.motionSources.join("+") } });
|
|
51999
52850
|
} else {
|
|
52000
|
-
const
|
|
52001
|
-
const
|
|
52851
|
+
const placeholderNode = this.deps.ledger.getAssignment(deviceId)?.agentNodeId ?? this.deps.localNodeId();
|
|
52852
|
+
const placeholder = await this.deps.settingsStore.resolvePipelineForDeviceIfCatalogCached(deviceId, placeholderNode, void 0, []);
|
|
52853
|
+
const pipelineConfig = placeholder.kind === "resolved" ? placeholder.config : {
|
|
52854
|
+
steps: [],
|
|
52855
|
+
audio: null
|
|
52856
|
+
};
|
|
52002
52857
|
const runnerConfig = buildRunnerConfig({
|
|
52003
52858
|
deviceId,
|
|
52004
52859
|
config,
|
|
@@ -52007,42 +52862,12 @@ var DetectionWiringController = class {
|
|
|
52007
52862
|
steps: pipelineConfig.steps,
|
|
52008
52863
|
audio: pipelineConfig.audio ?? null
|
|
52009
52864
|
});
|
|
52010
|
-
let dispatchedNodeId = null;
|
|
52011
52865
|
try {
|
|
52012
|
-
|
|
52013
|
-
if (result.kind === "assigned") dispatchedNodeId = result.agentNodeId;
|
|
52866
|
+
await this.deps.placement.dispatchCamera(runnerConfig);
|
|
52014
52867
|
} catch (err) {
|
|
52015
52868
|
const msg = errMsg$1(err);
|
|
52016
52869
|
log.error("dispatchCamera failed", { meta: { error: msg } });
|
|
52017
52870
|
}
|
|
52018
|
-
if (dispatchedNodeId && (dispatchedNodeId !== hydrateNodeId || (runnerConfig.steps?.length ?? 0) === 0)) try {
|
|
52019
|
-
const dispatchedDeviceKey = this.deps.ledger.getConfig(deviceId)?.deviceKey;
|
|
52020
|
-
const corrected = await this.deps.settingsStore.resolvePipelineForDevice(deviceId, dispatchedNodeId, dispatchedDeviceKey);
|
|
52021
|
-
const correctedBase = await this.deps.settingsStore.buildNodeInferenceDeviceSteps(dispatchedNodeId, dispatchedDeviceKey ?? "");
|
|
52022
|
-
const correctedOverride = dispatchedDeviceKey ? await this.deps.settingsStore.readCameraDeviceOverride(deviceId, dispatchedNodeId, dispatchedDeviceKey) : {};
|
|
52023
|
-
const correctedConfig = {
|
|
52024
|
-
...runnerConfig,
|
|
52025
|
-
...dispatchedDeviceKey ? { deviceKey: dispatchedDeviceKey } : {},
|
|
52026
|
-
steps: applyDeviceProvisioning(corrected.steps, correctedBase, correctedOverride),
|
|
52027
|
-
audio: corrected.audio ?? null
|
|
52028
|
-
};
|
|
52029
|
-
if (JSON.stringify({
|
|
52030
|
-
steps: runnerConfig.steps,
|
|
52031
|
-
audio: runnerConfig.audio ?? null
|
|
52032
|
-
}) !== JSON.stringify({
|
|
52033
|
-
steps: correctedConfig.steps,
|
|
52034
|
-
audio: correctedConfig.audio
|
|
52035
|
-
})) {
|
|
52036
|
-
await this.deps.attachOn(dispatchedNodeId, correctedConfig);
|
|
52037
|
-
this.deps.ledger.setConfig(deviceId, correctedConfig);
|
|
52038
|
-
log.info("startDetection: pipeline re-resolved after agent assignment", {
|
|
52039
|
-
tags: { nodeId: dispatchedNodeId },
|
|
52040
|
-
meta: { stepCount: corrected.steps.length }
|
|
52041
|
-
});
|
|
52042
|
-
}
|
|
52043
|
-
} catch (err) {
|
|
52044
|
-
log.warn("startDetection: post-dispatch re-resolve failed", { meta: { error: errMsg$1(err) } });
|
|
52045
|
-
}
|
|
52046
52871
|
}
|
|
52047
52872
|
try {
|
|
52048
52873
|
await this.deps.audio.resubscribe(deviceId, config);
|
|
@@ -52794,6 +53619,200 @@ async function migrateLegacyFlagsToBindings(deps) {
|
|
|
52794
53619
|
} });
|
|
52795
53620
|
}
|
|
52796
53621
|
//#endregion
|
|
53622
|
+
//#region src/apply-device-provisioning.ts
|
|
53623
|
+
/**
|
|
53624
|
+
* Merge base then override for one step. Override keys win; undefined = inherit.
|
|
53625
|
+
* `jumpDeviceKey` (step-tree device jump, phase 1) is a per-(node,device) BASE
|
|
53626
|
+
* concept ONLY — the per-camera override never carries it (spec: per-camera jump
|
|
53627
|
+
* is out of scope), so it is taken from `base` verbatim.
|
|
53628
|
+
*/
|
|
53629
|
+
function resolveStep(base, override) {
|
|
53630
|
+
if (!base && !override) return void 0;
|
|
53631
|
+
const modelId = override?.modelId ?? base?.modelId;
|
|
53632
|
+
const settings = base?.settings || override?.settings ? {
|
|
53633
|
+
...base?.settings,
|
|
53634
|
+
...override?.settings
|
|
53635
|
+
} : void 0;
|
|
53636
|
+
const jumpDeviceKey = base?.jumpDeviceKey;
|
|
53637
|
+
return {
|
|
53638
|
+
...modelId !== void 0 ? { modelId } : {},
|
|
53639
|
+
...settings ? { settings } : {},
|
|
53640
|
+
...jumpDeviceKey !== void 0 ? { jumpDeviceKey } : {}
|
|
53641
|
+
};
|
|
53642
|
+
}
|
|
53643
|
+
/**
|
|
53644
|
+
* Attach modelId + settings onto each step from the per-(node,device) BASE and
|
|
53645
|
+
* the per-camera-device OVERRIDE. Pure/immutable; recurses into children. Empty
|
|
53646
|
+
* base+override ⇒ same reference.
|
|
53647
|
+
*/
|
|
53648
|
+
function applyDeviceProvisioning(steps, base, override) {
|
|
53649
|
+
if (Object.keys(base).length === 0 && Object.keys(override).length === 0) return steps;
|
|
53650
|
+
return steps.map((step) => {
|
|
53651
|
+
const cfg = resolveStep(base[step.addonId], override[step.addonId]);
|
|
53652
|
+
const children = step.children ? applyDeviceProvisioning(step.children, base, override) : step.children;
|
|
53653
|
+
if (!cfg && children === step.children) return step;
|
|
53654
|
+
return {
|
|
53655
|
+
...step,
|
|
53656
|
+
...cfg?.modelId !== void 0 ? { modelId: cfg.modelId } : {},
|
|
53657
|
+
...cfg?.settings ? { settings: cfg.settings } : {},
|
|
53658
|
+
...cfg?.jumpDeviceKey !== void 0 ? { jumpDeviceKey: cfg.jumpDeviceKey } : {},
|
|
53659
|
+
...children !== void 0 ? { children } : {}
|
|
53660
|
+
};
|
|
53661
|
+
});
|
|
53662
|
+
}
|
|
53663
|
+
/**
|
|
53664
|
+
* What each step of `steps` would run with had the camera been elected onto
|
|
53665
|
+
* the device whose `base` / `override` these are — as a flat
|
|
53666
|
+
* `stepId → { modelId?, settings? }` map (D650). It IS
|
|
53667
|
+
* {@link applyDeviceProvisioning} over the same resolved tree, flattened, so a
|
|
53668
|
+
* jump target and the elected device are provisioned by one rule, and a value
|
|
53669
|
+
* the resolved tree already carries (a wholesale `pipelineByAgent` snapshot)
|
|
53670
|
+
* survives exactly as it would on the elected device. The runner REPLACES a
|
|
53671
|
+
* jumped subtree's `modelId` / `settings` with these, so the elected device's
|
|
53672
|
+
* base and override never follow a step across. `jumpDeviceKey` is dropped: it
|
|
53673
|
+
* names where a step leaves the ELECTED device for, and means nothing on the
|
|
53674
|
+
* target. A step with neither field is absent (it runs the format default).
|
|
53675
|
+
*/
|
|
53676
|
+
function provisionDeviceSteps(steps, base, override) {
|
|
53677
|
+
const out = {};
|
|
53678
|
+
const walk = (list) => {
|
|
53679
|
+
for (const step of list) {
|
|
53680
|
+
if (step.modelId !== void 0 || step.settings !== void 0) out[step.addonId] = {
|
|
53681
|
+
...step.modelId !== void 0 ? { modelId: step.modelId } : {},
|
|
53682
|
+
...step.settings !== void 0 ? { settings: step.settings } : {}
|
|
53683
|
+
};
|
|
53684
|
+
walk(step.children ?? []);
|
|
53685
|
+
}
|
|
53686
|
+
};
|
|
53687
|
+
walk(applyDeviceProvisioning(steps, base, override));
|
|
53688
|
+
return out;
|
|
53689
|
+
}
|
|
53690
|
+
//#endregion
|
|
53691
|
+
//#region src/jump-roster.ts
|
|
53692
|
+
/**
|
|
53693
|
+
* The roster (unprovisioned), or `undefined` when there is no jump to make: no
|
|
53694
|
+
* elected device, or fewer than two eligible devices on the node.
|
|
53695
|
+
*/
|
|
53696
|
+
function buildJumpRoster(input) {
|
|
53697
|
+
if (!input.deviceKey || Object.keys(input.eligible).length < 2) return void 0;
|
|
53698
|
+
return buildInferenceDeviceRoster(input.eligible, input.caps);
|
|
53699
|
+
}
|
|
53700
|
+
/** The roster devices a detail step may jump TO — every entry except the elected one. */
|
|
53701
|
+
function jumpTargetKeys(roster, electedDeviceKey) {
|
|
53702
|
+
return (roster ?? []).map((d) => d.deviceKey).filter((k) => k !== electedDeviceKey);
|
|
53703
|
+
}
|
|
53704
|
+
//#endregion
|
|
53705
|
+
//#region src/attach-pipeline.ts
|
|
53706
|
+
/**
|
|
53707
|
+
* The steps whose ELECTED-device base carries a manual `jumpDeviceKey` while
|
|
53708
|
+
* the camera has an override for that step on the elected device (D650). A
|
|
53709
|
+
* jumped step runs the TARGET's provisioning, so that override does not
|
|
53710
|
+
* travel with it — the operator must be told, not left to wonder.
|
|
53711
|
+
*/
|
|
53712
|
+
function manualJumpsLeavingOverride(base, override) {
|
|
53713
|
+
const out = [];
|
|
53714
|
+
for (const [stepId, cfg] of Object.entries(base)) {
|
|
53715
|
+
const jumpDeviceKey = cfg.jumpDeviceKey;
|
|
53716
|
+
if (jumpDeviceKey === void 0) continue;
|
|
53717
|
+
const own = override[stepId];
|
|
53718
|
+
if (own?.modelId === void 0 && own?.settings === void 0) continue;
|
|
53719
|
+
out.push({
|
|
53720
|
+
stepId,
|
|
53721
|
+
jumpDeviceKey
|
|
53722
|
+
});
|
|
53723
|
+
}
|
|
53724
|
+
return out;
|
|
53725
|
+
}
|
|
53726
|
+
/**
|
|
53727
|
+
* BLOCKING entry point: waits for the node's catalog (`resolvePipelineForDevice`
|
|
53728
|
+
* reconciles against the executor until it answers). For a FIRST placement —
|
|
53729
|
+
* a camera's first standing dispatch, an on-motion session, a re-attach —
|
|
53730
|
+
* where there is nothing better to attach than the real pipeline. See the
|
|
53731
|
+
* module header.
|
|
53732
|
+
*/
|
|
53733
|
+
async function resolveAttachPipeline(input, store) {
|
|
53734
|
+
const roster = buildJumpRoster({
|
|
53735
|
+
deviceKey: input.deviceKey,
|
|
53736
|
+
eligible: input.eligible,
|
|
53737
|
+
caps: input.caps
|
|
53738
|
+
});
|
|
53739
|
+
const resolved = await store.resolvePipelineForDeviceWithRoots(input.deviceId, input.nodeId, input.deviceKey, jumpTargetKeys(roster, input.deviceKey));
|
|
53740
|
+
if (resolved.droppedRoots.length > 0) return rootRefusal(input, resolved.droppedRoots);
|
|
53741
|
+
return {
|
|
53742
|
+
kind: "resolved",
|
|
53743
|
+
pipeline: await provisionAttachPipeline(input, roster, resolved.config, store)
|
|
53744
|
+
};
|
|
53745
|
+
}
|
|
53746
|
+
/**
|
|
53747
|
+
* NON-BLOCKING entry point: reads the node's catalog from the cache only, and
|
|
53748
|
+
* answers `catalog-unavailable` instead of waiting (D650 round 2). For the
|
|
53749
|
+
* RETRY and re-place paths — a pending retry, a remote-health re-place, a
|
|
53750
|
+
* rebalance migration — which run cameras serially in one sweep: one node
|
|
53751
|
+
* whose executor never answers must leave ITS camera pending, not hold every
|
|
53752
|
+
* camera behind it (D3, D49).
|
|
53753
|
+
*/
|
|
53754
|
+
async function resolveAttachPipelineIfCatalogCached(input, store) {
|
|
53755
|
+
const roster = buildJumpRoster({
|
|
53756
|
+
deviceKey: input.deviceKey,
|
|
53757
|
+
eligible: input.eligible,
|
|
53758
|
+
caps: input.caps
|
|
53759
|
+
});
|
|
53760
|
+
const outcome = await store.resolvePipelineForDeviceIfCatalogCached(input.deviceId, input.nodeId, input.deviceKey, jumpTargetKeys(roster, input.deviceKey));
|
|
53761
|
+
if (outcome.kind === "catalog-unavailable") return outcome;
|
|
53762
|
+
if (outcome.droppedRoots.length > 0) return rootRefusal(input, outcome.droppedRoots);
|
|
53763
|
+
return {
|
|
53764
|
+
kind: "resolved",
|
|
53765
|
+
pipeline: await provisionAttachPipeline(input, roster, outcome.config, store)
|
|
53766
|
+
};
|
|
53767
|
+
}
|
|
53768
|
+
function rootRefusal(input, droppedRoots) {
|
|
53769
|
+
return {
|
|
53770
|
+
kind: "root-refused",
|
|
53771
|
+
nodeId: input.nodeId,
|
|
53772
|
+
deviceKey: input.deviceKey,
|
|
53773
|
+
droppedRoots
|
|
53774
|
+
};
|
|
53775
|
+
}
|
|
53776
|
+
/** Steps 3 of the module header: provision the resolved structure. */
|
|
53777
|
+
async function provisionAttachPipeline(input, roster, resolved, store) {
|
|
53778
|
+
const { deviceId, nodeId, deviceKey } = input;
|
|
53779
|
+
const electedKey = deviceKey ?? "";
|
|
53780
|
+
const rosterKeys = (roster ?? []).map((d) => d.deviceKey);
|
|
53781
|
+
const sources = await store.readDeviceProvisioning(deviceId, nodeId, [...new Set([electedKey, ...rosterKeys])]);
|
|
53782
|
+
const elected = sources.get(electedKey);
|
|
53783
|
+
if (deviceKey && elected) {
|
|
53784
|
+
const left = manualJumpsLeavingOverride(elected.base, elected.override).filter((l) => rosterKeys.includes(l.jumpDeviceKey));
|
|
53785
|
+
if (left.length > 0) store.warnManualJumpsLeaveOverride(deviceId, nodeId, deviceKey, left);
|
|
53786
|
+
}
|
|
53787
|
+
const steps = applyDeviceProvisioning(resolved.steps, elected?.base ?? {}, elected?.override ?? {});
|
|
53788
|
+
const inferenceDevices = roster?.map((entry) => {
|
|
53789
|
+
const src = sources.get(entry.deviceKey);
|
|
53790
|
+
return {
|
|
53791
|
+
...entry,
|
|
53792
|
+
steps: provisionDeviceSteps(resolved.steps, src?.base ?? {}, src?.override ?? {})
|
|
53793
|
+
};
|
|
53794
|
+
});
|
|
53795
|
+
return {
|
|
53796
|
+
steps,
|
|
53797
|
+
audio: resolved.audio ?? null,
|
|
53798
|
+
...deviceKey ? { deviceKey } : {},
|
|
53799
|
+
...inferenceDevices ? { inferenceDevices } : {}
|
|
53800
|
+
};
|
|
53801
|
+
}
|
|
53802
|
+
/**
|
|
53803
|
+
* `config` with its pipeline REPLACED by `pipeline`. The four pipeline fields
|
|
53804
|
+
* are removed first, so a previous attach's `deviceKey` / roster never survives
|
|
53805
|
+
* a spread when the new pipeline has none (a migration to a single-device node,
|
|
53806
|
+
* a re-dispatch from a ledger config).
|
|
53807
|
+
*/
|
|
53808
|
+
function withAttachPipeline(config, pipeline) {
|
|
53809
|
+
const { deviceKey: _deviceKey, inferenceDevices: _inferenceDevices, steps: _steps, audio: _audio, ...rest } = config;
|
|
53810
|
+
return {
|
|
53811
|
+
...rest,
|
|
53812
|
+
...pipeline
|
|
53813
|
+
};
|
|
53814
|
+
}
|
|
53815
|
+
//#endregion
|
|
52797
53816
|
//#region src/dispatch-reconcile.ts
|
|
52798
53817
|
function runnerAttachmentKey(nodeId, deviceId) {
|
|
52799
53818
|
return `${nodeId}:${deviceId}`;
|
|
@@ -53084,6 +54103,32 @@ function selectRunnerFrameSource(input) {
|
|
|
53084
54103
|
//#region src/placement-service.ts
|
|
53085
54104
|
/** Key under which the orchestrator stores its per-device manual pin — shared with `readPipelinePin` (index.ts) and `handleNodeDisconnect` (`node-lifecycle-handler.ts`). */
|
|
53086
54105
|
var PREFERRED_AGENT_SETTING = "preferredAgent";
|
|
54106
|
+
/**
|
|
54107
|
+
* A load snapshot with this rebalance pass's own (re)assignments applied — at
|
|
54108
|
+
* node level, and per device so a device election in the same pass sees the
|
|
54109
|
+
* sessions the pass already gave each accelerator.
|
|
54110
|
+
*/
|
|
54111
|
+
function tallyLoad(load, nodeDelta, perDevice) {
|
|
54112
|
+
if (nodeDelta === 0 && (perDevice === void 0 || perDevice.size === 0)) return load;
|
|
54113
|
+
const known = new Set(load.devices.map((d) => d.deviceKey));
|
|
54114
|
+
const devices = [...load.devices.map((d) => {
|
|
54115
|
+
const delta = perDevice?.get(d.deviceKey) ?? 0;
|
|
54116
|
+
return delta === 0 ? d : {
|
|
54117
|
+
...d,
|
|
54118
|
+
attachedCameras: Math.max(0, d.attachedCameras + delta)
|
|
54119
|
+
};
|
|
54120
|
+
}), ...[...perDevice ?? /* @__PURE__ */ new Map()].filter(([deviceKey, delta]) => !known.has(deviceKey) && delta > 0).map(([deviceKey, delta]) => ({
|
|
54121
|
+
deviceKey,
|
|
54122
|
+
backend: deviceKey.split(":")[0] ?? deviceKey,
|
|
54123
|
+
attachedCameras: delta,
|
|
54124
|
+
queueDepthTotal: 0
|
|
54125
|
+
}))];
|
|
54126
|
+
return {
|
|
54127
|
+
...load,
|
|
54128
|
+
attachedCameras: Math.max(0, load.attachedCameras + nodeDelta),
|
|
54129
|
+
devices
|
|
54130
|
+
};
|
|
54131
|
+
}
|
|
53087
54132
|
var PlacementService = class {
|
|
53088
54133
|
deps;
|
|
53089
54134
|
/** True while an auto-rebalance pass is running (skip overlap). */
|
|
@@ -53121,7 +54166,116 @@ var PlacementService = class {
|
|
|
53121
54166
|
});
|
|
53122
54167
|
return usable;
|
|
53123
54168
|
}
|
|
54169
|
+
/**
|
|
54170
|
+
* Pick which of `nodeId`'s enabled inference devices a camera runs on, and
|
|
54171
|
+
* log why (`inference device selection` — one line per placement, the same
|
|
54172
|
+
* message as the on-motion path so one grep covers both). Returns the
|
|
54173
|
+
* eligibility inputs too: the attach pipeline is resolved against the SAME
|
|
54174
|
+
* roster the device was elected from.
|
|
54175
|
+
*/
|
|
54176
|
+
async electInferenceDevice(input) {
|
|
54177
|
+
const targetLoad = input.loads.find((load) => load.nodeId === input.nodeId);
|
|
54178
|
+
let eligible;
|
|
54179
|
+
if (input.read.kind === "await-catalog") eligible = await this.deps.getEligibleInferenceDevices(input.nodeId);
|
|
54180
|
+
else {
|
|
54181
|
+
const known = await this.deps.getEligibleInferenceDevicesIfCached(input.nodeId);
|
|
54182
|
+
if (known.kind === "unavailable") return known;
|
|
54183
|
+
eligible = known.eligible;
|
|
54184
|
+
}
|
|
54185
|
+
const caps = await this.deps.getInferenceDeviceCaps(input.nodeId);
|
|
54186
|
+
const pinnedDeviceKey = await this.deps.readPipelineDevicePin(input.deviceId);
|
|
54187
|
+
const selection = pickInferenceDevice({
|
|
54188
|
+
eligible,
|
|
54189
|
+
loads: targetLoad?.devices ?? [],
|
|
54190
|
+
caps,
|
|
54191
|
+
pinnedDeviceKey,
|
|
54192
|
+
currentDeviceKey: input.currentDeviceKey,
|
|
54193
|
+
rotation: this.deps.inferenceRotation
|
|
54194
|
+
});
|
|
54195
|
+
const deviceKey = selectedDeviceKey(selection);
|
|
54196
|
+
this.deps.ctx().logger.info("inference device selection", {
|
|
54197
|
+
tags: {
|
|
54198
|
+
deviceId: input.deviceId,
|
|
54199
|
+
nodeId: input.nodeId
|
|
54200
|
+
},
|
|
54201
|
+
meta: {
|
|
54202
|
+
path: input.path,
|
|
54203
|
+
eligible: selection.eligibleKeys,
|
|
54204
|
+
chosen: deviceKey ?? null,
|
|
54205
|
+
reason: selection.reason,
|
|
54206
|
+
...selection.kind === "selected" ? {
|
|
54207
|
+
tied: selection.tiedKeys,
|
|
54208
|
+
tieBreak: selection.tieBreak
|
|
54209
|
+
} : {},
|
|
54210
|
+
...pinnedDeviceKey ? { pin: pinnedDeviceKey } : {}
|
|
54211
|
+
}
|
|
54212
|
+
});
|
|
54213
|
+
return {
|
|
54214
|
+
kind: "elected",
|
|
54215
|
+
deviceKey,
|
|
54216
|
+
eligible,
|
|
54217
|
+
caps
|
|
54218
|
+
};
|
|
54219
|
+
}
|
|
54220
|
+
/**
|
|
54221
|
+
* Elect a device on `nodeId` and resolve the camera's attach pipeline for
|
|
54222
|
+
* it — the whole placement decision, for a first placement (`await-catalog`)
|
|
54223
|
+
* or a re-place (`cached-catalog`, never waits). Nothing is attached here.
|
|
54224
|
+
*/
|
|
54225
|
+
async resolvePlacement(input) {
|
|
54226
|
+
const election = await this.electInferenceDevice(input);
|
|
54227
|
+
if (election.kind === "unavailable") return election;
|
|
54228
|
+
const attachInput = {
|
|
54229
|
+
deviceId: input.deviceId,
|
|
54230
|
+
nodeId: input.nodeId,
|
|
54231
|
+
deviceKey: election.deviceKey,
|
|
54232
|
+
eligible: election.eligible,
|
|
54233
|
+
caps: election.caps
|
|
54234
|
+
};
|
|
54235
|
+
if (input.read.kind === "await-catalog") return resolveAttachPipeline(attachInput, this.deps.settingsStore);
|
|
54236
|
+
const outcome = await resolveAttachPipelineIfCatalogCached(attachInput, this.deps.settingsStore);
|
|
54237
|
+
if (outcome.kind === "catalog-unavailable") return {
|
|
54238
|
+
kind: "unavailable",
|
|
54239
|
+
nodeId: outcome.nodeId,
|
|
54240
|
+
what: "catalog"
|
|
54241
|
+
};
|
|
54242
|
+
return outcome;
|
|
54243
|
+
}
|
|
54244
|
+
/**
|
|
54245
|
+
* Re-resolve a camera's attach pipeline on the device it is ALREADY
|
|
54246
|
+
* elected onto (`deviceKey` from its ledger config; absent ⇒ default pool)
|
|
54247
|
+
* — the entry for every path that replaces the steps of a live attach
|
|
54248
|
+
* without re-electing (executor readiness, a package-zone change). Goes
|
|
54249
|
+
* through the same {@link resolveAttachPipeline} as a dispatch (D650).
|
|
54250
|
+
*/
|
|
54251
|
+
async resolveAttachPipelineOnDevice(input) {
|
|
54252
|
+
const [eligible, caps] = await Promise.all([this.deps.getEligibleInferenceDevices(input.nodeId), this.deps.getInferenceDeviceCaps(input.nodeId)]);
|
|
54253
|
+
return resolveAttachPipeline({
|
|
54254
|
+
...input,
|
|
54255
|
+
eligible,
|
|
54256
|
+
caps
|
|
54257
|
+
}, this.deps.settingsStore);
|
|
54258
|
+
}
|
|
54259
|
+
/**
|
|
54260
|
+
* FIRST placement of a standing camera: waits for the chosen node's catalog
|
|
54261
|
+
* (the resolve reconciles against its executor until it answers). Callers:
|
|
54262
|
+
* `startDetection`'s standing path — one camera, nothing to attach instead.
|
|
54263
|
+
*/
|
|
53124
54264
|
async dispatchCamera(runnerConfig) {
|
|
54265
|
+
return this.placeCamera(runnerConfig, { kind: "await-catalog" });
|
|
54266
|
+
}
|
|
54267
|
+
/**
|
|
54268
|
+
* RE-placement from the ledger — the pending-retry sweep and the
|
|
54269
|
+
* remote-health re-place (D650 round 2). NEVER waits for a node: the
|
|
54270
|
+
* catalog is read from the cache only, and a node that has not answered
|
|
54271
|
+
* leaves THIS camera pending (`catalog-unavailable:<nodeId>`) for the next
|
|
54272
|
+
* round, instead of holding every camera after it in a serial sweep behind
|
|
54273
|
+
* one wedged executor (D3, D49).
|
|
54274
|
+
*/
|
|
54275
|
+
async redispatchCamera(runnerConfig) {
|
|
54276
|
+
return this.placeCamera(runnerConfig, { kind: "cached-catalog" });
|
|
54277
|
+
}
|
|
54278
|
+
async placeCamera(runnerConfig, catalog) {
|
|
53125
54279
|
if (!this.deps.ctx()) throw new Error("PipelineOrchestrator: dispatchCamera called before initialize");
|
|
53126
54280
|
if (this.deps.maintenancePaused?.() === true) return {
|
|
53127
54281
|
success: true,
|
|
@@ -53174,45 +54328,30 @@ var PlacementService = class {
|
|
|
53174
54328
|
reason = decision.reason;
|
|
53175
54329
|
dispatchScore = decision.score;
|
|
53176
54330
|
}
|
|
53177
|
-
const
|
|
53178
|
-
|
|
53179
|
-
|
|
53180
|
-
|
|
53181
|
-
const selection = pickInferenceDevice({
|
|
53182
|
-
eligible: eligibleDevices,
|
|
53183
|
-
loads: targetLoad?.devices ?? [],
|
|
53184
|
-
caps: deviceCaps,
|
|
53185
|
-
pinnedDeviceKey,
|
|
54331
|
+
const placement = await this.resolvePlacement({
|
|
54332
|
+
deviceId: runnerConfig.deviceId,
|
|
54333
|
+
nodeId: targetNodeId,
|
|
54334
|
+
loads,
|
|
53186
54335
|
currentDeviceKey,
|
|
53187
|
-
|
|
54336
|
+
path: "standing",
|
|
54337
|
+
read: catalog
|
|
53188
54338
|
});
|
|
53189
|
-
|
|
53190
|
-
|
|
53191
|
-
|
|
53192
|
-
|
|
53193
|
-
|
|
53194
|
-
}
|
|
53195
|
-
|
|
53196
|
-
|
|
53197
|
-
|
|
53198
|
-
|
|
53199
|
-
|
|
53200
|
-
|
|
53201
|
-
|
|
53202
|
-
|
|
53203
|
-
|
|
53204
|
-
|
|
53205
|
-
}
|
|
53206
|
-
});
|
|
53207
|
-
const deviceBase = await this.deps.settingsStore.buildNodeInferenceDeviceSteps(targetNodeId, deviceKey ?? "");
|
|
53208
|
-
const deviceOverride = deviceKey ? await this.deps.settingsStore.readCameraDeviceOverride(runnerConfig.deviceId, targetNodeId, deviceKey) : {};
|
|
53209
|
-
const inferenceDevices = deviceKey && Object.keys(eligibleDevices).length >= 2 ? buildInferenceDeviceRoster(eligibleDevices, deviceCaps) : void 0;
|
|
53210
|
-
const attachConfig = {
|
|
53211
|
-
...runnerConfig,
|
|
53212
|
-
...deviceKey ? { deviceKey } : {},
|
|
53213
|
-
...inferenceDevices ? { inferenceDevices } : {},
|
|
53214
|
-
...runnerConfig.steps ? { steps: applyDeviceProvisioning(runnerConfig.steps, deviceBase, deviceOverride) } : {}
|
|
53215
|
-
};
|
|
54339
|
+
if (placement.kind === "unavailable") {
|
|
54340
|
+
this.leavePendingUnanswered(runnerConfig.deviceId, placement.nodeId, placement.what);
|
|
54341
|
+
return {
|
|
54342
|
+
success: true,
|
|
54343
|
+
kind: "pending"
|
|
54344
|
+
};
|
|
54345
|
+
}
|
|
54346
|
+
if (placement.kind === "root-refused") {
|
|
54347
|
+
this.leavePendingRootRefused(runnerConfig.deviceId, placement);
|
|
54348
|
+
return {
|
|
54349
|
+
success: true,
|
|
54350
|
+
kind: "pending"
|
|
54351
|
+
};
|
|
54352
|
+
}
|
|
54353
|
+
const pipeline = placement.pipeline;
|
|
54354
|
+
const attachConfig = withAttachPipeline(runnerConfig, pipeline);
|
|
53216
54355
|
const dispatchGuard = this.deps.ledger.markDispatching(runnerConfig.deviceId);
|
|
53217
54356
|
try {
|
|
53218
54357
|
await this.attachOn(targetNodeId, attachConfig);
|
|
@@ -53435,18 +54574,17 @@ var PlacementService = class {
|
|
|
53435
54574
|
const nodeCaps = await this.deps.settingsStore.buildNodeCaps();
|
|
53436
54575
|
const nodeWeights = await this.deps.settingsStore.buildNodeWeights();
|
|
53437
54576
|
const attachedDelta = /* @__PURE__ */ new Map();
|
|
53438
|
-
const
|
|
54577
|
+
const deviceDelta = /* @__PURE__ */ new Map();
|
|
54578
|
+
const bumpAttached = (nodeId, by, deviceKey) => {
|
|
53439
54579
|
attachedDelta.set(nodeId, (attachedDelta.get(nodeId) ?? 0) + by);
|
|
54580
|
+
if (deviceKey === void 0) return;
|
|
54581
|
+
const perDevice = deviceDelta.get(nodeId) ?? /* @__PURE__ */ new Map();
|
|
54582
|
+
perDevice.set(deviceKey, (perDevice.get(deviceKey) ?? 0) + by);
|
|
54583
|
+
deviceDelta.set(nodeId, perDevice);
|
|
53440
54584
|
};
|
|
53441
|
-
const talliedLoads = () => loads.map((load) =>
|
|
53442
|
-
const delta = attachedDelta.get(load.nodeId) ?? 0;
|
|
53443
|
-
if (delta === 0) return load;
|
|
53444
|
-
return {
|
|
53445
|
-
...load,
|
|
53446
|
-
attachedCameras: Math.max(0, load.attachedCameras + delta)
|
|
53447
|
-
};
|
|
53448
|
-
});
|
|
54585
|
+
const talliedLoads = () => loads.map((load) => tallyLoad(load, attachedDelta.get(load.nodeId) ?? 0, deviceDelta.get(load.nodeId)));
|
|
53449
54586
|
let migrated = 0;
|
|
54587
|
+
const skipped = [];
|
|
53450
54588
|
for (const [deviceId, config] of this.deps.ledger.configEntries()) {
|
|
53451
54589
|
if (this.deps.isIdleSessionCamera(deviceId)) continue;
|
|
53452
54590
|
const current = this.deps.ledger.getAssignment(deviceId);
|
|
@@ -53471,6 +54609,28 @@ var PlacementService = class {
|
|
|
53471
54609
|
const targetLoad = tallied.find((l) => l.nodeId === decision.agentNodeId);
|
|
53472
54610
|
if (currentLoad && targetLoad && capacityImprovement(currentLoad, targetLoad, nodeWeights) < minImprovement) continue;
|
|
53473
54611
|
}
|
|
54612
|
+
const placement = await this.resolvePlacement({
|
|
54613
|
+
deviceId,
|
|
54614
|
+
nodeId: decision.agentNodeId,
|
|
54615
|
+
loads: talliedLoads(),
|
|
54616
|
+
currentDeviceKey: null,
|
|
54617
|
+
path: "rebalance",
|
|
54618
|
+
read: { kind: "cached-catalog" }
|
|
54619
|
+
});
|
|
54620
|
+
if (placement.kind !== "resolved") {
|
|
54621
|
+
const reason = placement.kind === "root-refused" ? `root-unrunnable:${placement.nodeId}/${placement.deviceKey ?? "default-pool"}` : `${placement.what}-unavailable:${placement.nodeId}`;
|
|
54622
|
+
skipped.push({
|
|
54623
|
+
deviceId,
|
|
54624
|
+
targetNodeId: decision.agentNodeId,
|
|
54625
|
+
reason
|
|
54626
|
+
});
|
|
54627
|
+
if (current) this.noteMigrationSkipped(deviceId, current.agentNodeId, decision.agentNodeId, reason);
|
|
54628
|
+
else if (placement.kind === "root-refused") this.leavePendingRootRefused(deviceId, placement);
|
|
54629
|
+
else this.leavePendingUnanswered(deviceId, placement.nodeId, placement.what);
|
|
54630
|
+
continue;
|
|
54631
|
+
}
|
|
54632
|
+
this.migrationSkips.delete(deviceId);
|
|
54633
|
+
const targetConfig = withAttachPipeline(config, placement.pipeline);
|
|
53474
54634
|
if (current) {
|
|
53475
54635
|
await this.detachOn(current.agentNodeId, deviceId).catch((err) => {
|
|
53476
54636
|
const msg = errMsg$1(err);
|
|
@@ -53479,19 +54639,13 @@ var PlacementService = class {
|
|
|
53479
54639
|
meta: { error: msg }
|
|
53480
54640
|
});
|
|
53481
54641
|
});
|
|
53482
|
-
bumpAttached(current.agentNodeId, -1);
|
|
54642
|
+
bumpAttached(current.agentNodeId, -1, config.deviceKey);
|
|
53483
54643
|
}
|
|
53484
|
-
const reResolved = await this.deps.settingsStore.resolvePipelineForDevice(deviceId, decision.agentNodeId);
|
|
53485
|
-
const targetConfig = reResolved.steps.length > 0 ? {
|
|
53486
|
-
...config,
|
|
53487
|
-
steps: reResolved.steps,
|
|
53488
|
-
audio: reResolved.audio ?? null
|
|
53489
|
-
} : config;
|
|
53490
54644
|
this.deps.ledger.setConfig(deviceId, targetConfig);
|
|
53491
54645
|
const dispatchGuard = this.deps.ledger.markDispatching(deviceId);
|
|
53492
54646
|
try {
|
|
53493
54647
|
await this.attachOn(decision.agentNodeId, targetConfig);
|
|
53494
|
-
bumpAttached(decision.agentNodeId, 1);
|
|
54648
|
+
bumpAttached(decision.agentNodeId, 1, targetConfig.deviceKey);
|
|
53495
54649
|
this.deps.ledger.recordAssignment(deviceId, decision.agentNodeId, "rebalance", false);
|
|
53496
54650
|
} finally {
|
|
53497
54651
|
dispatchGuard.dispose();
|
|
@@ -53499,7 +54653,10 @@ var PlacementService = class {
|
|
|
53499
54653
|
migrated++;
|
|
53500
54654
|
}
|
|
53501
54655
|
ctx.logger.info("rebalance: migrated cameras", { meta: { migrated } });
|
|
53502
|
-
return {
|
|
54656
|
+
return {
|
|
54657
|
+
migrated,
|
|
54658
|
+
skipped
|
|
54659
|
+
};
|
|
53503
54660
|
}
|
|
53504
54661
|
getPipelineAssignments() {
|
|
53505
54662
|
return this.deps.ledger.listAssignments();
|
|
@@ -53678,6 +54835,71 @@ var PlacementService = class {
|
|
|
53678
54835
|
};
|
|
53679
54836
|
}
|
|
53680
54837
|
/**
|
|
54838
|
+
* A re-place found the node it chose has never answered what the placement
|
|
54839
|
+
* needs (its catalog, or its device probe + pool health): the camera stays
|
|
54840
|
+
* pending with the node NAMED, and the line fires once per change of pending
|
|
54841
|
+
* state — a sweep re-finding the same unanswering node every tick is not
|
|
54842
|
+
* news, a camera newly stuck on it (or on another node) is.
|
|
54843
|
+
*/
|
|
54844
|
+
leavePendingUnanswered(deviceId, nodeId, what) {
|
|
54845
|
+
const reason = what === "catalog" ? `catalog-unavailable:${nodeId}` : `device-health-unavailable:${nodeId}`;
|
|
54846
|
+
if (!this.setPendingIfChanged(deviceId, reason)) return;
|
|
54847
|
+
this.deps.ctx().logger.warn("camera left pending — the chosen node has not answered", {
|
|
54848
|
+
tags: {
|
|
54849
|
+
deviceId,
|
|
54850
|
+
nodeId
|
|
54851
|
+
},
|
|
54852
|
+
meta: {
|
|
54853
|
+
reason,
|
|
54854
|
+
hint: "a re-place never waits for a node; the next retry round places it once the node answers"
|
|
54855
|
+
}
|
|
54856
|
+
});
|
|
54857
|
+
}
|
|
54858
|
+
/**
|
|
54859
|
+
* The NAMED refusal of a root-less attach (D650 round 3): the elected
|
|
54860
|
+
* device cannot run a gated-on root of the camera, so the attach would
|
|
54861
|
+
* detect nothing and — once its assignment were recorded — nothing would
|
|
54862
|
+
* re-place it. The camera stays pending instead, once-per-change logged.
|
|
54863
|
+
*/
|
|
54864
|
+
leavePendingRootRefused(deviceId, refusal) {
|
|
54865
|
+
const reason = `root-unrunnable:${refusal.nodeId}/${refusal.deviceKey ?? "default-pool"}`;
|
|
54866
|
+
if (!this.setPendingIfChanged(deviceId, reason)) return;
|
|
54867
|
+
this.deps.ctx().logger.error("attach refused — the elected device cannot run the camera root; camera left pending", {
|
|
54868
|
+
tags: {
|
|
54869
|
+
deviceId,
|
|
54870
|
+
nodeId: refusal.nodeId
|
|
54871
|
+
},
|
|
54872
|
+
meta: {
|
|
54873
|
+
reason,
|
|
54874
|
+
deviceKey: refusal.deviceKey ?? null,
|
|
54875
|
+
droppedRoots: refusal.droppedRoots.join(","),
|
|
54876
|
+
hint: "the root is never jumped: enable a device on this node whose format ships the root model, or disable the device that cannot"
|
|
54877
|
+
}
|
|
54878
|
+
});
|
|
54879
|
+
}
|
|
54880
|
+
/** Last migration-skip reason per camera — the info line fires once per change. */
|
|
54881
|
+
migrationSkips = /* @__PURE__ */ new Map();
|
|
54882
|
+
noteMigrationSkipped(deviceId, sourceNodeId, targetNodeId, reason) {
|
|
54883
|
+
if (this.migrationSkips.get(deviceId) === reason) return;
|
|
54884
|
+
this.migrationSkips.set(deviceId, reason);
|
|
54885
|
+
this.deps.ctx().logger.info("rebalance: migration skipped — camera stays on its source", {
|
|
54886
|
+
tags: {
|
|
54887
|
+
deviceId,
|
|
54888
|
+
nodeId: targetNodeId
|
|
54889
|
+
},
|
|
54890
|
+
meta: {
|
|
54891
|
+
sourceNodeId,
|
|
54892
|
+
reason
|
|
54893
|
+
}
|
|
54894
|
+
});
|
|
54895
|
+
}
|
|
54896
|
+
/** Record `reason`; `true` when it differs from the camera's previous pending reason. */
|
|
54897
|
+
setPendingIfChanged(deviceId, reason) {
|
|
54898
|
+
const prior = this.deps.ledger.getPendingReason(deviceId);
|
|
54899
|
+
this.deps.ledger.setPendingReason(deviceId, reason);
|
|
54900
|
+
return prior !== reason;
|
|
54901
|
+
}
|
|
54902
|
+
/**
|
|
53681
54903
|
* Normalizes the `decision.kind === 'pending'` → `setPendingReason` +
|
|
53682
54904
|
* `warn` half shared by `dispatchCamera`/`unassignPipeline`/`rebalance`
|
|
53683
54905
|
* (smell #12). Each call site keeps its own bail (return / fall-through /
|
|
@@ -53934,13 +55156,16 @@ var NodeLifecycleHandler = class {
|
|
|
53934
55156
|
for (const deviceId of candidates) {
|
|
53935
55157
|
const cached = this.deps.ledger.getConfig(deviceId);
|
|
53936
55158
|
if (!cached) continue;
|
|
53937
|
-
|
|
53938
|
-
|
|
53939
|
-
|
|
53940
|
-
|
|
53941
|
-
|
|
53942
|
-
|
|
53943
|
-
|
|
55159
|
+
if (this.isUnplacedPlaceholder(deviceId, cached, nodeId)) continue;
|
|
55160
|
+
const outcome = await this.deps.resolveAttachPipeline({
|
|
55161
|
+
deviceId,
|
|
55162
|
+
nodeId,
|
|
55163
|
+
deviceKey: cached.deviceKey
|
|
55164
|
+
});
|
|
55165
|
+
if (this.refusedReattach(deviceId, nodeId, outcome)) continue;
|
|
55166
|
+
const pipeline = outcome.pipeline;
|
|
55167
|
+
if (pipeline.steps.length === 0) continue;
|
|
55168
|
+
const next = withAttachPipeline(cached, pipeline);
|
|
53944
55169
|
this.deps.ledger.setConfig(deviceId, next);
|
|
53945
55170
|
affected.push({
|
|
53946
55171
|
deviceId,
|
|
@@ -53993,6 +55218,43 @@ var NodeLifecycleHandler = class {
|
|
|
53993
55218
|
}
|
|
53994
55219
|
}
|
|
53995
55220
|
/**
|
|
55221
|
+
* A re-attach whose elected device cannot run the camera's root is REFUSED
|
|
55222
|
+
* (D650 round 3): the live attach is left as it is rather than replaced by
|
|
55223
|
+
* one that detects nothing. Said once per pass, with the roots.
|
|
55224
|
+
*/
|
|
55225
|
+
refusedReattach(deviceId, nodeId, outcome) {
|
|
55226
|
+
if (outcome.kind !== "root-refused") return false;
|
|
55227
|
+
this.deps.logger.error("re-attach refused — the elected device cannot run the camera root", {
|
|
55228
|
+
tags: {
|
|
55229
|
+
deviceId,
|
|
55230
|
+
nodeId
|
|
55231
|
+
},
|
|
55232
|
+
meta: {
|
|
55233
|
+
deviceKey: outcome.deviceKey ?? null,
|
|
55234
|
+
droppedRoots: outcome.droppedRoots.join(",")
|
|
55235
|
+
}
|
|
55236
|
+
});
|
|
55237
|
+
return true;
|
|
55238
|
+
}
|
|
55239
|
+
/**
|
|
55240
|
+
* A ledger config with no assignment AND no elected device is the standing
|
|
55241
|
+
* path's PLACEHOLDER (D650 round 2): a camera the balancer has not placed,
|
|
55242
|
+
* hydrated before any election. Attaching it here would place it on this
|
|
55243
|
+
* node behind the balancer's back, on no device — the pending-retry sweep
|
|
55244
|
+
* owns it. Skipped, and said so.
|
|
55245
|
+
*/
|
|
55246
|
+
isUnplacedPlaceholder(deviceId, cached, nodeId) {
|
|
55247
|
+
if (this.deps.ledger.hasAssignment(deviceId) || cached.deviceKey !== void 0) return false;
|
|
55248
|
+
this.deps.logger.info("readiness: skipped an unplaced placeholder config", {
|
|
55249
|
+
tags: {
|
|
55250
|
+
deviceId,
|
|
55251
|
+
nodeId
|
|
55252
|
+
},
|
|
55253
|
+
meta: { reason: "no assignment and no elected device — the pending-retry sweep places it" }
|
|
55254
|
+
});
|
|
55255
|
+
return true;
|
|
55256
|
+
}
|
|
55257
|
+
/**
|
|
53996
55258
|
* Walk every cached RunnerCameraConfig and re-attach any whose stored
|
|
53997
55259
|
* `steps` are empty AND whose latest resolve produces a non-empty
|
|
53998
55260
|
* pipeline. Catches the "camera dispatched mid-readiness" race where
|
|
@@ -54003,13 +55265,16 @@ var NodeLifecycleHandler = class {
|
|
|
54003
55265
|
for (const [deviceId, cached] of this.deps.ledger.configEntries()) {
|
|
54004
55266
|
if ((cached.steps?.length ?? 0) > 0) continue;
|
|
54005
55267
|
if ((this.deps.ledger.getAssignment(deviceId)?.agentNodeId ?? this.deps.localNodeId()) !== nodeId) continue;
|
|
54006
|
-
|
|
54007
|
-
|
|
54008
|
-
|
|
54009
|
-
|
|
54010
|
-
|
|
54011
|
-
|
|
54012
|
-
|
|
55268
|
+
if (this.isUnplacedPlaceholder(deviceId, cached, nodeId)) continue;
|
|
55269
|
+
const outcome = await this.deps.resolveAttachPipeline({
|
|
55270
|
+
deviceId,
|
|
55271
|
+
nodeId,
|
|
55272
|
+
deviceKey: cached.deviceKey
|
|
55273
|
+
});
|
|
55274
|
+
if (this.refusedReattach(deviceId, nodeId, outcome)) continue;
|
|
55275
|
+
const pipeline = outcome.pipeline;
|
|
55276
|
+
if (pipeline.steps.length === 0) continue;
|
|
55277
|
+
const next = withAttachPipeline(cached, pipeline);
|
|
54013
55278
|
this.deps.ledger.setConfig(deviceId, next);
|
|
54014
55279
|
try {
|
|
54015
55280
|
await this.deps.attachOn(nodeId, next);
|
|
@@ -55392,9 +56657,11 @@ function wireOrchestratorSubscriptions(deps) {
|
|
|
55392
56657
|
});
|
|
55393
56658
|
const unsubFrameTracked = deps.eventBus.subscribe({ category: EventCategory.PipelineAnalyticsFrameTracked }, (event) => {
|
|
55394
56659
|
if (!isEvent(event, EventCategory.PipelineAnalyticsFrameTracked)) return;
|
|
55395
|
-
if (event.data.hasLiveTrack !== true) return;
|
|
55396
56660
|
const deviceId = event.data.deviceId;
|
|
55397
56661
|
if (typeof deviceId !== "number") return;
|
|
56662
|
+
const liveTrackCount = event.data.liveTrackCount;
|
|
56663
|
+
if (typeof liveTrackCount === "number") deps.noteSessionTrackedFrame(deviceId, liveTrackCount);
|
|
56664
|
+
if (event.data.hasLiveTrack !== true) return;
|
|
55398
56665
|
deps.noteSessionSubjectPresent(deviceId);
|
|
55399
56666
|
});
|
|
55400
56667
|
const unsubMotionAnalysis = deps.eventBus.subscribe({ category: EventCategory.MotionAnalysis }, (event) => {
|
|
@@ -55536,303 +56803,54 @@ var PackageZoneGate = class {
|
|
|
55536
56803
|
}
|
|
55537
56804
|
};
|
|
55538
56805
|
//#endregion
|
|
55539
|
-
//#region src/
|
|
55540
|
-
/**
|
|
55541
|
-
*
|
|
55542
|
-
*
|
|
55543
|
-
*
|
|
55544
|
-
|
|
55545
|
-
|
|
55546
|
-
|
|
55547
|
-
*
|
|
55548
|
-
*
|
|
55549
|
-
|
|
55550
|
-
|
|
55551
|
-
|
|
55552
|
-
|
|
55553
|
-
|
|
55554
|
-
|
|
55555
|
-
|
|
55556
|
-
|
|
55557
|
-
|
|
55558
|
-
|
|
55559
|
-
|
|
55560
|
-
|
|
55561
|
-
* `package-detection` is a ROOT (`inputClasses: null`) so it only ever appears
|
|
55562
|
-
* at the top level — no recursion into children needed. Every other step is
|
|
55563
|
-
* left untouched. Immutable: returns a fresh array only when a change applies.
|
|
55564
|
-
*/
|
|
55565
|
-
function applyPackageZoneActivation(steps, hasPackageZone) {
|
|
55566
|
-
const existing = steps.find((s) => s.addonId === PACKAGE_DETECTION_STEP_ID);
|
|
55567
|
-
if (!hasPackageZone) {
|
|
55568
|
-
if (!existing) return steps;
|
|
55569
|
-
return steps.filter((s) => s.addonId !== PACKAGE_DETECTION_STEP_ID);
|
|
55570
|
-
}
|
|
55571
|
-
if (existing) {
|
|
55572
|
-
if (existing.enabled === true) return steps;
|
|
55573
|
-
return steps.map((s) => s.addonId === "package-detection" ? {
|
|
55574
|
-
...s,
|
|
55575
|
-
enabled: true
|
|
55576
|
-
} : s);
|
|
55577
|
-
}
|
|
55578
|
-
const node = {
|
|
55579
|
-
addonId: PACKAGE_DETECTION_STEP_ID,
|
|
55580
|
-
enabled: true,
|
|
55581
|
-
children: []
|
|
55582
|
-
};
|
|
55583
|
-
return [...steps, node];
|
|
55584
|
-
}
|
|
55585
|
-
/**
|
|
55586
|
-
* Steps that are OFF by the per-camera gate DEFAULT (i.e. when the camera's
|
|
55587
|
-
* `stepToggles[addonId]` is unset). Mirrors each step's catalog
|
|
55588
|
-
* `enabledByDefault: false` — the heavy / opt-in classifiers + segmentation.
|
|
55589
|
-
*
|
|
55590
|
-
* The pure resolver derives the default gate from the LIVE catalog
|
|
55591
|
-
* (`addon.enabledByDefault ?? true`), so it stays correct if a future
|
|
55592
|
-
* default-off step ships. This literal set is the catalog-FREE fallback used
|
|
55593
|
-
* by the one-shot gating migration ({@link migrateStepGating}), which runs at
|
|
55594
|
-
* boot with no guaranteed live executor catalog. The two agree for every
|
|
55595
|
-
* step that exists today.
|
|
55596
|
-
*
|
|
55597
|
-
* `package-detection` is deliberately NOT listed — it is ZONE-driven
|
|
55598
|
-
* ({@link applyPackageZoneActivation}) and never governed by the gate.
|
|
55599
|
-
*/
|
|
55600
|
-
var DEFAULT_OFF_STEP_IDS = new Set([
|
|
55601
|
-
"animal-classifier",
|
|
55602
|
-
"bird-classifier",
|
|
55603
|
-
"vehicle-classifier",
|
|
55604
|
-
"segmentation"
|
|
55605
|
-
]);
|
|
55606
|
-
/**
|
|
55607
|
-
* Catalog-free default gate for `addonId` — `true` unless the step is in
|
|
55608
|
-
* {@link DEFAULT_OFF_STEP_IDS}. Used by the migration (no live catalog); the
|
|
55609
|
-
* resolver prefers the catalog's own `enabledByDefault ?? true`.
|
|
55610
|
-
*/
|
|
55611
|
-
function defaultStepGate(addonId) {
|
|
55612
|
-
return !DEFAULT_OFF_STEP_IDS.has(addonId);
|
|
55613
|
-
}
|
|
55614
|
-
/**
|
|
55615
|
-
* PROVISIONING presence for `(addon, format)` — the node "provides" the step
|
|
55616
|
-
* iff the catalog lists at least one model with a build for the node's engine
|
|
55617
|
-
* format. Byte-for-byte the same test the admin-ui matrices use
|
|
55618
|
-
* (`addon.models.some(m => Boolean(m.formats[fmt]))`). An absent `modelId`
|
|
55619
|
-
* on the resolved step is still "Auto" (the node resolves its own format
|
|
55620
|
-
* default) — provisioning does NOT require an operator-pinned model, only a
|
|
55621
|
-
* compatible model to EXIST for the format.
|
|
55622
|
-
*/
|
|
55623
|
-
function addonHasCompatibleModel(addon, format) {
|
|
55624
|
-
return (addon.models ?? []).some((m) => Boolean(m.formats[format]));
|
|
55625
|
-
}
|
|
55626
|
-
/**
|
|
55627
|
-
* Device-aware resolver — the SINGLE per-camera gate + per-DEVICE skip check.
|
|
55628
|
-
*
|
|
55629
|
-
* Step `S` runs for camera `C` on device `D` ⇔
|
|
55630
|
-
* gate(C, S) — `stepToggles[S]` ?? `addon.enabledByDefault ?? true`
|
|
55631
|
-
* AND provided(D, S) — the catalog lists a model with a build for D's FORMAT
|
|
55632
|
-
*
|
|
55633
|
-
* Enablement is the per-camera `stepToggles` authority ONLY. Provisioning
|
|
55634
|
-
* (`modelId` / `settings`) is NOT applied here — the returned tree carries bare
|
|
55635
|
-
* `{ addonId, enabled: true, children }` nodes; {@link applyDeviceProvisioning}
|
|
55636
|
-
* attaches model + settings at dispatch from the per-(node,device) base + the
|
|
55637
|
-
* per-camera-device override.
|
|
55638
|
-
*
|
|
55639
|
-
* A step gated ON but with NO compatible model for the DEVICE's format is
|
|
55640
|
-
* DROPPED and recorded in `skipped` so the caller can surface a visible "gated
|
|
55641
|
-
* on, no model on this device" status at dispatch time.
|
|
55642
|
-
*
|
|
55643
|
-
* `package-detection` is excluded here — it is zone-driven
|
|
55644
|
-
* ({@link applyPackageZoneActivation}, applied by the caller).
|
|
55645
|
-
*
|
|
55646
|
-
* "Inference disabled for this camera" is NOT handled here — the caller
|
|
55647
|
-
* (resolvePipelineForDevice) consults the detection-pipeline binding and
|
|
55648
|
-
* short-circuits to an empty pipeline before invoking this resolver.
|
|
55649
|
-
*/
|
|
55650
|
-
function resolvePipelineWithSkips(cameraSettings, agentNodeId, deviceFormat, catalog) {
|
|
55651
|
-
const cam = cameraSettings ?? {};
|
|
55652
|
-
const wholesale = cam.pipelineByAgent?.[agentNodeId];
|
|
55653
|
-
if (wholesale) return {
|
|
55654
|
-
config: {
|
|
55655
|
-
steps: mergeEnabledByDefaultSteps(wholesale.steps, catalog),
|
|
55656
|
-
audio: wholesale.audio ? {
|
|
55657
|
-
modelId: wholesale.audio.modelId,
|
|
55658
|
-
enabled: wholesale.audio.enabled
|
|
55659
|
-
} : null
|
|
55660
|
-
},
|
|
55661
|
-
skipped: []
|
|
55662
|
-
};
|
|
55663
|
-
const toggles = cam.stepToggles ?? {};
|
|
55664
|
-
const format = deviceFormat;
|
|
55665
|
-
const enabled = [];
|
|
55666
|
-
const skipped = [];
|
|
55667
|
-
for (const slot of catalog.slots) for (const addon of slot.addons) {
|
|
55668
|
-
const addonId = addon.id;
|
|
55669
|
-
if (addonId === "package-detection") continue;
|
|
55670
|
-
if (!(toggles[addonId] ?? addon.enabledByDefault ?? true)) continue;
|
|
55671
|
-
if (!addonHasCompatibleModel(addon, format)) {
|
|
55672
|
-
skipped.push({
|
|
55673
|
-
addonId,
|
|
55674
|
-
reason: "no-compatible-model"
|
|
55675
|
-
});
|
|
55676
|
-
continue;
|
|
55677
|
-
}
|
|
55678
|
-
enabled.push({ addonId });
|
|
55679
|
-
}
|
|
55680
|
-
const audioAddons = [];
|
|
55681
|
-
const videoAddons = [];
|
|
55682
|
-
for (const entry of enabled) {
|
|
55683
|
-
const meta = findInCatalog(catalog, entry.addonId);
|
|
55684
|
-
if (!meta) continue;
|
|
55685
|
-
if (meta.slot === "audio-classifier") audioAddons.push(entry);
|
|
55686
|
-
else videoAddons.push(entry);
|
|
55687
|
-
}
|
|
55688
|
-
return {
|
|
55689
|
-
config: {
|
|
55690
|
-
steps: buildTreeFromAddons(videoAddons, catalog),
|
|
55691
|
-
audio: audioAddons.length > 0 ? { enabled: true } : null
|
|
55692
|
-
},
|
|
55693
|
-
skipped
|
|
55694
|
-
};
|
|
55695
|
-
}
|
|
55696
|
-
function findInCatalog(catalog, addonId) {
|
|
55697
|
-
for (const slot of catalog.slots) for (const addon of slot.addons) if (addon.id === addonId) return addon;
|
|
55698
|
-
return null;
|
|
55699
|
-
}
|
|
55700
|
-
/**
|
|
55701
|
-
* Compose a step tree from a flat list of enabled video addons using
|
|
55702
|
-
* catalog compatibility: an addon whose `inputClasses` overlaps a
|
|
55703
|
-
* candidate parent's `outputClasses` attaches as the parent's child.
|
|
55704
|
-
* Addons with `inputClasses` null/empty become roots.
|
|
55705
|
-
*
|
|
55706
|
-
* Multiple addons in the same slot with compatible inputClasses run in
|
|
55707
|
-
* parallel — they attach to the same parent as siblings. Example: two
|
|
55708
|
-
* classifiers (bird-classifier + animal-classifier) both accept 'animal'
|
|
55709
|
-
* → both attach to object-detection as siblings, both receive every
|
|
55710
|
-
* animal crop.
|
|
55711
|
-
*
|
|
55712
|
-
* Unparented leaves (inputClasses set, but no matching parent in the
|
|
55713
|
-
* enabled set) are DROPPED. The runner would have nothing to feed them
|
|
55714
|
-
* anyway — better silent drop than dispatching a dead step.
|
|
55715
|
-
*/
|
|
55716
|
-
function buildTreeFromAddons(enabled, catalog) {
|
|
55717
|
-
const bySlot = /* @__PURE__ */ new Map();
|
|
55718
|
-
for (const slot of catalog.slots) bySlot.set(slot.id, slot);
|
|
55719
|
-
const nodes = /* @__PURE__ */ new Map();
|
|
55720
|
-
const metaById = /* @__PURE__ */ new Map();
|
|
55721
|
-
for (const { addonId } of enabled) {
|
|
55722
|
-
const meta = findInCatalog(catalog, addonId);
|
|
55723
|
-
if (!meta) continue;
|
|
55724
|
-
metaById.set(addonId, meta);
|
|
55725
|
-
nodes.set(addonId, {
|
|
55726
|
-
addonId,
|
|
55727
|
-
enabled: true,
|
|
55728
|
-
children: []
|
|
56806
|
+
//#region src/package-zone-reattach.ts
|
|
56807
|
+
/**
|
|
56808
|
+
* Re-attach a placed camera after its `package` zone rules changed.
|
|
56809
|
+
*
|
|
56810
|
+
* A package zone flips the toggle-less `package-detection` root on or off
|
|
56811
|
+
* (`applyPackageZoneActivation`), which changes the step TREE, so the live
|
|
56812
|
+
* attach must be re-resolved rather than wait for the next natural attach.
|
|
56813
|
+
* It goes through the one owner of an attach's steps (D650) on the device the
|
|
56814
|
+
* camera is already elected onto — a bare re-resolve here used to gate on the
|
|
56815
|
+
* node DEFAULT format and drop the device's provisioning and jump roster.
|
|
56816
|
+
*/
|
|
56817
|
+
/** No-op for a camera that is not placed or has no ledger config. Never throws. */
|
|
56818
|
+
async function reattachAfterPackageZoneChange(deps, deviceId) {
|
|
56819
|
+
const assignment = deps.ledger.getAssignment(deviceId);
|
|
56820
|
+
const cached = deps.ledger.getConfig(deviceId);
|
|
56821
|
+
if (!assignment || !cached) return;
|
|
56822
|
+
const nodeId = assignment.agentNodeId;
|
|
56823
|
+
try {
|
|
56824
|
+
const outcome = await deps.resolveAttachPipeline({
|
|
56825
|
+
deviceId,
|
|
56826
|
+
nodeId,
|
|
56827
|
+
deviceKey: cached.deviceKey
|
|
55729
56828
|
});
|
|
55730
|
-
|
|
55731
|
-
|
|
55732
|
-
|
|
55733
|
-
|
|
55734
|
-
|
|
55735
|
-
|
|
55736
|
-
|
|
55737
|
-
|
|
55738
|
-
|
|
55739
|
-
for (const [candidateId, candidateNode] of nodes) {
|
|
55740
|
-
if (candidateId === addonId) continue;
|
|
55741
|
-
if (metaById.get(candidateId).outputClasses.some((c) => inputs.includes(c))) {
|
|
55742
|
-
parent = candidateNode;
|
|
55743
|
-
break;
|
|
55744
|
-
}
|
|
55745
|
-
}
|
|
55746
|
-
if (parent) parent.children.push(node);
|
|
55747
|
-
}
|
|
55748
|
-
return roots;
|
|
55749
|
-
}
|
|
55750
|
-
/**
|
|
55751
|
-
* Back-fill catalog steps EXPLICITLY flagged `backfillIntoExistingOverrides`
|
|
55752
|
-
* but ENTIRELY ABSENT from an existing step tree — reconciles a stored
|
|
55753
|
-
* wholesale `pipelineByAgent` snapshot with genuinely-new default steps.
|
|
55754
|
-
*
|
|
55755
|
-
* Why it's needed: a `pipelineByAgent` wholesale override SNAPSHOTS the full
|
|
55756
|
-
* tree. A step introduced AFTER the snapshot (e.g. `clip-embedding` for object
|
|
55757
|
-
* semantic search) would otherwise never reach that camera.
|
|
55758
|
-
*
|
|
55759
|
-
* Why the flag (not `enabledByDefault`): absence-from-a-snapshot is ambiguous —
|
|
55760
|
-
* it can mean "predates this step" OR "operator deliberately pruned it". A
|
|
55761
|
-
* broad "back-fill every default-on step" would resurrect deliberate removals
|
|
55762
|
-
* of long-existing steps (face-detection, etc.), an operator-intent
|
|
55763
|
-
* regression. Only a step that opts in via `backfillIntoExistingOverrides`
|
|
55764
|
-
* (set on genuinely-new default steps ONLY) re-enters existing overrides.
|
|
55765
|
-
*
|
|
55766
|
-
* Non-destructive by design:
|
|
55767
|
-
* - Only steps whose id is absent from the WHOLE tree are added. A step the
|
|
55768
|
-
* operator has already seen — present anywhere, even `enabled: false` —
|
|
55769
|
-
* is left exactly as-is (its enabled flag is honoured).
|
|
55770
|
-
* - Only `backfillIntoExistingOverrides === true` addons are candidates.
|
|
55771
|
-
* Every other step (including long-standing default-on members) is left
|
|
55772
|
-
* untouched, so a deliberately-pruned override stays pruned.
|
|
55773
|
-
* - Audio-classifier addons are skipped — audio lives in
|
|
55774
|
-
* `CameraPipelineConfig.audio`, never in the video `steps` tree.
|
|
55775
|
-
* - A candidate attaches by catalog compat (its `inputClasses` overlap a
|
|
55776
|
-
* present node's `outputClasses`), mirroring {@link buildTreeFromAddons}.
|
|
55777
|
-
* A candidate with non-empty `inputClasses` and NO compatible parent is
|
|
55778
|
-
* dropped (never re-rooted) so a crop-scoped embedder never regresses to
|
|
55779
|
-
* running on the full frame. Empty `inputClasses` ⇒ a genuine root step.
|
|
55780
|
-
*/
|
|
55781
|
-
function mergeEnabledByDefaultSteps(steps, catalog) {
|
|
55782
|
-
if (steps.length === 0) return steps;
|
|
55783
|
-
const present = /* @__PURE__ */ new Set();
|
|
55784
|
-
const collect = (nodes) => {
|
|
55785
|
-
for (const n of nodes) {
|
|
55786
|
-
present.add(n.addonId);
|
|
55787
|
-
if (n.children?.length) collect(n.children);
|
|
55788
|
-
}
|
|
55789
|
-
};
|
|
55790
|
-
collect(steps);
|
|
55791
|
-
const catalogById = /* @__PURE__ */ new Map();
|
|
55792
|
-
for (const slot of catalog.slots) for (const addon of slot.addons) catalogById.set(addon.id, addon);
|
|
55793
|
-
const newRoots = [];
|
|
55794
|
-
const childrenByParent = /* @__PURE__ */ new Map();
|
|
55795
|
-
for (const slot of catalog.slots) {
|
|
55796
|
-
if (slot.id === "audio-classifier") continue;
|
|
55797
|
-
for (const addon of slot.addons) {
|
|
55798
|
-
if (addon.backfillIntoExistingOverrides !== true) continue;
|
|
55799
|
-
if (present.has(addon.id)) continue;
|
|
55800
|
-
const node = {
|
|
55801
|
-
addonId: addon.id,
|
|
55802
|
-
enabled: true,
|
|
55803
|
-
children: []
|
|
55804
|
-
};
|
|
55805
|
-
const inputs = addon.inputClasses;
|
|
55806
|
-
if (!inputs || inputs.length === 0) {
|
|
55807
|
-
newRoots.push(node);
|
|
55808
|
-
continue;
|
|
55809
|
-
}
|
|
55810
|
-
let parentId = null;
|
|
55811
|
-
for (const presentId of present) {
|
|
55812
|
-
const meta = catalogById.get(presentId);
|
|
55813
|
-
if (!meta) continue;
|
|
55814
|
-
if (meta.outputClasses.some((c) => inputs.includes(c))) {
|
|
55815
|
-
parentId = presentId;
|
|
55816
|
-
break;
|
|
56829
|
+
if (outcome.kind === "root-refused") {
|
|
56830
|
+
deps.logger.error("package zone-rule re-attach refused — the elected device cannot run the camera root", {
|
|
56831
|
+
tags: {
|
|
56832
|
+
deviceId,
|
|
56833
|
+
nodeId
|
|
56834
|
+
},
|
|
56835
|
+
meta: {
|
|
56836
|
+
deviceKey: outcome.deviceKey ?? null,
|
|
56837
|
+
droppedRoots: outcome.droppedRoots.join(",")
|
|
55817
56838
|
}
|
|
55818
|
-
}
|
|
55819
|
-
|
|
55820
|
-
const list = childrenByParent.get(parentId) ?? [];
|
|
55821
|
-
list.push(node);
|
|
55822
|
-
childrenByParent.set(parentId, list);
|
|
56839
|
+
});
|
|
56840
|
+
return;
|
|
55823
56841
|
}
|
|
56842
|
+
const next = withAttachPipeline(cached, outcome.pipeline);
|
|
56843
|
+
deps.ledger.setConfig(deviceId, next);
|
|
56844
|
+
await deps.attachOn(nodeId, next);
|
|
56845
|
+
} catch (err) {
|
|
56846
|
+
deps.logger.warn("package zone-rule change re-dispatch failed", {
|
|
56847
|
+
tags: {
|
|
56848
|
+
deviceId,
|
|
56849
|
+
nodeId
|
|
56850
|
+
},
|
|
56851
|
+
meta: { error: errMsg$1(err) }
|
|
56852
|
+
});
|
|
55824
56853
|
}
|
|
55825
|
-
if (childrenByParent.size === 0 && newRoots.length === 0) return steps;
|
|
55826
|
-
const attach = (nodes) => nodes.map((n) => {
|
|
55827
|
-
const rebuiltChildren = attach(n.children ?? []);
|
|
55828
|
-
const extra = childrenByParent.get(n.addonId) ?? [];
|
|
55829
|
-
const mergedChildren = [...rebuiltChildren, ...extra];
|
|
55830
|
-
return mergedChildren.length > 0 ? {
|
|
55831
|
-
...n,
|
|
55832
|
-
children: mergedChildren
|
|
55833
|
-
} : n;
|
|
55834
|
-
});
|
|
55835
|
-
return [...attach(steps), ...newRoots];
|
|
55836
56854
|
}
|
|
55837
56855
|
//#endregion
|
|
55838
56856
|
//#region src/node-step-settings-fold.ts
|
|
@@ -56063,6 +57081,10 @@ var PipelineSettingsStore = class PipelineSettingsStore {
|
|
|
56063
57081
|
this.deps = deps;
|
|
56064
57082
|
}
|
|
56065
57083
|
disposed = false;
|
|
57084
|
+
/** Whether `dispose()` has run — a `null` catalog from a bounded wait means "not answered" only while this is false. */
|
|
57085
|
+
isDisposed() {
|
|
57086
|
+
return this.disposed;
|
|
57087
|
+
}
|
|
56066
57088
|
/** Called once from the addon's `onShutdown()`. Idempotent. */
|
|
56067
57089
|
dispose() {
|
|
56068
57090
|
this.disposed = true;
|
|
@@ -56354,35 +57376,140 @@ var PipelineSettingsStore = class PipelineSettingsStore {
|
|
|
56354
57376
|
}
|
|
56355
57377
|
catalogCache = /* @__PURE__ */ new Map();
|
|
56356
57378
|
/**
|
|
56357
|
-
*
|
|
56358
|
-
*
|
|
56359
|
-
*
|
|
56360
|
-
*
|
|
57379
|
+
* A node's `PipelineSchema`: the cached answer when fresh, else ONE live
|
|
57380
|
+
* `getSchema` RPC (bounded by the transport's request timeout, not by this
|
|
57381
|
+
* store). `null` when that read fails. Unlike {@link waitForAgentAndCatalog}
|
|
57382
|
+
* it does not retry; unlike {@link peekCatalogForAgent} it DOES wait for the
|
|
57383
|
+
* node — use the peek on any path that must never wait.
|
|
56361
57384
|
*/
|
|
56362
57385
|
async getCatalogForNode(nodeId) {
|
|
56363
57386
|
return this.getCatalogForAgent(nodeId);
|
|
56364
57387
|
}
|
|
57388
|
+
/**
|
|
57389
|
+
* The node's catalog for a LIVE eligibility read, in one of two explicit
|
|
57390
|
+
* modes (D650 round 3, bounded in the pre-ship review):
|
|
57391
|
+
* - `unbounded` — WAIT until the node's executor answers, the same
|
|
57392
|
+
* loss-proof reconcile the resolve uses ({@link awaitExecutorCatalog}).
|
|
57393
|
+
* For a FIRST placement, which will wait for this catalog to resolve its
|
|
57394
|
+
* pipeline anyway: electing against it too keeps the root guard from
|
|
57395
|
+
* failing open. `null` only when the store is disposed mid-wait.
|
|
57396
|
+
* - `one-round` — ONE reconcile round (one `getSchema`, no acquire wait).
|
|
57397
|
+
* `null` means "not answered". For a read nothing waits on — a background
|
|
57398
|
+
* refresher — where the unbounded form is a loop that outlives the node
|
|
57399
|
+
* it is asking (D3): a `getSchema` plus a 30 s acquire per round and a
|
|
57400
|
+
* WARN every round, for as long as the key is held.
|
|
57401
|
+
*/
|
|
57402
|
+
async awaitCatalogForNode(nodeId, wait) {
|
|
57403
|
+
return this.awaitExecutorCatalog(nodeId, wait === "one-round" ? { maxRounds: 1 } : {});
|
|
57404
|
+
}
|
|
57405
|
+
/**
|
|
57406
|
+
* Per-node catalog generation. {@link invalidateCatalog} bumps it; a
|
|
57407
|
+
* refresh records its answer only if the generation it started under is
|
|
57408
|
+
* still current, so a read that began BEFORE an invalidation can never
|
|
57409
|
+
* re-install the catalog the invalidation just removed.
|
|
57410
|
+
*/
|
|
57411
|
+
catalogGeneration = /* @__PURE__ */ new Map();
|
|
57412
|
+
/** Bumped by a full {@link invalidateCatalog}; part of every node's generation. */
|
|
57413
|
+
catalogEpoch = 0;
|
|
57414
|
+
catalogGenerationOf(nodeId) {
|
|
57415
|
+
return `${this.catalogEpoch}:${this.catalogGeneration.get(nodeId) ?? 0}`;
|
|
57416
|
+
}
|
|
57417
|
+
/** Nodes whose last catalog read FAILED — the warn fires once per failing stretch. */
|
|
57418
|
+
catalogReadFailing = /* @__PURE__ */ new Set();
|
|
57419
|
+
/** Nodes being served a catalog past its TTL — the warn fires once per stale stretch. */
|
|
57420
|
+
catalogServedStale = /* @__PURE__ */ new Set();
|
|
57421
|
+
/** Nodes with a background catalog refresh in flight (single-flight per node). */
|
|
57422
|
+
catalogRefreshInFlight = /* @__PURE__ */ new Set();
|
|
57423
|
+
/**
|
|
57424
|
+
* A catalog served this far past its TTL is WORTH a warn: its background
|
|
57425
|
+
* refresh has not landed in ten refresh windows. Below this, serving past
|
|
57426
|
+
* the TTL is by construction — the re-place sweep runs every 60 s against a
|
|
57427
|
+
* 30 s TTL, so every round finds the entry expired and refreshes it behind
|
|
57428
|
+
* the read — and a line per round said nothing (pre-ship review, M5).
|
|
57429
|
+
*/
|
|
57430
|
+
static CATALOG_STALE_WARN_MS = 5 * 6e4;
|
|
57431
|
+
/**
|
|
57432
|
+
* Cache-only catalog read — NEVER awaits the node (D650 round 2). Serves
|
|
57433
|
+
* the last answer the node gave, even past its TTL (a stale catalog is a
|
|
57434
|
+
* structure that was true a minute ago; its absence is a camera left
|
|
57435
|
+
* pending), and starts ONE background {@link getCatalogForAgent} when the
|
|
57436
|
+
* entry is missing or expired, so the next caller sees a fresh answer.
|
|
57437
|
+
* Returns `null` only when the node has never answered.
|
|
57438
|
+
*/
|
|
57439
|
+
peekCatalogForAgent(nodeId) {
|
|
57440
|
+
const hit = this.catalogCache.get(`${nodeId}::*`);
|
|
57441
|
+
const ageMs = hit === void 0 ? null : Date.now() - hit.at;
|
|
57442
|
+
const fresh = ageMs !== null && ageMs < PipelineSettingsStore.CATALOG_CACHE_TTL_MS;
|
|
57443
|
+
if (fresh) this.catalogServedStale.delete(nodeId);
|
|
57444
|
+
else if (ageMs !== null && ageMs >= PipelineSettingsStore.CATALOG_STALE_WARN_MS && !this.catalogServedStale.has(nodeId)) {
|
|
57445
|
+
this.catalogServedStale.add(nodeId);
|
|
57446
|
+
this.deps.logger.warn("serving a node catalog long past its TTL — its refresh has not landed", {
|
|
57447
|
+
tags: { nodeId },
|
|
57448
|
+
meta: {
|
|
57449
|
+
ageMs,
|
|
57450
|
+
ttlMs: PipelineSettingsStore.CATALOG_CACHE_TTL_MS,
|
|
57451
|
+
warnAfterMs: PipelineSettingsStore.CATALOG_STALE_WARN_MS
|
|
57452
|
+
}
|
|
57453
|
+
});
|
|
57454
|
+
}
|
|
57455
|
+
if (!fresh && !this.catalogRefreshInFlight.has(nodeId)) {
|
|
57456
|
+
this.catalogRefreshInFlight.add(nodeId);
|
|
57457
|
+
this.getCatalogForAgent(nodeId).finally(() => {
|
|
57458
|
+
this.catalogRefreshInFlight.delete(nodeId);
|
|
57459
|
+
});
|
|
57460
|
+
}
|
|
57461
|
+
return hit?.schema ?? null;
|
|
57462
|
+
}
|
|
57463
|
+
/**
|
|
57464
|
+
* How many times one {@link getCatalogForAgent} call re-reads after its
|
|
57465
|
+
* answer was fenced out by an invalidation, before it gives up (`null`, which
|
|
57466
|
+
* the callers treat as "not answered"). Invalidations come from readiness
|
|
57467
|
+
* epochs and operator resets — a bound, not a budget.
|
|
57468
|
+
*/
|
|
57469
|
+
static CATALOG_FENCE_REREADS = 3;
|
|
56365
57470
|
async getCatalogForAgent(nodeId) {
|
|
56366
57471
|
const api = this.deps.api();
|
|
56367
57472
|
if (!api) return null;
|
|
56368
57473
|
const engineKey = `${nodeId}::*`;
|
|
56369
|
-
|
|
56370
|
-
|
|
56371
|
-
|
|
56372
|
-
|
|
56373
|
-
const
|
|
56374
|
-
|
|
56375
|
-
|
|
56376
|
-
|
|
56377
|
-
})
|
|
56378
|
-
|
|
56379
|
-
|
|
56380
|
-
|
|
57474
|
+
for (let reread = 0; !this.disposed; reread++) {
|
|
57475
|
+
const now = Date.now();
|
|
57476
|
+
const hit = this.catalogCache.get(engineKey);
|
|
57477
|
+
if (hit && now - hit.at < PipelineSettingsStore.CATALOG_CACHE_TTL_MS) return hit.schema;
|
|
57478
|
+
const generation = this.catalogGenerationOf(nodeId);
|
|
57479
|
+
let schema;
|
|
57480
|
+
try {
|
|
57481
|
+
schema = await api.pipelineExecutor.getSchema.query({ nodeId });
|
|
57482
|
+
} catch (err) {
|
|
57483
|
+
if (!this.catalogReadFailing.has(nodeId)) {
|
|
57484
|
+
this.catalogReadFailing.add(nodeId);
|
|
57485
|
+
this.deps.logger.warn("node catalog read failed — placements on it wait or stay pending", {
|
|
57486
|
+
tags: { nodeId },
|
|
57487
|
+
meta: { error: errMsg$1(err) }
|
|
57488
|
+
});
|
|
57489
|
+
}
|
|
57490
|
+
return null;
|
|
57491
|
+
}
|
|
57492
|
+
if (this.catalogReadFailing.delete(nodeId)) this.deps.logger.info("node catalog read recovered", { tags: { nodeId } });
|
|
57493
|
+
if (this.catalogGenerationOf(nodeId) === generation) {
|
|
57494
|
+
this.catalogCache.set(engineKey, {
|
|
57495
|
+
schema,
|
|
57496
|
+
at: now
|
|
57497
|
+
});
|
|
57498
|
+
return schema;
|
|
57499
|
+
}
|
|
57500
|
+
if (reread >= PipelineSettingsStore.CATALOG_FENCE_REREADS) {
|
|
57501
|
+
this.deps.logger.warn("node catalog read fenced repeatedly — giving up this read", {
|
|
57502
|
+
tags: { nodeId },
|
|
57503
|
+
meta: { rereads: reread }
|
|
57504
|
+
});
|
|
57505
|
+
return null;
|
|
57506
|
+
}
|
|
57507
|
+
this.deps.logger.info("node catalog read fenced by an invalidation — re-reading", {
|
|
56381
57508
|
tags: { nodeId },
|
|
56382
|
-
meta: {
|
|
57509
|
+
meta: { reread: reread + 1 }
|
|
56383
57510
|
});
|
|
56384
|
-
return null;
|
|
56385
57511
|
}
|
|
57512
|
+
return null;
|
|
56386
57513
|
}
|
|
56387
57514
|
/**
|
|
56388
57515
|
* Consolidated catalog-cache invalidation (smell #21). Omit `nodeId` for
|
|
@@ -56394,9 +57521,11 @@ var PipelineSettingsStore = class PipelineSettingsStore {
|
|
|
56394
57521
|
*/
|
|
56395
57522
|
invalidateCatalog(nodeId) {
|
|
56396
57523
|
if (nodeId === void 0) {
|
|
57524
|
+
this.catalogEpoch++;
|
|
56397
57525
|
this.catalogCache.clear();
|
|
56398
57526
|
return;
|
|
56399
57527
|
}
|
|
57528
|
+
this.catalogGeneration.set(nodeId, (this.catalogGeneration.get(nodeId) ?? 0) + 1);
|
|
56400
57529
|
for (const key of Array.from(this.catalogCache.keys())) if (key.startsWith(`${nodeId}::`)) this.catalogCache.delete(key);
|
|
56401
57530
|
}
|
|
56402
57531
|
/**
|
|
@@ -56468,8 +57597,40 @@ var PipelineSettingsStore = class PipelineSettingsStore {
|
|
|
56468
57597
|
*
|
|
56469
57598
|
* Everything else (boot ordering, catalog cold-start, transient cap
|
|
56470
57599
|
* registration delay) loops with backoff until the catalog responds.
|
|
57600
|
+
*
|
|
57601
|
+
* `jumpDeviceKeys` (D650) are the node's OTHER enabled devices the runner
|
|
57602
|
+
* may jump a detail step to — `jumpTargetKeys(roster)` of the SAME roster the
|
|
57603
|
+
* attach carries. A detail step the elected device cannot run is kept when
|
|
57604
|
+
* one of them can; empty ⇒ the elected format alone decides.
|
|
57605
|
+
*/
|
|
57606
|
+
async resolvePipelineForDevice(deviceId, agentNodeIdOverride, deviceKey, jumpDeviceKeys = []) {
|
|
57607
|
+
const outcome = await this.resolvePipelineWithCatalog(deviceId, agentNodeIdOverride, deviceKey, jumpDeviceKeys, { kind: "await" });
|
|
57608
|
+
if (outcome.kind === "resolved") return outcome.config;
|
|
57609
|
+
throw new Error(`PipelineSettingsStore.resolvePipelineForDevice: catalog unavailable for "${outcome.nodeId}"`);
|
|
57610
|
+
}
|
|
57611
|
+
/**
|
|
57612
|
+
* {@link resolvePipelineForDevice} with the gated-on roots the elected device
|
|
57613
|
+
* cannot run (D650 round 3) — what an attach needs to refuse a camera whose
|
|
57614
|
+
* own detector would not run. Waits for the catalog exactly as it does.
|
|
57615
|
+
*/
|
|
57616
|
+
async resolvePipelineForDeviceWithRoots(deviceId, agentNodeId, deviceKey, jumpDeviceKeys) {
|
|
57617
|
+
const outcome = await this.resolvePipelineWithCatalog(deviceId, agentNodeId, deviceKey, jumpDeviceKeys, { kind: "await" });
|
|
57618
|
+
if (outcome.kind === "resolved") return outcome;
|
|
57619
|
+
throw new Error(`PipelineSettingsStore.resolvePipelineForDeviceWithRoots: catalog unavailable for "${outcome.nodeId}"`);
|
|
57620
|
+
}
|
|
57621
|
+
/**
|
|
57622
|
+
* The NON-BLOCKING sibling of {@link resolvePipelineForDevice} (D650 round
|
|
57623
|
+
* 2): the same resolution, but the node's catalog is read from the cache
|
|
57624
|
+
* only ({@link peekCatalogForAgent}). A cold or unanswering node yields
|
|
57625
|
+
* `catalog-unavailable` instead of a wait — the RETRY / re-place paths call
|
|
57626
|
+
* this, so one wedged executor can never hold a sweep (D3: no waiting for a
|
|
57627
|
+
* node; D49: never gate on a fallible read). A cold cache also starts one
|
|
57628
|
+
* background refresh, so the next retry round finds the catalog.
|
|
56471
57629
|
*/
|
|
56472
|
-
async
|
|
57630
|
+
async resolvePipelineForDeviceIfCatalogCached(deviceId, agentNodeId, deviceKey, jumpDeviceKeys) {
|
|
57631
|
+
return this.resolvePipelineWithCatalog(deviceId, agentNodeId, deviceKey, jumpDeviceKeys, { kind: "cached" });
|
|
57632
|
+
}
|
|
57633
|
+
async resolvePipelineWithCatalog(deviceId, agentNodeIdOverride, deviceKey, jumpDeviceKeys, catalogRead) {
|
|
56473
57634
|
const agentNodeId = agentNodeIdOverride ?? this.deps.getAssignmentNode(deviceId);
|
|
56474
57635
|
if (!agentNodeId) throw new Error(`PipelineSettingsStore.resolvePipelineForDevice: no node resolvable for device ${deviceId}`);
|
|
56475
57636
|
if (!await this.isDetectionPipelineActive(deviceId)) {
|
|
@@ -56481,33 +57642,57 @@ var PipelineSettingsStore = class PipelineSettingsStore {
|
|
|
56481
57642
|
meta: { reason: "detection-pipeline binding inactive" }
|
|
56482
57643
|
});
|
|
56483
57644
|
return {
|
|
56484
|
-
|
|
56485
|
-
|
|
57645
|
+
kind: "resolved",
|
|
57646
|
+
config: {
|
|
57647
|
+
steps: [],
|
|
57648
|
+
audio: null
|
|
57649
|
+
},
|
|
57650
|
+
droppedRoots: []
|
|
56486
57651
|
};
|
|
56487
57652
|
}
|
|
56488
57653
|
const hasPackageZone = await this.deps.hasPackageZone(deviceId);
|
|
56489
57654
|
const cam = (await this.readCameraSettingsMap())[String(deviceId)] ?? null;
|
|
56490
57655
|
const wholesale = cam?.pipelineByAgent?.[agentNodeId];
|
|
56491
57656
|
if (wholesale) {
|
|
56492
|
-
const catalog = await this.getCatalogForAgent(agentNodeId);
|
|
57657
|
+
const catalog = catalogRead.kind === "await" ? await this.getCatalogForAgent(agentNodeId) : this.peekCatalogForAgent(agentNodeId);
|
|
56493
57658
|
const baseSteps = catalog ? mergeEnabledByDefaultSteps(wholesale.steps, catalog) : wholesale.steps;
|
|
56494
57659
|
return {
|
|
56495
|
-
|
|
56496
|
-
|
|
56497
|
-
|
|
56498
|
-
|
|
56499
|
-
|
|
57660
|
+
kind: "resolved",
|
|
57661
|
+
droppedRoots: [],
|
|
57662
|
+
config: {
|
|
57663
|
+
steps: baseSteps.length > 0 ? applyPackageZoneActivation(baseSteps, hasPackageZone) : baseSteps,
|
|
57664
|
+
audio: wholesale.audio ? {
|
|
57665
|
+
modelId: wholesale.audio.modelId,
|
|
57666
|
+
enabled: wholesale.audio.enabled
|
|
57667
|
+
} : null
|
|
57668
|
+
}
|
|
57669
|
+
};
|
|
57670
|
+
}
|
|
57671
|
+
let catalog;
|
|
57672
|
+
if (catalogRead.kind === "await") catalog = (await this.waitForAgentAndCatalog(agentNodeId)).catalog;
|
|
57673
|
+
else {
|
|
57674
|
+
const cached = this.peekCatalogForAgent(agentNodeId);
|
|
57675
|
+
if (!cached) return {
|
|
57676
|
+
kind: "catalog-unavailable",
|
|
57677
|
+
nodeId: agentNodeId
|
|
56500
57678
|
};
|
|
57679
|
+
catalog = cached;
|
|
56501
57680
|
}
|
|
56502
|
-
const { catalog } = await this.waitForAgentAndCatalog(agentNodeId);
|
|
56503
57681
|
const deviceFormat = deviceKey ? parseDeviceKey(deviceKey).format : catalog.selectedEngine.format;
|
|
56504
|
-
const
|
|
57682
|
+
const jumpFormats = [...new Set(jumpDeviceKeys.map((k) => parseDeviceKey(k).format))];
|
|
57683
|
+
const { config: resolvedBase, skipped, droppedRoots } = resolvePipelineWithSkips(cam, agentNodeId, deviceFormat, catalog, jumpFormats);
|
|
56505
57684
|
if (skipped.length > 0) this.deps.logger.warn("pipeline steps gated on but not provisioned on this node — skipped", {
|
|
56506
57685
|
tags: {
|
|
56507
57686
|
deviceId,
|
|
56508
57687
|
nodeId: agentNodeId
|
|
56509
57688
|
},
|
|
56510
|
-
meta: {
|
|
57689
|
+
meta: {
|
|
57690
|
+
skipped: skipped.map((s) => `${s.addonId}:${s.reason}`).join(","),
|
|
57691
|
+
electedDeviceKey: deviceKey ?? null,
|
|
57692
|
+
electedFormat: deviceFormat,
|
|
57693
|
+
jumpDeviceKeys: jumpDeviceKeys.join(","),
|
|
57694
|
+
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)"
|
|
57695
|
+
}
|
|
56511
57696
|
});
|
|
56512
57697
|
const resolved = {
|
|
56513
57698
|
...resolvedBase,
|
|
@@ -56524,7 +57709,11 @@ var PipelineSettingsStore = class PipelineSettingsStore {
|
|
|
56524
57709
|
catalogSlotCount: catalog.slots?.length ?? 0
|
|
56525
57710
|
}
|
|
56526
57711
|
});
|
|
56527
|
-
return
|
|
57712
|
+
return {
|
|
57713
|
+
kind: "resolved",
|
|
57714
|
+
config: resolved,
|
|
57715
|
+
droppedRoots
|
|
57716
|
+
};
|
|
56528
57717
|
}
|
|
56529
57718
|
/**
|
|
56530
57719
|
* Loss-proof `pipeline-executor` gate (D8 — reconcile against the
|
|
@@ -56658,6 +57847,56 @@ var PipelineSettingsStore = class PipelineSettingsStore {
|
|
|
56658
57847
|
return foldNodeStepSettings((await this.agentSettingsState.get())[nodeId]?.inferenceDevices, deviceKey);
|
|
56659
57848
|
}
|
|
56660
57849
|
/**
|
|
57850
|
+
* The provisioning SOURCES of every device an attach may run a step on
|
|
57851
|
+
* (D650): for each requested `deviceKey`, its per-(node,device) base (the
|
|
57852
|
+
* same fold as {@link buildNodeInferenceDeviceSteps}) and the camera's
|
|
57853
|
+
* override for that device (the same slot as {@link readCameraDeviceOverride}).
|
|
57854
|
+
* ONE read of each blob for the whole set — the elected device and every
|
|
57855
|
+
* jump target — instead of two reads per device. `''` is the default pool:
|
|
57856
|
+
* its base is the node's folded settings and it has no camera override.
|
|
57857
|
+
*/
|
|
57858
|
+
async readDeviceProvisioning(deviceId, nodeId, deviceKeys) {
|
|
57859
|
+
const [agentBlob, cameraMap] = await Promise.all([this.agentSettingsState.get(), this.readCameraSettingsMap()]);
|
|
57860
|
+
const devices = agentBlob[nodeId]?.inferenceDevices;
|
|
57861
|
+
const byDevice = cameraMap[String(deviceId)]?.stepOverridesByDevice?.[nodeId];
|
|
57862
|
+
const out = /* @__PURE__ */ new Map();
|
|
57863
|
+
for (const deviceKey of deviceKeys) {
|
|
57864
|
+
const override = deviceKey === "" ? void 0 : byDevice?.[deviceKey];
|
|
57865
|
+
out.set(deviceKey, {
|
|
57866
|
+
base: foldNodeStepSettings(devices, deviceKey),
|
|
57867
|
+
override: override ? { ...override } : {}
|
|
57868
|
+
});
|
|
57869
|
+
}
|
|
57870
|
+
return out;
|
|
57871
|
+
}
|
|
57872
|
+
/** `${deviceId}:${stepId}` pairs already warned by {@link warnManualJumpsLeaveOverride}. */
|
|
57873
|
+
warnedManualJumpOverride = /* @__PURE__ */ new Set();
|
|
57874
|
+
/**
|
|
57875
|
+
* WARN, once per (camera, step), that an operator `jumpDeviceKey` sends a
|
|
57876
|
+
* step off the elected device while the camera has an override for that
|
|
57877
|
+
* step ON the elected device (D650): the jumped step runs the TARGET's
|
|
57878
|
+
* provisioning, so that override is not applied when the jump is taken.
|
|
57879
|
+
*/
|
|
57880
|
+
warnManualJumpsLeaveOverride(deviceId, nodeId, electedDeviceKey, steps) {
|
|
57881
|
+
for (const { stepId, jumpDeviceKey } of steps) {
|
|
57882
|
+
const key = `${deviceId}:${stepId}`;
|
|
57883
|
+
if (this.warnedManualJumpOverride.has(key)) continue;
|
|
57884
|
+
this.warnedManualJumpOverride.add(key);
|
|
57885
|
+
this.deps.logger.warn("manual jumpDeviceKey leaves an elected-device override behind — the jumped step runs the target device provisioning", {
|
|
57886
|
+
tags: {
|
|
57887
|
+
deviceId,
|
|
57888
|
+
nodeId
|
|
57889
|
+
},
|
|
57890
|
+
meta: {
|
|
57891
|
+
step: stepId,
|
|
57892
|
+
electedDeviceKey,
|
|
57893
|
+
jumpDeviceKey,
|
|
57894
|
+
hint: `move the camera's override for ${stepId} to ${jumpDeviceKey}, or drop the jumpDeviceKey`
|
|
57895
|
+
}
|
|
57896
|
+
});
|
|
57897
|
+
}
|
|
57898
|
+
}
|
|
57899
|
+
/**
|
|
56661
57900
|
* Structural runtime check for `CameraPipelineConfig`. Persisted JSON
|
|
56662
57901
|
* may be stale or partial across upgrades, so we gate reads on shape
|
|
56663
57902
|
* rather than trusting the raw object. False = silently ignore that
|
|
@@ -57447,7 +58686,7 @@ var ReconcileController = class ReconcileController {
|
|
|
57447
58686
|
const cfg = this.deps.ledger.getConfig(deviceId);
|
|
57448
58687
|
if (!cfg) continue;
|
|
57449
58688
|
try {
|
|
57450
|
-
const result = await this.deps.
|
|
58689
|
+
const result = await this.deps.redispatchCamera(cfg);
|
|
57451
58690
|
retried++;
|
|
57452
58691
|
if (result.kind === "pending") stillPending++;
|
|
57453
58692
|
} catch (err) {
|
|
@@ -57558,7 +58797,7 @@ var ReconcileController = class ReconcileController {
|
|
|
57558
58797
|
});
|
|
57559
58798
|
});
|
|
57560
58799
|
this.deps.ledger.dropAssignment(deviceId, "silent");
|
|
57561
|
-
await this.deps.
|
|
58800
|
+
await this.deps.redispatchCamera(cfg).catch((err) => {
|
|
57562
58801
|
this.deps.logger.warn("remote-health re-dispatch failed", {
|
|
57563
58802
|
tags: { deviceId },
|
|
57564
58803
|
meta: { error: errMsg$1(err) }
|
|
@@ -58041,7 +59280,7 @@ var TtlMemo = class {
|
|
|
58041
59280
|
});
|
|
58042
59281
|
if (entry && at - entry.readAt < this.ttlMs) return entry.value;
|
|
58043
59282
|
try {
|
|
58044
|
-
return await this.read(key);
|
|
59283
|
+
return await this.read(key, "miss");
|
|
58045
59284
|
} catch (err) {
|
|
58046
59285
|
if (entry) return entry.value;
|
|
58047
59286
|
throw err;
|
|
@@ -58073,7 +59312,7 @@ var TtlMemo = class {
|
|
|
58073
59312
|
ageMs: at - entry.readAt
|
|
58074
59313
|
};
|
|
58075
59314
|
const fallback = this.lastKnown.get(key);
|
|
58076
|
-
const pending = this.read(key);
|
|
59315
|
+
const pending = this.read(key, "miss");
|
|
58077
59316
|
if (fallback === void 0) return {
|
|
58078
59317
|
value: await pending,
|
|
58079
59318
|
stale: false,
|
|
@@ -58112,7 +59351,7 @@ var TtlMemo = class {
|
|
|
58112
59351
|
* is what re-warms a key immediately after {@link invalidate}.
|
|
58113
59352
|
*/
|
|
58114
59353
|
async refresh(key) {
|
|
58115
|
-
await this.read(key).catch(() => void 0);
|
|
59354
|
+
await this.read(key, "refresh").catch(() => void 0);
|
|
58116
59355
|
}
|
|
58117
59356
|
/**
|
|
58118
59357
|
* Re-read every key still in use and drop the ones nobody has asked for in
|
|
@@ -58138,12 +59377,16 @@ var TtlMemo = class {
|
|
|
58138
59377
|
this.generations.clear();
|
|
58139
59378
|
this.lastKnown.clear();
|
|
58140
59379
|
}
|
|
58141
|
-
/**
|
|
58142
|
-
read
|
|
59380
|
+
/**
|
|
59381
|
+
* One shared in-flight read per key; stores the result on success. A
|
|
59382
|
+
* refresh that finds a miss's read in flight joins it — the waiting caller
|
|
59383
|
+
* chose to wait, and the refresher never awaits the promise it is handed.
|
|
59384
|
+
*/
|
|
59385
|
+
read(key, mode) {
|
|
58143
59386
|
const existing = this.inFlight.get(key);
|
|
58144
59387
|
if (existing) return existing;
|
|
58145
59388
|
const generation = this.generations.get(key) ?? 0;
|
|
58146
|
-
const pending = this.opts.load(key).then((value) => {
|
|
59389
|
+
const pending = this.opts.load(key, mode).then((value) => {
|
|
58147
59390
|
const at = this.now();
|
|
58148
59391
|
const stored = {
|
|
58149
59392
|
value,
|
|
@@ -58337,6 +59580,23 @@ var SessionDispatchController = class {
|
|
|
58337
59580
|
*/
|
|
58338
59581
|
motionWatchByDevice = /* @__PURE__ */ new Map();
|
|
58339
59582
|
/**
|
|
59583
|
+
* The last session REFUSAL per camera (D650 rounds 3–4): the elected device
|
|
59584
|
+
* could not run the camera root, so no session opened and nothing was
|
|
59585
|
+
* recorded. Two readers:
|
|
59586
|
+
* - the refusal line fires once per CHANGE of this entry, not on every
|
|
59587
|
+
* motion edge (a camera re-elected onto the same device every few
|
|
59588
|
+
* seconds is one fact, not a log storm);
|
|
59589
|
+
* - the camera status reads it through {@link refusalReason} and shows
|
|
59590
|
+
* `pending:<reason>` in `reasons.detection`, where a STANDING camera's
|
|
59591
|
+
* pending reason already appears — an armed camera whose every session
|
|
59592
|
+
* is refused must not look like a healthy one waiting for motion.
|
|
59593
|
+
* Deliberately NOT the ledger's `pendingReasons`: an entry there counts as a
|
|
59594
|
+
* standing attach in {@link handleSessionMotion} and would freeze the camera
|
|
59595
|
+
* idle — the trap `isIdleSessionCamera` (index.ts) flags. Cleared when a
|
|
59596
|
+
* session opens and when the camera's detection is stopped.
|
|
59597
|
+
*/
|
|
59598
|
+
sessionRefusals = /* @__PURE__ */ new Map();
|
|
59599
|
+
/**
|
|
58340
59600
|
* Per-device wall-clock of the last frame on which the tracker still had a
|
|
58341
59601
|
* subject — ANY tracked object, moving or stopped — while a session was
|
|
58342
59602
|
* active. The session-hold's only liveness input.
|
|
@@ -58357,6 +59617,15 @@ var SessionDispatchController = class {
|
|
|
58357
59617
|
*/
|
|
58358
59618
|
lastSubjectSeenAt = /* @__PURE__ */ new Map();
|
|
58359
59619
|
/**
|
|
59620
|
+
* Per-device count of objects the TRACKER held on the last tracked frame of
|
|
59621
|
+
* the active session — moving OR still (D652). Diagnostics only: it decides
|
|
59622
|
+
* nothing. {@link lastSubjectSeenAt} is presence as the HOLD sees it, which
|
|
59623
|
+
* the producer's stillness valve deliberately subtracts a still subject from;
|
|
59624
|
+
* this is what lets a close say whether it ended tracks the tracker still
|
|
59625
|
+
* had. Fed by {@link noteTrackedFrame}; pruned with the session.
|
|
59626
|
+
*/
|
|
59627
|
+
lastLiveTrackCount = /* @__PURE__ */ new Map();
|
|
59628
|
+
/**
|
|
58360
59629
|
* Per-device count of "motion refired while the session was already
|
|
58361
59630
|
* active" no-ops since the current session was dispatched. Drives the
|
|
58362
59631
|
* SAMPLED debug line in the dispatch path: on a busy camera the refire
|
|
@@ -58382,7 +59651,7 @@ var SessionDispatchController = class {
|
|
|
58382
59651
|
agentLoads = new TtlMemo({ load: () => this.deps.loadService.collectAgentLoad({ onlyEnabled: true }) });
|
|
58383
59652
|
nodeCaps = new TtlMemo({ load: () => this.deps.settingsStore.buildNodeCaps() });
|
|
58384
59653
|
nodeWeights = new TtlMemo({ load: () => this.deps.settingsStore.buildNodeWeights() });
|
|
58385
|
-
eligibleInferenceDevices = new TtlMemo({ load: (nodeId) => this.deps.getEligibleInferenceDevices(nodeId) });
|
|
59654
|
+
eligibleInferenceDevices = new TtlMemo({ load: (nodeId, read) => this.deps.getEligibleInferenceDevices(nodeId, read === "refresh" ? "one-round" : "await-catalog") });
|
|
58386
59655
|
inferenceDeviceCaps = new TtlMemo({ load: (nodeId) => this.deps.getInferenceDeviceCaps(nodeId) });
|
|
58387
59656
|
/** Background refresher for {@link agentLoads} et al. Null until first use. */
|
|
58388
59657
|
balancerRefreshTimer = null;
|
|
@@ -58477,6 +59746,17 @@ var SessionDispatchController = class {
|
|
|
58477
59746
|
this.lastSubjectSeenAt.set(deviceId, at);
|
|
58478
59747
|
}
|
|
58479
59748
|
/**
|
|
59749
|
+
* Record how many objects the tracker held on this frame — the count the
|
|
59750
|
+
* close line reports as `liveTracks` (D652). Every tracked frame, not only
|
|
59751
|
+
* the ones that hold the session: a still subject is exactly the case that
|
|
59752
|
+
* stops holding it. No-op without an active session, which also bounds the
|
|
59753
|
+
* map. Telemetry-lossy (D8): a dropped frame leaves the previous count.
|
|
59754
|
+
*/
|
|
59755
|
+
noteTrackedFrame(deviceId, liveTrackCount) {
|
|
59756
|
+
if (!this.sessionRegistry.has(deviceId)) return;
|
|
59757
|
+
this.lastLiveTrackCount.set(deviceId, liveTrackCount);
|
|
59758
|
+
}
|
|
59759
|
+
/**
|
|
58480
59760
|
* `sessionRegistry.has(deviceId)` — the closure other controllers (Task 3's
|
|
58481
59761
|
* standing-machinery guards, `index.ts`'s `isIdleSessionCamera`) consume
|
|
58482
59762
|
* instead of reaching into `sessionRegistry` directly.
|
|
@@ -58523,6 +59803,7 @@ var SessionDispatchController = class {
|
|
|
58523
59803
|
this.deps.dropAssignment(deviceId, "silent");
|
|
58524
59804
|
this.sessionRegistry.deregister(deviceId);
|
|
58525
59805
|
this.lastSubjectSeenAt.delete(deviceId);
|
|
59806
|
+
this.lastLiveTrackCount.delete(deviceId);
|
|
58526
59807
|
this.notePlacementChanged();
|
|
58527
59808
|
});
|
|
58528
59809
|
}
|
|
@@ -58761,11 +60042,34 @@ var SessionDispatchController = class {
|
|
|
58761
60042
|
}
|
|
58762
60043
|
});
|
|
58763
60044
|
const deviceSelectedAt = Date.now();
|
|
58764
|
-
const
|
|
58765
|
-
|
|
58766
|
-
|
|
58767
|
-
|
|
58768
|
-
|
|
60045
|
+
const outcome = await resolveAttachPipeline({
|
|
60046
|
+
deviceId,
|
|
60047
|
+
nodeId: targetNodeId,
|
|
60048
|
+
deviceKey,
|
|
60049
|
+
eligible: enabledDevices,
|
|
60050
|
+
caps: deviceCaps
|
|
60051
|
+
}, this.deps.settingsStore);
|
|
60052
|
+
if (outcome.kind === "root-refused") {
|
|
60053
|
+
const reason = `root-unrunnable:${targetNodeId}/${outcome.deviceKey ?? "default-pool"}`;
|
|
60054
|
+
if (this.sessionRefusals.get(deviceId) !== reason) {
|
|
60055
|
+
this.sessionRefusals.set(deviceId, reason);
|
|
60056
|
+
log.error("detection session refused — the elected device cannot run the camera root", {
|
|
60057
|
+
tags: {
|
|
60058
|
+
deviceId,
|
|
60059
|
+
nodeId: targetNodeId
|
|
60060
|
+
},
|
|
60061
|
+
meta: {
|
|
60062
|
+
reason,
|
|
60063
|
+
deviceKey: outcome.deviceKey ?? null,
|
|
60064
|
+
droppedRoots: outcome.droppedRoots.join(","),
|
|
60065
|
+
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"
|
|
60066
|
+
}
|
|
60067
|
+
});
|
|
60068
|
+
}
|
|
60069
|
+
releaseGuard();
|
|
60070
|
+
return null;
|
|
60071
|
+
}
|
|
60072
|
+
const pipeline = outcome.pipeline;
|
|
58769
60073
|
const zones = await this.deps.listZones(deviceId);
|
|
58770
60074
|
const sessionDetectionFps = detectionFpsForTrigger(config, trigger);
|
|
58771
60075
|
if (sessionDetectionFps !== config.detectionFps) log.info("session opened at the device-activity rate", { meta: {
|
|
@@ -58775,8 +60079,8 @@ var SessionDispatchController = class {
|
|
|
58775
60079
|
} });
|
|
58776
60080
|
const sessionConfig = {
|
|
58777
60081
|
deviceId,
|
|
58778
|
-
...deviceKey ? { deviceKey } : {},
|
|
58779
|
-
...inferenceDevices ? { inferenceDevices } : {},
|
|
60082
|
+
...pipeline.deviceKey ? { deviceKey: pipeline.deviceKey } : {},
|
|
60083
|
+
...pipeline.inferenceDevices ? { inferenceDevices: pipeline.inferenceDevices } : {},
|
|
58780
60084
|
motionCooldownMs: config.motionCooldownMs,
|
|
58781
60085
|
motionFps: config.motionFps,
|
|
58782
60086
|
detectionFps: sessionDetectionFps,
|
|
@@ -58784,8 +60088,8 @@ var SessionDispatchController = class {
|
|
|
58784
60088
|
detectionStreamId: config.detectionStreamId,
|
|
58785
60089
|
motionSources: [],
|
|
58786
60090
|
pipelineEnabled: config.pipelineEnabled,
|
|
58787
|
-
steps,
|
|
58788
|
-
audio:
|
|
60091
|
+
steps: pipeline.steps,
|
|
60092
|
+
audio: pipeline.audio,
|
|
58789
60093
|
zones,
|
|
58790
60094
|
detectionMode: "always-on",
|
|
58791
60095
|
audioMode: config.audioMode,
|
|
@@ -58804,6 +60108,13 @@ var SessionDispatchController = class {
|
|
|
58804
60108
|
attachedAt = Date.now();
|
|
58805
60109
|
this.deps.recordAssignment(deviceId, targetNodeId, decision.reason, decision.reason === "manual");
|
|
58806
60110
|
this.sessionRegistry.register(deviceId, targetNodeId, Date.now(), deviceKey);
|
|
60111
|
+
if (this.sessionRefusals.delete(deviceId)) log.info("detection session refusal cleared — a session opened", {
|
|
60112
|
+
tags: {
|
|
60113
|
+
deviceId,
|
|
60114
|
+
nodeId: targetNodeId
|
|
60115
|
+
},
|
|
60116
|
+
meta: deviceKey ? { deviceKey } : {}
|
|
60117
|
+
});
|
|
58807
60118
|
this.recheck?.noteSessionDispatched(deviceId, targetNodeId, sessionConfig);
|
|
58808
60119
|
log.info("detection session: dispatched on motion", {
|
|
58809
60120
|
tags: {
|
|
@@ -58812,9 +60123,7 @@ var SessionDispatchController = class {
|
|
|
58812
60123
|
},
|
|
58813
60124
|
meta: {
|
|
58814
60125
|
reason: decision.reason,
|
|
58815
|
-
...deviceKey ? { deviceKey } : {}
|
|
58816
|
-
...Object.keys(deviceBase).length > 0 ? { deviceBase } : {},
|
|
58817
|
-
...Object.keys(deviceOverride).length > 0 ? { deviceOverride } : {}
|
|
60126
|
+
...deviceKey ? { deviceKey } : {}
|
|
58818
60127
|
}
|
|
58819
60128
|
});
|
|
58820
60129
|
} finally {
|
|
@@ -58978,6 +60287,8 @@ var SessionDispatchController = class {
|
|
|
58978
60287
|
this.deps.dropAssignment(deviceId, "unassigned");
|
|
58979
60288
|
this.sessionRegistry.deregister(deviceId);
|
|
58980
60289
|
this.lastSubjectSeenAt.delete(deviceId);
|
|
60290
|
+
const liveTracks = this.lastLiveTrackCount.get(deviceId) ?? null;
|
|
60291
|
+
this.lastLiveTrackCount.delete(deviceId);
|
|
58981
60292
|
this.activeRefireCountByDevice.delete(deviceId);
|
|
58982
60293
|
this.notePlacementChanged();
|
|
58983
60294
|
const { openForMs, sinceSubjectSeenMs, maxHoldMs } = observed;
|
|
@@ -58988,6 +60299,7 @@ var SessionDispatchController = class {
|
|
|
58988
60299
|
openForMs,
|
|
58989
60300
|
sinceSubjectSeenMs,
|
|
58990
60301
|
subjectPresent,
|
|
60302
|
+
liveTracks,
|
|
58991
60303
|
maxHoldMs,
|
|
58992
60304
|
minOpenMs: MIN_SESSION_OPEN_MS,
|
|
58993
60305
|
subjectStaleMs: SUBJECT_PRESENT_STALE_MS
|
|
@@ -58999,6 +60311,17 @@ var SessionDispatchController = class {
|
|
|
58999
60311
|
});
|
|
59000
60312
|
return;
|
|
59001
60313
|
}
|
|
60314
|
+
if (liveTracks !== null && liveTracks > 0) {
|
|
60315
|
+
const designedValve = sinceSubjectSeenMs !== null;
|
|
60316
|
+
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";
|
|
60317
|
+
const extras = {
|
|
60318
|
+
tags: { nodeId: session.nodeId },
|
|
60319
|
+
meta
|
|
60320
|
+
};
|
|
60321
|
+
if (designedValve) log.info(line, extras);
|
|
60322
|
+
else log.warn(line, extras);
|
|
60323
|
+
return;
|
|
60324
|
+
}
|
|
59002
60325
|
log.info(reason === "motion_cleared" ? "detection session: closed on motion clear" : "detection session: closed after motion cooldown", {
|
|
59003
60326
|
tags: { nodeId: session.nodeId },
|
|
59004
60327
|
meta
|
|
@@ -59044,6 +60367,14 @@ var SessionDispatchController = class {
|
|
|
59044
60367
|
* down too, since it reads `sessionRegistry` fresh at the top of this same
|
|
59045
60368
|
* callback).
|
|
59046
60369
|
*/
|
|
60370
|
+
/**
|
|
60371
|
+
* Why this camera's LAST on-motion session was refused, or `undefined` when
|
|
60372
|
+
* it was not (or a session has opened since). The camera status renders it
|
|
60373
|
+
* as `pending:<reason>` — see `sessionRefusals`.
|
|
60374
|
+
*/
|
|
60375
|
+
refusalReason(deviceId) {
|
|
60376
|
+
return this.sessionRefusals.get(deviceId);
|
|
60377
|
+
}
|
|
59047
60378
|
async stopForDevice(deviceId) {
|
|
59048
60379
|
await this.sessionLocks.run(deviceId, async () => {
|
|
59049
60380
|
const session = this.sessionRegistry.get(deviceId);
|
|
@@ -59063,6 +60394,8 @@ var SessionDispatchController = class {
|
|
|
59063
60394
|
}
|
|
59064
60395
|
this.deps.dropActiveDetection(deviceId);
|
|
59065
60396
|
this.lastSubjectSeenAt.delete(deviceId);
|
|
60397
|
+
this.lastLiveTrackCount.delete(deviceId);
|
|
60398
|
+
this.sessionRefusals.delete(deviceId);
|
|
59066
60399
|
this.recheck?.forget(deviceId);
|
|
59067
60400
|
});
|
|
59068
60401
|
const watchNodeId = this.motionWatchByDevice.get(deviceId);
|
|
@@ -59103,6 +60436,8 @@ var SessionDispatchController = class {
|
|
|
59103
60436
|
this.sessionRegistry.clear();
|
|
59104
60437
|
this.motionWatchByDevice.clear();
|
|
59105
60438
|
this.lastSubjectSeenAt.clear();
|
|
60439
|
+
this.lastLiveTrackCount.clear();
|
|
60440
|
+
this.sessionRefusals.clear();
|
|
59106
60441
|
if (this.balancerRefreshTimer !== null) {
|
|
59107
60442
|
clearInterval(this.balancerRefreshTimer);
|
|
59108
60443
|
this.balancerRefreshTimer = null;
|
|
@@ -60072,7 +61407,8 @@ async function buildOrchestratorControllers(deps) {
|
|
|
60072
61407
|
watchdog: () => pipelineWatchdog,
|
|
60073
61408
|
localNodeId: () => localNodeId,
|
|
60074
61409
|
readPipelinePin: (deviceId) => deps.readPipelinePin(deviceId),
|
|
60075
|
-
getEligibleInferenceDevices: (nodeId) => deps.getEligibleInferenceDevices(nodeId),
|
|
61410
|
+
getEligibleInferenceDevices: (nodeId) => deps.getEligibleInferenceDevices(nodeId, "await-catalog"),
|
|
61411
|
+
getEligibleInferenceDevicesIfCached: (nodeId) => deps.getEligibleInferenceDevicesIfCached(nodeId),
|
|
60076
61412
|
isNodeInferenceUsable: (nodeId) => deps.isNodeInferenceUsable(nodeId),
|
|
60077
61413
|
getInferenceDeviceCaps: (nodeId) => deps.getInferenceDeviceCaps(nodeId),
|
|
60078
61414
|
readPipelineDevicePin: (deviceId) => deps.readPipelineDevicePin(deviceId),
|
|
@@ -60108,7 +61444,7 @@ async function buildOrchestratorControllers(deps) {
|
|
|
60108
61444
|
},
|
|
60109
61445
|
listZones: async (deviceId) => await zonesProvider?.listZones({ deviceId }) ?? [],
|
|
60110
61446
|
readPipelinePin: (deviceId) => deps.readPipelinePin(deviceId),
|
|
60111
|
-
getEligibleInferenceDevices: (nodeId) => deps.getEligibleInferenceDevices(nodeId),
|
|
61447
|
+
getEligibleInferenceDevices: (nodeId, read) => deps.getEligibleInferenceDevices(nodeId, read),
|
|
60112
61448
|
isNodeInferenceUsable: (nodeId) => deps.isNodeInferenceUsable(nodeId),
|
|
60113
61449
|
getInferenceDeviceCaps: (nodeId) => deps.getInferenceDeviceCaps(nodeId),
|
|
60114
61450
|
readPipelineDevicePin: (deviceId) => deps.readPipelineDevicePin(deviceId),
|
|
@@ -60151,7 +61487,7 @@ async function buildOrchestratorControllers(deps) {
|
|
|
60151
61487
|
getAudioAssignment: (deviceId) => audio.getAssignment(deviceId),
|
|
60152
61488
|
getCameraConfig: (deviceId) => ledger.getConfig(deviceId),
|
|
60153
61489
|
hasCameraConfig: (deviceId) => ledger.hasConfig(deviceId),
|
|
60154
|
-
getPendingReason: (deviceId) => ledger.getPendingReason(deviceId),
|
|
61490
|
+
getPendingReason: (deviceId) => ledger.getPendingReason(deviceId) ?? session.refusalReason(deviceId),
|
|
60155
61491
|
assignSource: (deviceId) => topology.assignSource(deviceId),
|
|
60156
61492
|
listAssignedDeviceIds: () => [...new Set([...ledger.listAssignedDeviceIds(), ...detectionWiring.activeDeviceIds()])],
|
|
60157
61493
|
isSessionCamera: (deviceId) => deps.isSessionCamera(deviceId),
|
|
@@ -60169,7 +61505,7 @@ async function buildOrchestratorControllers(deps) {
|
|
|
60169
61505
|
logger: deps.ctx().logger,
|
|
60170
61506
|
handleDeviceRegistered: (deviceId) => deps.handleDeviceRegistered(deviceId),
|
|
60171
61507
|
handleDeviceUnregistered: (deviceId) => deps.handleDeviceUnregistered(deviceId),
|
|
60172
|
-
|
|
61508
|
+
redispatchCamera: (config) => placement.redispatchCamera(config),
|
|
60173
61509
|
detachOn: (nodeId, deviceId) => deps.detachOn(nodeId, deviceId),
|
|
60174
61510
|
localNodeId: () => localNodeId,
|
|
60175
61511
|
activeDeviceIds: () => detectionWiring.activeDeviceIds(),
|
|
@@ -60199,6 +61535,7 @@ async function buildOrchestratorControllers(deps) {
|
|
|
60199
61535
|
loadService,
|
|
60200
61536
|
settingsStore,
|
|
60201
61537
|
attachOn: (nodeId, config) => deps.attachOn(nodeId, config),
|
|
61538
|
+
resolveAttachPipeline: (input) => placement.resolveAttachPipelineOnDevice(input),
|
|
60202
61539
|
rebalance: (opts) => deps.rebalance(opts),
|
|
60203
61540
|
scheduleReconcile: () => reconcile.scheduleReconcile(),
|
|
60204
61541
|
schedulePendingRetry: () => reconcile.schedulePendingRetry(),
|
|
@@ -60217,6 +61554,7 @@ async function buildOrchestratorControllers(deps) {
|
|
|
60217
61554
|
if (!nodeId.includes("/")) topology.removeKnownRunnerNode(nodeId);
|
|
60218
61555
|
nodeLifecycle.unsubscribeDetectionPipelineReadiness(nodeId);
|
|
60219
61556
|
audio.markNodeOffline(nodeId);
|
|
61557
|
+
deps.forgetInferenceDeviceProbe(nodeId);
|
|
60220
61558
|
nodeLifecycle.handleNodeDisconnect(nodeId).catch((err) => {
|
|
60221
61559
|
const msg = errMsg$1(err);
|
|
60222
61560
|
deps.ctx().logger.error("failover after disconnect failed", {
|
|
@@ -60332,26 +61670,12 @@ async function buildOrchestratorControllers(deps) {
|
|
|
60332
61670
|
fetchDevice: (deviceId) => deps.ctx().fetchDevice(deviceId),
|
|
60333
61671
|
onRulesChanged: (deviceId, stage) => {
|
|
60334
61672
|
if (stage !== "package") return;
|
|
60335
|
-
|
|
60336
|
-
|
|
60337
|
-
|
|
60338
|
-
|
|
60339
|
-
|
|
60340
|
-
|
|
60341
|
-
steps: resolved.steps,
|
|
60342
|
-
audio: resolved.audio ?? null
|
|
60343
|
-
};
|
|
60344
|
-
ledger.setConfig(deviceId, next);
|
|
60345
|
-
return deps.attachOn(assignment.agentNodeId, next);
|
|
60346
|
-
}).catch((err) => {
|
|
60347
|
-
deps.ctx().logger.warn("package zone-rule change re-dispatch failed", {
|
|
60348
|
-
tags: {
|
|
60349
|
-
deviceId,
|
|
60350
|
-
nodeId: assignment.agentNodeId
|
|
60351
|
-
},
|
|
60352
|
-
meta: { error: errMsg$1(err) }
|
|
60353
|
-
});
|
|
60354
|
-
});
|
|
61673
|
+
reattachAfterPackageZoneChange({
|
|
61674
|
+
ledger,
|
|
61675
|
+
resolveAttachPipeline: (input) => placement.resolveAttachPipelineOnDevice(input),
|
|
61676
|
+
attachOn: (nodeId, config) => deps.attachOn(nodeId, config),
|
|
61677
|
+
logger: deps.ctx().logger
|
|
61678
|
+
}, deviceId);
|
|
60355
61679
|
}
|
|
60356
61680
|
});
|
|
60357
61681
|
zoneRulesProviderRef = zoneRulesProvider;
|
|
@@ -60415,6 +61739,7 @@ async function buildOrchestratorControllers(deps) {
|
|
|
60415
61739
|
getActiveDetectionConfig: (deviceId) => detectionWiring.getActiveDetectionConfig(deviceId),
|
|
60416
61740
|
handleSessionMotion: (deviceId, detected, emittedAt) => session.handleSessionMotion(deviceId, detected, emittedAt),
|
|
60417
61741
|
noteSessionSubjectPresent: (deviceId) => session.noteSubjectPresent(deviceId),
|
|
61742
|
+
noteSessionTrackedFrame: (deviceId, liveTrackCount) => session.noteTrackedFrame(deviceId, liveTrackCount),
|
|
60418
61743
|
noteWatchdogSignal: (deviceId, stage) => pipelineWatchdog?.noteSignal(deviceId, stage)
|
|
60419
61744
|
});
|
|
60420
61745
|
reconcile.scheduleReconcile();
|
|
@@ -61139,6 +62464,15 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
61139
62464
|
/** This node's Moleculer nodeId (from this.ctx.kernel.localNodeId). */
|
|
61140
62465
|
localNodeId = "hub";
|
|
61141
62466
|
/**
|
|
62467
|
+
* A shutdown latch for {@link getClusterModelChoice} (D651): cleared on the
|
|
62468
|
+
* first line of `onShutdown`, so an owner that is tearing down stops
|
|
62469
|
+
* answering. Set at the end of `onInitialize`; it is also false before that,
|
|
62470
|
+
* but nothing can call the method then — the cap provider is registered
|
|
62471
|
+
* from what `onInitialize` RETURNS, so the pre-init state is unreachable and
|
|
62472
|
+
* not a claim this flag makes.
|
|
62473
|
+
*/
|
|
62474
|
+
clusterModelChoiceReady = false;
|
|
62475
|
+
/**
|
|
61142
62476
|
* Single owner of the shared placement state (`assignments`/`cameraConfigs`/
|
|
61143
62477
|
* `pendingReasons`/`dispatchingDevices`) — see `placement-ledger.ts` (S6
|
|
61144
62478
|
* extraction). Constructed FIRST in `onInitialize` (several other
|
|
@@ -61383,7 +62717,9 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
61383
62717
|
attachOn: (nodeId, config) => this.attachOn(nodeId, config),
|
|
61384
62718
|
detachOn: (nodeId, deviceId) => this.detachOn(nodeId, deviceId),
|
|
61385
62719
|
readPipelinePin: (deviceId) => this.readPipelinePin(deviceId),
|
|
61386
|
-
getEligibleInferenceDevices: (nodeId) => this.resolveEligibleInferenceDevices(nodeId),
|
|
62720
|
+
getEligibleInferenceDevices: (nodeId, read) => this.resolveEligibleInferenceDevices(nodeId, read),
|
|
62721
|
+
getEligibleInferenceDevicesIfCached: (nodeId) => this.resolveEligibleInferenceDevicesIfCached(nodeId),
|
|
62722
|
+
forgetInferenceDeviceProbe: (nodeId) => this.forgetInferenceDeviceProbe(nodeId),
|
|
61387
62723
|
isNodeInferenceUsable: (nodeId) => this.isNodeInferenceUsable(nodeId),
|
|
61388
62724
|
getInferenceDeviceCaps: (nodeId) => this.getInferenceDeviceCaps(nodeId),
|
|
61389
62725
|
readPipelineDevicePin: (deviceId) => this.readPipelineDevicePin(deviceId),
|
|
@@ -61410,7 +62746,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
61410
62746
|
state: this.state.bind(this),
|
|
61411
62747
|
rebalance: (opts) => this.rebalance(opts),
|
|
61412
62748
|
setCameraPipelineForAgent: (input) => this.setCameraPipelineForAgent(input),
|
|
61413
|
-
dispatchCamera: (config) => this.dispatchCamera(config),
|
|
61414
62749
|
maintenancePaused: () => this.storageMigrationLeaseId !== null,
|
|
61415
62750
|
setTopology: (topology) => {
|
|
61416
62751
|
this.topology = topology;
|
|
@@ -61455,6 +62790,8 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
61455
62790
|
this.autoRebalanceTimer = controllers.autoRebalanceTimer;
|
|
61456
62791
|
this.nodeStressLts = controllers.nodeStressLts;
|
|
61457
62792
|
this.nodeStressLtsTimer = controllers.nodeStressLtsTimer;
|
|
62793
|
+
const widgetsProvider = { listWidgets: async () => pipelineOrchestratorWidgets };
|
|
62794
|
+
this.clusterModelChoiceReady = true;
|
|
61458
62795
|
return {
|
|
61459
62796
|
providers: [
|
|
61460
62797
|
{
|
|
@@ -61475,7 +62812,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
61475
62812
|
},
|
|
61476
62813
|
{
|
|
61477
62814
|
capability: addonWidgetsSourceCapability,
|
|
61478
|
-
provider:
|
|
62815
|
+
provider: widgetsProvider
|
|
61479
62816
|
},
|
|
61480
62817
|
{
|
|
61481
62818
|
capability: viewerUiCapability,
|
|
@@ -61524,6 +62861,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
61524
62861
|
};
|
|
61525
62862
|
}
|
|
61526
62863
|
async onShutdown() {
|
|
62864
|
+
this.clusterModelChoiceReady = false;
|
|
61527
62865
|
this.audio.beginShutdown();
|
|
61528
62866
|
this.session.shutdown();
|
|
61529
62867
|
if (this.pendingRetryTimer !== null) {
|
|
@@ -61669,6 +63007,16 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
61669
63007
|
* a server restart that rehydrates assignments from disk without
|
|
61670
63008
|
* re-dispatching).
|
|
61671
63009
|
*/
|
|
63010
|
+
/**
|
|
63011
|
+
* The operator's stored model choice for a cluster-scoped step (D651).
|
|
63012
|
+
* Answers `owner-not-ready` from the first line of `onShutdown`: an owner
|
|
63013
|
+
* that is tearing down must not answer at all. (Before `onInitialize`
|
|
63014
|
+
* returns nobody can reach this method — see `clusterModelChoiceReady`.)
|
|
63015
|
+
*/
|
|
63016
|
+
async getClusterModelChoice(input) {
|
|
63017
|
+
if (!this.clusterModelChoiceReady) return decideClusterModelChoice(input.stepId, "owner-not-ready");
|
|
63018
|
+
return decideClusterModelChoice(input.stepId, await this.readStoredGlobalKey(clusterModelSettingKey(input.stepId)));
|
|
63019
|
+
}
|
|
61672
63020
|
async getAgentLoad() {
|
|
61673
63021
|
await this.loadService.refreshDecodeHwaccels();
|
|
61674
63022
|
await this.loadService.collectAgentLoad();
|
|
@@ -61785,6 +63133,14 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
61785
63133
|
async evaluateRemoteAssignmentHealth() {
|
|
61786
63134
|
return this.reconcile.evaluateRemoteAssignmentHealth();
|
|
61787
63135
|
}
|
|
63136
|
+
/**
|
|
63137
|
+
* Thin seam onto `ReconcileController.retryPendingDispatches` — the same
|
|
63138
|
+
* `protected` pattern, for `pending-retry-catalog-wedge.spec.ts` to run one
|
|
63139
|
+
* sweep and bound it.
|
|
63140
|
+
*/
|
|
63141
|
+
async retryPendingDispatches() {
|
|
63142
|
+
return this.reconcile.retryPendingDispatches();
|
|
63143
|
+
}
|
|
61788
63144
|
async getCapabilityBindings(input) {
|
|
61789
63145
|
if (!this.ctx?.settings) return {};
|
|
61790
63146
|
return this.settingsStore.getCapabilityBindings(input.nodeId);
|
|
@@ -61867,6 +63223,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
61867
63223
|
success: true,
|
|
61868
63224
|
removed: false
|
|
61869
63225
|
};
|
|
63226
|
+
this.forgetInferenceDeviceProbe(input.agentNodeId);
|
|
61870
63227
|
await this.topology.refreshNodeCapabilities();
|
|
61871
63228
|
this.ctx.logger.info("agentSettings entry removed", { tags: { nodeId: input.agentNodeId } });
|
|
61872
63229
|
return {
|
|
@@ -62068,12 +63425,110 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
62068
63425
|
* the runner uses its single default accelerator. NEVER throws (an
|
|
62069
63426
|
* unreachable probe yields no eligible devices).
|
|
62070
63427
|
*/
|
|
62071
|
-
async resolveEligibleInferenceDevices(nodeId) {
|
|
62072
|
-
const
|
|
62073
|
-
|
|
62074
|
-
|
|
62075
|
-
const catalog = await this.settingsStore.
|
|
62076
|
-
|
|
63428
|
+
async resolveEligibleInferenceDevices(nodeId, read) {
|
|
63429
|
+
const [probe, poolHealth] = await Promise.all([this.probeNodeInferenceDevices(nodeId), this.probeNodeInferenceDeviceHealth(nodeId)]);
|
|
63430
|
+
this.noteInferenceDeviceProbe(nodeId, probe, poolHealth);
|
|
63431
|
+
const probed = probe.probed;
|
|
63432
|
+
const catalog = await this.settingsStore.awaitCatalogForNode(nodeId, read === "one-round" ? "one-round" : "unbounded");
|
|
63433
|
+
if (catalog === null && read === "one-round" && !this.settingsStore.isDisposed()) {
|
|
63434
|
+
this.ctx.logger.debug("inference device eligibility: node catalog not answered in one round — last answer kept", {
|
|
63435
|
+
tags: { nodeId },
|
|
63436
|
+
meta: { nodeId }
|
|
63437
|
+
});
|
|
63438
|
+
throw new Error(`inference device eligibility: node "${nodeId}" catalog not answered`);
|
|
63439
|
+
}
|
|
63440
|
+
return this.electableDevices(nodeId, probed, catalog);
|
|
63441
|
+
}
|
|
63442
|
+
/**
|
|
63443
|
+
* Forget a node's last device probe (pre-ship review, M4). Called when the
|
|
63444
|
+
* node leaves (`AgentOffline`) and when the operator removes its settings: a
|
|
63445
|
+
* node that comes back with a different accelerator set must be probed
|
|
63446
|
+
* again, not elected against the list it left with.
|
|
63447
|
+
*/
|
|
63448
|
+
forgetInferenceDeviceProbe(nodeId) {
|
|
63449
|
+
if (!this.lastInferenceDeviceProbe.delete(nodeId)) return;
|
|
63450
|
+
this.ctx.logger.info("inference device probe forgotten — the node left", {
|
|
63451
|
+
tags: { nodeId },
|
|
63452
|
+
meta: { nodeId }
|
|
63453
|
+
});
|
|
63454
|
+
}
|
|
63455
|
+
/**
|
|
63456
|
+
* The NON-BLOCKING sibling of {@link resolveEligibleInferenceDevices}, for
|
|
63457
|
+
* the RE-place paths (D650 round 3). Makes no live call to the node: the
|
|
63458
|
+
* device list and the pool health are the last ones it answered, the
|
|
63459
|
+
* catalog is the cached one. A node that has never answered any of them is
|
|
63460
|
+
* `unavailable` — the camera stays pending, named, and ONE background
|
|
63461
|
+
* refresh per node is started so the next round can place it. A cold cache
|
|
63462
|
+
* never fails open here: failing open on a re-place is how a camera gets
|
|
63463
|
+
* attached with no root.
|
|
63464
|
+
*/
|
|
63465
|
+
async resolveEligibleInferenceDevicesIfCached(nodeId) {
|
|
63466
|
+
const known = this.lastInferenceDeviceProbe.get(nodeId);
|
|
63467
|
+
if (known === void 0) {
|
|
63468
|
+
this.refreshInferenceDeviceProbe(nodeId);
|
|
63469
|
+
return {
|
|
63470
|
+
kind: "unavailable",
|
|
63471
|
+
nodeId,
|
|
63472
|
+
what: "device-health"
|
|
63473
|
+
};
|
|
63474
|
+
}
|
|
63475
|
+
const catalog = this.settingsStore.peekCatalogForAgent(nodeId);
|
|
63476
|
+
if (catalog === null) return {
|
|
63477
|
+
kind: "unavailable",
|
|
63478
|
+
nodeId,
|
|
63479
|
+
what: "catalog"
|
|
63480
|
+
};
|
|
63481
|
+
this.refreshInferenceDeviceProbe(nodeId);
|
|
63482
|
+
return {
|
|
63483
|
+
kind: "eligible",
|
|
63484
|
+
eligible: await this.electableDevices(nodeId, known, catalog)
|
|
63485
|
+
};
|
|
63486
|
+
}
|
|
63487
|
+
/**
|
|
63488
|
+
* The last device list each node's probe returned — what the re-place paths
|
|
63489
|
+
* elect against, with the pool-health mirror that probe updated. Written
|
|
63490
|
+
* when a probe COMPLETES, so a cold entry means "the node's probe has never
|
|
63491
|
+
* come back", never "came back with nothing".
|
|
63492
|
+
*/
|
|
63493
|
+
lastInferenceDeviceProbe = /* @__PURE__ */ new Map();
|
|
63494
|
+
/** Nodes with a background device probe in flight (single-flight per node). */
|
|
63495
|
+
inferenceProbeInFlight = /* @__PURE__ */ new Set();
|
|
63496
|
+
refreshInferenceDeviceProbe(nodeId) {
|
|
63497
|
+
if (this.inferenceProbeInFlight.has(nodeId)) return;
|
|
63498
|
+
this.inferenceProbeInFlight.add(nodeId);
|
|
63499
|
+
Promise.all([this.probeNodeInferenceDevices(nodeId), this.probeNodeInferenceDeviceHealth(nodeId)]).then(([probe, poolHealth]) => this.noteInferenceDeviceProbe(nodeId, probe, poolHealth)).finally(() => {
|
|
63500
|
+
this.inferenceProbeInFlight.delete(nodeId);
|
|
63501
|
+
});
|
|
63502
|
+
}
|
|
63503
|
+
/**
|
|
63504
|
+
* Fold one COMPLETED probe into the pool-health mirror and the last-known
|
|
63505
|
+
* list. "Completed" means the node ANSWERED: a probe that could not reach
|
|
63506
|
+
* it (`reachable: false`) changes nothing (D49, round 4) — its empty device
|
|
63507
|
+
* list is not an answer, and recording it as one both wiped a good
|
|
63508
|
+
* last-known list and turned a never-answered node into an "answered with
|
|
63509
|
+
* nothing" one, so `device-health-unavailable` stopped firing after a single
|
|
63510
|
+
* failure. A health read that failed (`null`) changes no verdict in the
|
|
63511
|
+
* mirror either, but it did come back — a node whose read never comes back
|
|
63512
|
+
* at all is the one a re-place must not wait on, and that node never reaches
|
|
63513
|
+
* this line.
|
|
63514
|
+
*/
|
|
63515
|
+
noteInferenceDeviceProbe(nodeId, probe, poolHealth) {
|
|
63516
|
+
if (!probe.reachable) {
|
|
63517
|
+
this.ctx.logger.debug("inference device probe unreachable — last known device list kept", {
|
|
63518
|
+
tags: { nodeId },
|
|
63519
|
+
meta: {
|
|
63520
|
+
nodeId,
|
|
63521
|
+
known: this.lastInferenceDeviceProbe.has(nodeId)
|
|
63522
|
+
}
|
|
63523
|
+
});
|
|
63524
|
+
return;
|
|
63525
|
+
}
|
|
63526
|
+
this.noteInferenceDeviceHealth(nodeId, poolHealth, probe.probed.map((d) => d.key));
|
|
63527
|
+
this.lastInferenceDeviceProbe.set(nodeId, probe.probed);
|
|
63528
|
+
}
|
|
63529
|
+
/** Eligibility from a device list + catalog; records usability like every dispatch read. */
|
|
63530
|
+
async electableDevices(nodeId, probed, catalog) {
|
|
63531
|
+
const eligibility = resolveInferenceDeviceEligibility(probed, (await this.settingsStore.readAgentSettingsMap())[nodeId]?.inferenceDevices ?? {}, catalog ? makeRootCapabilityGuard(catalog) : void 0, (deviceKey) => this.inferenceDeviceUsability.isUsable(nodeId, deviceKey));
|
|
62077
63532
|
const { eligible, excluded } = eligibility;
|
|
62078
63533
|
this.logInferenceDeviceShrinkage(nodeId, eligible, excluded);
|
|
62079
63534
|
this.noteNodeInferenceUsability(nodeId, resolveNodeInferenceUsability(eligibility));
|