@camstack/addon-pipeline-orchestrator 1.1.22 → 1.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +83 -3
- package/dist/index.mjs +83 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -24982,6 +24982,15 @@ var OrchestratorDiagnosticsSchema = object({
|
|
|
24982
24982
|
activeDetectionCount: number().int().min(0)
|
|
24983
24983
|
});
|
|
24984
24984
|
var pipelineOrchestratorActions = defineCustomActions({ dumpState: customAction(_void(), OrchestratorDiagnosticsSchema) });
|
|
24985
|
+
/**
|
|
24986
|
+
* Sentinel returned by `buildDetectionConfig` when the profile-slot READ
|
|
24987
|
+
* itself failed transiently (e.g. `listAllProfileSlots` discovery timeout
|
|
24988
|
+
* while the stream-broker is (re)starting) — as opposed to `null`, which
|
|
24989
|
+
* means "genuinely no assigned slot / not configured". Callers MUST treat
|
|
24990
|
+
* this differently from `null`: never stop active detection on a transient
|
|
24991
|
+
* read failure (the slots almost certainly still exist), and schedule a retry.
|
|
24992
|
+
*/
|
|
24993
|
+
var TRANSIENT_SLOT_READ = Symbol("transient-slot-read");
|
|
24985
24994
|
var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddon {
|
|
24986
24995
|
/** This node's Moleculer nodeId (from this.ctx.kernel.localNodeId). */
|
|
24987
24996
|
localNodeId = "hub";
|
|
@@ -25205,6 +25214,15 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
25205
25214
|
loadShedResumeTimer = null;
|
|
25206
25215
|
/** Per-camera guard so repeated low-fps snapshots don't stack relocate/pause. */
|
|
25207
25216
|
shedInFlight = /* @__PURE__ */ new Set();
|
|
25217
|
+
/**
|
|
25218
|
+
* Per-device backoff for retrying {@link handleDeviceRegistered} after a
|
|
25219
|
+
* TRANSIENT profile-slot read failure ({@link TRANSIENT_SLOT_READ}). Timers
|
|
25220
|
+
* are `unref`'d and cleared on shutdown / device removal.
|
|
25221
|
+
*/
|
|
25222
|
+
slotReadRetryTimers = /* @__PURE__ */ new Map();
|
|
25223
|
+
slotReadRetryAttempts = /* @__PURE__ */ new Map();
|
|
25224
|
+
static SLOT_READ_MAX_RETRIES = 6;
|
|
25225
|
+
static SLOT_READ_RETRY_BASE_MS = 500;
|
|
25208
25226
|
/** Pending `scheduleReconcile` debounce timer. */
|
|
25209
25227
|
reconcileTimer = null;
|
|
25210
25228
|
/** True while `reconcileDispatch` is awaiting the RPC round-trip. */
|
|
@@ -25672,6 +25690,9 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
25672
25690
|
clearTimeout(this.pendingRetryDebounceTimer);
|
|
25673
25691
|
this.pendingRetryDebounceTimer = null;
|
|
25674
25692
|
}
|
|
25693
|
+
for (const t of this.slotReadRetryTimers.values()) clearTimeout(t);
|
|
25694
|
+
this.slotReadRetryTimers.clear();
|
|
25695
|
+
this.slotReadRetryAttempts.clear();
|
|
25675
25696
|
this.unsubDeviceRegistered?.();
|
|
25676
25697
|
this.unsubDeviceRegistered = null;
|
|
25677
25698
|
this.unsubDeviceUnregistered?.();
|
|
@@ -26961,6 +26982,47 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
26961
26982
|
}
|
|
26962
26983
|
}
|
|
26963
26984
|
/**
|
|
26985
|
+
* Bounded per-device retry of {@link handleDeviceRegistered} after a
|
|
26986
|
+
* TRANSIENT profile-slot read failure ({@link TRANSIENT_SLOT_READ}). The
|
|
26987
|
+
* read (`listAllProfileSlots`) can time out during a broker (re)start; we
|
|
26988
|
+
* must neither stop a running camera nor leave a not-yet-started camera
|
|
26989
|
+
* stranded — so re-run the registration handler with exponential backoff
|
|
26990
|
+
* until the read succeeds or the budget is exhausted. Reset on any
|
|
26991
|
+
* successful read ({@link clearSlotReadRetry}).
|
|
26992
|
+
*/
|
|
26993
|
+
scheduleSlotReadRetry(deviceId) {
|
|
26994
|
+
const attempts = this.slotReadRetryAttempts.get(deviceId) ?? 0;
|
|
26995
|
+
if (attempts >= PipelineOrchestratorAddon.SLOT_READ_MAX_RETRIES) {
|
|
26996
|
+
this.ctx.logger.warn("slot-read retry budget exhausted — leaving detection as-is", { tags: { deviceId } });
|
|
26997
|
+
this.slotReadRetryAttempts.delete(deviceId);
|
|
26998
|
+
return;
|
|
26999
|
+
}
|
|
27000
|
+
const existing = this.slotReadRetryTimers.get(deviceId);
|
|
27001
|
+
if (existing) clearTimeout(existing);
|
|
27002
|
+
const delay = PipelineOrchestratorAddon.SLOT_READ_RETRY_BASE_MS * 2 ** attempts;
|
|
27003
|
+
this.slotReadRetryAttempts.set(deviceId, attempts + 1);
|
|
27004
|
+
const timer = setTimeout(() => {
|
|
27005
|
+
this.slotReadRetryTimers.delete(deviceId);
|
|
27006
|
+
this.handleDeviceRegistered(deviceId).catch((err) => {
|
|
27007
|
+
this.ctx.logger.debug("slot-read retry: handleDeviceRegistered failed", {
|
|
27008
|
+
tags: { deviceId },
|
|
27009
|
+
meta: { error: errMsg(err) }
|
|
27010
|
+
});
|
|
27011
|
+
});
|
|
27012
|
+
}, delay);
|
|
27013
|
+
if (typeof timer === "object" && timer !== null && "unref" in timer) timer.unref();
|
|
27014
|
+
this.slotReadRetryTimers.set(deviceId, timer);
|
|
27015
|
+
}
|
|
27016
|
+
/** Cancel any pending slot-read retry + reset the budget for a device. */
|
|
27017
|
+
clearSlotReadRetry(deviceId) {
|
|
27018
|
+
const existing = this.slotReadRetryTimers.get(deviceId);
|
|
27019
|
+
if (existing) {
|
|
27020
|
+
clearTimeout(existing);
|
|
27021
|
+
this.slotReadRetryTimers.delete(deviceId);
|
|
27022
|
+
}
|
|
27023
|
+
this.slotReadRetryAttempts.delete(deviceId);
|
|
27024
|
+
}
|
|
27025
|
+
/**
|
|
26964
27026
|
* Coalesce bursts of capacity/eligibility/readiness signals into a single
|
|
26965
27027
|
* `retryPendingDispatches` pass. Mirrors `scheduleReconcile`, but on a
|
|
26966
27028
|
* dedicated (longer) debounce so a raise-cap / node-connect flurry doesn't
|
|
@@ -28837,7 +28899,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
28837
28899
|
*/
|
|
28838
28900
|
async fetchAssignedProfiles(deviceId) {
|
|
28839
28901
|
const api = this.api;
|
|
28840
|
-
if (!api) return
|
|
28902
|
+
if (!api) return null;
|
|
28841
28903
|
try {
|
|
28842
28904
|
const slots = await api.streamBroker.listAllProfileSlots.query();
|
|
28843
28905
|
const out = /* @__PURE__ */ new Map();
|
|
@@ -28847,8 +28909,12 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
28847
28909
|
out.set(slot.profile, slot.sourceCamStreamId);
|
|
28848
28910
|
}
|
|
28849
28911
|
return out;
|
|
28850
|
-
} catch {
|
|
28851
|
-
|
|
28912
|
+
} catch (err) {
|
|
28913
|
+
this.ctx.logger.debug("fetchAssignedProfiles: slot read failed (transient) — treating as unknown", {
|
|
28914
|
+
tags: { deviceId },
|
|
28915
|
+
meta: { error: errMsg(err) }
|
|
28916
|
+
});
|
|
28917
|
+
return null;
|
|
28852
28918
|
}
|
|
28853
28919
|
}
|
|
28854
28920
|
/**
|
|
@@ -28867,6 +28933,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
28867
28933
|
*/
|
|
28868
28934
|
async buildDetectionConfig(deviceId) {
|
|
28869
28935
|
const assigned = await this.fetchAssignedProfiles(deviceId);
|
|
28936
|
+
if (assigned === null) return TRANSIENT_SLOT_READ;
|
|
28870
28937
|
if (assigned.size === 0) return null;
|
|
28871
28938
|
const resolved = await this.resolveDeviceDetectionSettings(deviceId);
|
|
28872
28939
|
if (!resolved) return null;
|
|
@@ -29010,6 +29077,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
29010
29077
|
}
|
|
29011
29078
|
/** Stop detection and purge all persisted config for a removed device. */
|
|
29012
29079
|
async handleDeviceUnregistered(deviceId) {
|
|
29080
|
+
this.clearSlotReadRetry(deviceId);
|
|
29013
29081
|
await this.stopDetection(deviceId);
|
|
29014
29082
|
this.zonesProvider?.forgetDevice(deviceId);
|
|
29015
29083
|
this.zoneRulesProvider?.forgetDevice(deviceId);
|
|
@@ -29031,6 +29099,12 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
29031
29099
|
const log = this.ctx.logger.withTags({ deviceId });
|
|
29032
29100
|
log.info("handleDeviceRegistered", { meta: { phase: "handleDeviceRegistered" } });
|
|
29033
29101
|
const config = await this.buildDetectionConfig(deviceId);
|
|
29102
|
+
if (config === TRANSIENT_SLOT_READ) {
|
|
29103
|
+
log.warn("Profile-slot read failed transiently — keeping detection, scheduling retry");
|
|
29104
|
+
this.scheduleSlotReadRetry(deviceId);
|
|
29105
|
+
return;
|
|
29106
|
+
}
|
|
29107
|
+
this.clearSlotReadRetry(deviceId);
|
|
29034
29108
|
log.info("[pipeline-orchestrator] buildDetectionConfig", config ? { meta: {
|
|
29035
29109
|
enabled: config.enabled,
|
|
29036
29110
|
motionStreamId: config.motionStreamId,
|
|
@@ -29096,6 +29170,12 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
29096
29170
|
const log = this.ctx.logger.withTags({ deviceId });
|
|
29097
29171
|
if (this.activeDetections.has(deviceId)) await this.stopDetection(deviceId);
|
|
29098
29172
|
const config = await this.buildDetectionConfig(deviceId);
|
|
29173
|
+
if (config === TRANSIENT_SLOT_READ) {
|
|
29174
|
+
log.warn("Settings changed — slot read failed transiently, scheduling retry");
|
|
29175
|
+
this.scheduleSlotReadRetry(deviceId);
|
|
29176
|
+
return;
|
|
29177
|
+
}
|
|
29178
|
+
this.clearSlotReadRetry(deviceId);
|
|
29099
29179
|
if (!config) {
|
|
29100
29180
|
log.info("Settings changed — no assigned slot, detection stopped");
|
|
29101
29181
|
return;
|
package/dist/index.mjs
CHANGED
|
@@ -24978,6 +24978,15 @@ var OrchestratorDiagnosticsSchema = object({
|
|
|
24978
24978
|
activeDetectionCount: number().int().min(0)
|
|
24979
24979
|
});
|
|
24980
24980
|
var pipelineOrchestratorActions = defineCustomActions({ dumpState: customAction(_void(), OrchestratorDiagnosticsSchema) });
|
|
24981
|
+
/**
|
|
24982
|
+
* Sentinel returned by `buildDetectionConfig` when the profile-slot READ
|
|
24983
|
+
* itself failed transiently (e.g. `listAllProfileSlots` discovery timeout
|
|
24984
|
+
* while the stream-broker is (re)starting) — as opposed to `null`, which
|
|
24985
|
+
* means "genuinely no assigned slot / not configured". Callers MUST treat
|
|
24986
|
+
* this differently from `null`: never stop active detection on a transient
|
|
24987
|
+
* read failure (the slots almost certainly still exist), and schedule a retry.
|
|
24988
|
+
*/
|
|
24989
|
+
var TRANSIENT_SLOT_READ = Symbol("transient-slot-read");
|
|
24981
24990
|
var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddon {
|
|
24982
24991
|
/** This node's Moleculer nodeId (from this.ctx.kernel.localNodeId). */
|
|
24983
24992
|
localNodeId = "hub";
|
|
@@ -25201,6 +25210,15 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
25201
25210
|
loadShedResumeTimer = null;
|
|
25202
25211
|
/** Per-camera guard so repeated low-fps snapshots don't stack relocate/pause. */
|
|
25203
25212
|
shedInFlight = /* @__PURE__ */ new Set();
|
|
25213
|
+
/**
|
|
25214
|
+
* Per-device backoff for retrying {@link handleDeviceRegistered} after a
|
|
25215
|
+
* TRANSIENT profile-slot read failure ({@link TRANSIENT_SLOT_READ}). Timers
|
|
25216
|
+
* are `unref`'d and cleared on shutdown / device removal.
|
|
25217
|
+
*/
|
|
25218
|
+
slotReadRetryTimers = /* @__PURE__ */ new Map();
|
|
25219
|
+
slotReadRetryAttempts = /* @__PURE__ */ new Map();
|
|
25220
|
+
static SLOT_READ_MAX_RETRIES = 6;
|
|
25221
|
+
static SLOT_READ_RETRY_BASE_MS = 500;
|
|
25204
25222
|
/** Pending `scheduleReconcile` debounce timer. */
|
|
25205
25223
|
reconcileTimer = null;
|
|
25206
25224
|
/** True while `reconcileDispatch` is awaiting the RPC round-trip. */
|
|
@@ -25668,6 +25686,9 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
25668
25686
|
clearTimeout(this.pendingRetryDebounceTimer);
|
|
25669
25687
|
this.pendingRetryDebounceTimer = null;
|
|
25670
25688
|
}
|
|
25689
|
+
for (const t of this.slotReadRetryTimers.values()) clearTimeout(t);
|
|
25690
|
+
this.slotReadRetryTimers.clear();
|
|
25691
|
+
this.slotReadRetryAttempts.clear();
|
|
25671
25692
|
this.unsubDeviceRegistered?.();
|
|
25672
25693
|
this.unsubDeviceRegistered = null;
|
|
25673
25694
|
this.unsubDeviceUnregistered?.();
|
|
@@ -26957,6 +26978,47 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
26957
26978
|
}
|
|
26958
26979
|
}
|
|
26959
26980
|
/**
|
|
26981
|
+
* Bounded per-device retry of {@link handleDeviceRegistered} after a
|
|
26982
|
+
* TRANSIENT profile-slot read failure ({@link TRANSIENT_SLOT_READ}). The
|
|
26983
|
+
* read (`listAllProfileSlots`) can time out during a broker (re)start; we
|
|
26984
|
+
* must neither stop a running camera nor leave a not-yet-started camera
|
|
26985
|
+
* stranded — so re-run the registration handler with exponential backoff
|
|
26986
|
+
* until the read succeeds or the budget is exhausted. Reset on any
|
|
26987
|
+
* successful read ({@link clearSlotReadRetry}).
|
|
26988
|
+
*/
|
|
26989
|
+
scheduleSlotReadRetry(deviceId) {
|
|
26990
|
+
const attempts = this.slotReadRetryAttempts.get(deviceId) ?? 0;
|
|
26991
|
+
if (attempts >= PipelineOrchestratorAddon.SLOT_READ_MAX_RETRIES) {
|
|
26992
|
+
this.ctx.logger.warn("slot-read retry budget exhausted — leaving detection as-is", { tags: { deviceId } });
|
|
26993
|
+
this.slotReadRetryAttempts.delete(deviceId);
|
|
26994
|
+
return;
|
|
26995
|
+
}
|
|
26996
|
+
const existing = this.slotReadRetryTimers.get(deviceId);
|
|
26997
|
+
if (existing) clearTimeout(existing);
|
|
26998
|
+
const delay = PipelineOrchestratorAddon.SLOT_READ_RETRY_BASE_MS * 2 ** attempts;
|
|
26999
|
+
this.slotReadRetryAttempts.set(deviceId, attempts + 1);
|
|
27000
|
+
const timer = setTimeout(() => {
|
|
27001
|
+
this.slotReadRetryTimers.delete(deviceId);
|
|
27002
|
+
this.handleDeviceRegistered(deviceId).catch((err) => {
|
|
27003
|
+
this.ctx.logger.debug("slot-read retry: handleDeviceRegistered failed", {
|
|
27004
|
+
tags: { deviceId },
|
|
27005
|
+
meta: { error: errMsg(err) }
|
|
27006
|
+
});
|
|
27007
|
+
});
|
|
27008
|
+
}, delay);
|
|
27009
|
+
if (typeof timer === "object" && timer !== null && "unref" in timer) timer.unref();
|
|
27010
|
+
this.slotReadRetryTimers.set(deviceId, timer);
|
|
27011
|
+
}
|
|
27012
|
+
/** Cancel any pending slot-read retry + reset the budget for a device. */
|
|
27013
|
+
clearSlotReadRetry(deviceId) {
|
|
27014
|
+
const existing = this.slotReadRetryTimers.get(deviceId);
|
|
27015
|
+
if (existing) {
|
|
27016
|
+
clearTimeout(existing);
|
|
27017
|
+
this.slotReadRetryTimers.delete(deviceId);
|
|
27018
|
+
}
|
|
27019
|
+
this.slotReadRetryAttempts.delete(deviceId);
|
|
27020
|
+
}
|
|
27021
|
+
/**
|
|
26960
27022
|
* Coalesce bursts of capacity/eligibility/readiness signals into a single
|
|
26961
27023
|
* `retryPendingDispatches` pass. Mirrors `scheduleReconcile`, but on a
|
|
26962
27024
|
* dedicated (longer) debounce so a raise-cap / node-connect flurry doesn't
|
|
@@ -28833,7 +28895,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
28833
28895
|
*/
|
|
28834
28896
|
async fetchAssignedProfiles(deviceId) {
|
|
28835
28897
|
const api = this.api;
|
|
28836
|
-
if (!api) return
|
|
28898
|
+
if (!api) return null;
|
|
28837
28899
|
try {
|
|
28838
28900
|
const slots = await api.streamBroker.listAllProfileSlots.query();
|
|
28839
28901
|
const out = /* @__PURE__ */ new Map();
|
|
@@ -28843,8 +28905,12 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
28843
28905
|
out.set(slot.profile, slot.sourceCamStreamId);
|
|
28844
28906
|
}
|
|
28845
28907
|
return out;
|
|
28846
|
-
} catch {
|
|
28847
|
-
|
|
28908
|
+
} catch (err) {
|
|
28909
|
+
this.ctx.logger.debug("fetchAssignedProfiles: slot read failed (transient) — treating as unknown", {
|
|
28910
|
+
tags: { deviceId },
|
|
28911
|
+
meta: { error: errMsg(err) }
|
|
28912
|
+
});
|
|
28913
|
+
return null;
|
|
28848
28914
|
}
|
|
28849
28915
|
}
|
|
28850
28916
|
/**
|
|
@@ -28863,6 +28929,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
28863
28929
|
*/
|
|
28864
28930
|
async buildDetectionConfig(deviceId) {
|
|
28865
28931
|
const assigned = await this.fetchAssignedProfiles(deviceId);
|
|
28932
|
+
if (assigned === null) return TRANSIENT_SLOT_READ;
|
|
28866
28933
|
if (assigned.size === 0) return null;
|
|
28867
28934
|
const resolved = await this.resolveDeviceDetectionSettings(deviceId);
|
|
28868
28935
|
if (!resolved) return null;
|
|
@@ -29006,6 +29073,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
29006
29073
|
}
|
|
29007
29074
|
/** Stop detection and purge all persisted config for a removed device. */
|
|
29008
29075
|
async handleDeviceUnregistered(deviceId) {
|
|
29076
|
+
this.clearSlotReadRetry(deviceId);
|
|
29009
29077
|
await this.stopDetection(deviceId);
|
|
29010
29078
|
this.zonesProvider?.forgetDevice(deviceId);
|
|
29011
29079
|
this.zoneRulesProvider?.forgetDevice(deviceId);
|
|
@@ -29027,6 +29095,12 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
29027
29095
|
const log = this.ctx.logger.withTags({ deviceId });
|
|
29028
29096
|
log.info("handleDeviceRegistered", { meta: { phase: "handleDeviceRegistered" } });
|
|
29029
29097
|
const config = await this.buildDetectionConfig(deviceId);
|
|
29098
|
+
if (config === TRANSIENT_SLOT_READ) {
|
|
29099
|
+
log.warn("Profile-slot read failed transiently — keeping detection, scheduling retry");
|
|
29100
|
+
this.scheduleSlotReadRetry(deviceId);
|
|
29101
|
+
return;
|
|
29102
|
+
}
|
|
29103
|
+
this.clearSlotReadRetry(deviceId);
|
|
29030
29104
|
log.info("[pipeline-orchestrator] buildDetectionConfig", config ? { meta: {
|
|
29031
29105
|
enabled: config.enabled,
|
|
29032
29106
|
motionStreamId: config.motionStreamId,
|
|
@@ -29092,6 +29166,12 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
|
|
|
29092
29166
|
const log = this.ctx.logger.withTags({ deviceId });
|
|
29093
29167
|
if (this.activeDetections.has(deviceId)) await this.stopDetection(deviceId);
|
|
29094
29168
|
const config = await this.buildDetectionConfig(deviceId);
|
|
29169
|
+
if (config === TRANSIENT_SLOT_READ) {
|
|
29170
|
+
log.warn("Settings changed — slot read failed transiently, scheduling retry");
|
|
29171
|
+
this.scheduleSlotReadRetry(deviceId);
|
|
29172
|
+
return;
|
|
29173
|
+
}
|
|
29174
|
+
this.clearSlotReadRetry(deviceId);
|
|
29095
29175
|
if (!config) {
|
|
29096
29176
|
log.info("Settings changed — no assigned slot, detection stopped");
|
|
29097
29177
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-pipeline-orchestrator",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.23",
|
|
4
4
|
"description": "Hub-side camera-to-agent load balancer — tracks runner capacity and dispatches attachCamera calls to the optimal pipeline-runner instance",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|