@camstack/addon-terminal 0.1.11 → 0.1.13
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/addon.js +1134 -214
- package/dist/addon.mjs +1134 -214
- package/package.json +2 -2
package/dist/addon.js
CHANGED
|
@@ -7627,12 +7627,11 @@ var RecordingConfigSchema = object({
|
|
|
7627
7627
|
/**
|
|
7628
7628
|
* Entity-relocation job state (storage entity-routing spec, Phase 4).
|
|
7629
7629
|
*
|
|
7630
|
-
* One shape shared by the recorder
|
|
7631
|
-
*
|
|
7632
|
-
*
|
|
7633
|
-
*
|
|
7634
|
-
*
|
|
7635
|
-
* row on the owning addon's surface.
|
|
7630
|
+
* One shape shared by the recorder and pipeline-analytics internal movers.
|
|
7631
|
+
* The public admin surface is `storage-migration`; child jobs remain in RAM
|
|
7632
|
+
* because copy-if-absent, verify, delete and index/row repoint are resumable.
|
|
7633
|
+
* Each completed/failed run also lands one durable ops-log row on its owning
|
|
7634
|
+
* addon surface.
|
|
7636
7635
|
*/
|
|
7637
7636
|
var RelocateJobStateSchema = _enum([
|
|
7638
7637
|
"running",
|
|
@@ -7659,19 +7658,100 @@ var RelocateJobSchema = object({
|
|
|
7659
7658
|
finishedAt: number().nullable(),
|
|
7660
7659
|
error: string().nullable()
|
|
7661
7660
|
});
|
|
7661
|
+
/** Profile-derived footage selection used only by the migration coordinator:
|
|
7662
|
+
* `recordings` owns high+mid; `recordingsLow` owns low. */
|
|
7663
|
+
var RelocateFootageClassSchema = _enum(["recordings", "recordingsLow"]);
|
|
7662
7664
|
var RelocateFootageInputSchema = object({
|
|
7663
|
-
deviceId: number().optional(),
|
|
7664
7665
|
fromLocationId: string(),
|
|
7665
7666
|
toLocationId: string(),
|
|
7666
7667
|
entities: array(_enum(["segments"])).optional(),
|
|
7668
|
+
/** Limits relocation to the logical profile class. Omit only for the
|
|
7669
|
+
* pre-orchestration compatibility path. */
|
|
7670
|
+
footageClass: RelocateFootageClassSchema.optional(),
|
|
7667
7671
|
/** Copy throttle in MB/s (default 40) — the drain is a background chore,
|
|
7668
7672
|
* never allowed to starve live writers. */
|
|
7669
7673
|
throttleMbps: number().min(1).max(1e3).optional()
|
|
7670
7674
|
});
|
|
7671
|
-
|
|
7672
|
-
|
|
7675
|
+
/** Internal, lease-scoped participant operation. It is intentionally separate
|
|
7676
|
+
* from persistent recording settings: a migration never changes
|
|
7677
|
+
* `RecordingConfig.enabled` or camera wrapper bindings. */
|
|
7678
|
+
var StorageMigrationLeaseInputSchema = object({ leaseId: string().min(1) });
|
|
7679
|
+
var StorageMigrationFootageMoveInputSchema = RelocateFootageInputSchema.extend({ leaseId: string().min(1) });
|
|
7680
|
+
var StorageMigrationMediaMoveInputSchema = object({
|
|
7673
7681
|
toLocationId: string(),
|
|
7674
7682
|
throttleMbps: number().min(1).max(1e3).optional()
|
|
7683
|
+
}).extend({ leaseId: string().min(1) });
|
|
7684
|
+
/** The independently selectable logical storage classes. `recordings`
|
|
7685
|
+
* encompasses the high and mid segment profiles; `recordingsLow` is low
|
|
7686
|
+
* segments; `eventMedia` is post-analysis blobs. */
|
|
7687
|
+
var StorageMigrationClassSchema = _enum([
|
|
7688
|
+
"recordings",
|
|
7689
|
+
"recordingsLow",
|
|
7690
|
+
"eventMedia"
|
|
7691
|
+
]);
|
|
7692
|
+
/** A destination is always an existing, fully-qualified location id. The
|
|
7693
|
+
* migration API intentionally never changes a source location's `basePath`:
|
|
7694
|
+
* callers create a new `<type>:<slug>` location, then select it here. */
|
|
7695
|
+
var StorageMigrationDestinationsSchema = object({
|
|
7696
|
+
recordings: string().min(1).optional(),
|
|
7697
|
+
recordingsLow: string().min(1).optional(),
|
|
7698
|
+
eventMedia: string().min(1).optional()
|
|
7699
|
+
}).refine((value) => Object.keys(value).length > 0, { message: "select at least one storage class" });
|
|
7700
|
+
/** Shared input for planning and starting an orchestrated storage migration. */
|
|
7701
|
+
var StorageMigrationInputSchema = object({
|
|
7702
|
+
destinations: StorageMigrationDestinationsSchema,
|
|
7703
|
+
throttleMbps: number().min(1).max(1e3).optional()
|
|
7704
|
+
});
|
|
7705
|
+
/** The durable coordinator state machine. The only phase that changes default
|
|
7706
|
+
* locations is `repointing`, after every selected mover has completed and been
|
|
7707
|
+
* verified. */
|
|
7708
|
+
var StorageMigrationPhaseSchema = _enum([
|
|
7709
|
+
"planning",
|
|
7710
|
+
"pausing",
|
|
7711
|
+
"moving",
|
|
7712
|
+
"verifying",
|
|
7713
|
+
"repointing",
|
|
7714
|
+
"refreshing",
|
|
7715
|
+
"resuming",
|
|
7716
|
+
"done",
|
|
7717
|
+
"failed",
|
|
7718
|
+
"cancelled"
|
|
7719
|
+
]);
|
|
7720
|
+
var StorageMigrationParticipantSchema = _enum([
|
|
7721
|
+
"pipeline",
|
|
7722
|
+
"recorder",
|
|
7723
|
+
"analytics"
|
|
7724
|
+
]);
|
|
7725
|
+
var StorageMigrationMoveSchema = object({
|
|
7726
|
+
storageClass: StorageMigrationClassSchema,
|
|
7727
|
+
fromLocationId: string(),
|
|
7728
|
+
toLocationId: string(),
|
|
7729
|
+
moverJobId: string().nullable(),
|
|
7730
|
+
state: RelocateJobStateSchema.nullable(),
|
|
7731
|
+
error: string().nullable()
|
|
7732
|
+
});
|
|
7733
|
+
var StorageMigrationJobSchema = object({
|
|
7734
|
+
jobId: string(),
|
|
7735
|
+
phase: StorageMigrationPhaseSchema,
|
|
7736
|
+
destinations: StorageMigrationDestinationsSchema,
|
|
7737
|
+
throttleMbps: number(),
|
|
7738
|
+
moves: array(StorageMigrationMoveSchema),
|
|
7739
|
+
pauseLeaseId: string().nullable(),
|
|
7740
|
+
pausedParticipants: array(StorageMigrationParticipantSchema),
|
|
7741
|
+
repointed: boolean(),
|
|
7742
|
+
cancelRequested: boolean(),
|
|
7743
|
+
startedAt: number(),
|
|
7744
|
+
updatedAt: number(),
|
|
7745
|
+
finishedAt: number().nullable(),
|
|
7746
|
+
error: string().nullable()
|
|
7747
|
+
});
|
|
7748
|
+
var StorageMigrationPlanSchema = object({
|
|
7749
|
+
destinations: StorageMigrationDestinationsSchema,
|
|
7750
|
+
moves: array(object({
|
|
7751
|
+
storageClass: StorageMigrationClassSchema,
|
|
7752
|
+
fromLocationId: string(),
|
|
7753
|
+
toLocationId: string()
|
|
7754
|
+
}))
|
|
7675
7755
|
});
|
|
7676
7756
|
/**
|
|
7677
7757
|
* `StorageLocationType` — an addon-declared id that identifies the *kind* of
|
|
@@ -16270,13 +16350,19 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
16270
16350
|
}), method(object({ deviceId: number() }), EventPruneCountsSchema, {
|
|
16271
16351
|
kind: "mutation",
|
|
16272
16352
|
auth: "admin"
|
|
16273
|
-
}), method(
|
|
16353
|
+
}), method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
|
|
16274
16354
|
kind: "mutation",
|
|
16275
16355
|
auth: "admin"
|
|
16276
|
-
}), method(object({
|
|
16277
|
-
kind: "
|
|
16356
|
+
}), method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
|
|
16357
|
+
kind: "mutation",
|
|
16278
16358
|
auth: "admin"
|
|
16279
|
-
}), method(
|
|
16359
|
+
}), method(StorageMigrationLeaseInputSchema, object({ refreshed: literal(true) }), {
|
|
16360
|
+
kind: "mutation",
|
|
16361
|
+
auth: "admin"
|
|
16362
|
+
}), method(StorageMigrationMediaMoveInputSchema, object({ jobId: string() }), {
|
|
16363
|
+
kind: "mutation",
|
|
16364
|
+
auth: "admin"
|
|
16365
|
+
}), method(object({ jobId: string() }), RelocateJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
|
|
16280
16366
|
kind: "mutation",
|
|
16281
16367
|
auth: "admin"
|
|
16282
16368
|
}), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
|
|
@@ -17806,7 +17892,13 @@ var NodeInferenceDevicesSchema = object({
|
|
|
17806
17892
|
reachable: boolean(),
|
|
17807
17893
|
devices: array(NodeInferenceDeviceSchema).readonly()
|
|
17808
17894
|
});
|
|
17809
|
-
method(object({
|
|
17895
|
+
method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
|
|
17896
|
+
kind: "mutation",
|
|
17897
|
+
auth: "admin"
|
|
17898
|
+
}), method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
|
|
17899
|
+
kind: "mutation",
|
|
17900
|
+
auth: "admin"
|
|
17901
|
+
}), method(object({
|
|
17810
17902
|
deviceId: number(),
|
|
17811
17903
|
agentNodeId: string()
|
|
17812
17904
|
}), object({ success: literal(true) }), {
|
|
@@ -18246,7 +18338,33 @@ var SnapshotImageSchema = object({
|
|
|
18246
18338
|
base64: string(),
|
|
18247
18339
|
contentType: string()
|
|
18248
18340
|
});
|
|
18249
|
-
|
|
18341
|
+
/**
|
|
18342
|
+
* snapshot — device-scoped capability for camera image capture.
|
|
18343
|
+
*
|
|
18344
|
+
* Two kinds of providers coexist behind this cap name:
|
|
18345
|
+
*
|
|
18346
|
+
* - **Native** providers (kind:'native'): registered per-device by
|
|
18347
|
+
* device-driver addons (RtspCamera, OnvifCamera, …) via
|
|
18348
|
+
* `DeviceContext.registerNativeCap`. Each knows how to fetch a frame
|
|
18349
|
+
* straight from the camera (HTTP snapshot URL, ONVIF action, etc.).
|
|
18350
|
+
*
|
|
18351
|
+
* - **Wrapper** providers (kind:'wrapper'): register as a system
|
|
18352
|
+
* provider (SnapshotAddon in `@camstack/system/builtins/snapshot`). The
|
|
18353
|
+
* wrapper owns the cache and invokes the native via
|
|
18354
|
+
* `ctx.getNativeProvider(snapshotCapability, deviceId)` on miss.
|
|
18355
|
+
*
|
|
18356
|
+
* Device-scoped routing: callers use `ctx.fetchDevice(id).snapshot.*`;
|
|
18357
|
+
* the DeviceProxy auto-injects `deviceId` + `nodeId` and dispatches to
|
|
18358
|
+
* the provider currently active for that device (wrapper wins when
|
|
18359
|
+
* activated via `setWrapperActive`, otherwise the native).
|
|
18360
|
+
*/
|
|
18361
|
+
/**
|
|
18362
|
+
* Live readable snapshot state — diagnostic info that a consumer can
|
|
18363
|
+
* pull to know when the last image was captured, how stale the cache
|
|
18364
|
+
* is, and which stream was used. Distinct from `getSnapshot` which
|
|
18365
|
+
* returns the JPEG itself.
|
|
18366
|
+
*/
|
|
18367
|
+
var SnapshotStatusSchema = object({
|
|
18250
18368
|
/** Ms epoch of the last successful capture. Null if none yet. */
|
|
18251
18369
|
lastCapturedAt: number().nullable(),
|
|
18252
18370
|
/** Age of the cached image in ms. Null if no cache. */
|
|
@@ -18256,64 +18374,133 @@ object({
|
|
|
18256
18374
|
/** Stream id used for the last capture ('high'|'mid'|'low' or custom). Null if via HTTP endpoint. */
|
|
18257
18375
|
lastStreamId: string().nullable()
|
|
18258
18376
|
});
|
|
18259
|
-
|
|
18260
|
-
|
|
18261
|
-
|
|
18262
|
-
|
|
18263
|
-
|
|
18264
|
-
|
|
18265
|
-
|
|
18266
|
-
|
|
18267
|
-
|
|
18268
|
-
|
|
18269
|
-
|
|
18270
|
-
|
|
18271
|
-
|
|
18272
|
-
|
|
18273
|
-
|
|
18274
|
-
|
|
18275
|
-
|
|
18276
|
-
|
|
18277
|
-
|
|
18278
|
-
|
|
18279
|
-
|
|
18280
|
-
|
|
18281
|
-
|
|
18282
|
-
|
|
18283
|
-
|
|
18284
|
-
|
|
18285
|
-
|
|
18286
|
-
|
|
18287
|
-
|
|
18288
|
-
|
|
18377
|
+
var snapshotCapability = {
|
|
18378
|
+
name: "snapshot",
|
|
18379
|
+
scope: "device",
|
|
18380
|
+
deviceNative: true,
|
|
18381
|
+
mode: "singleton",
|
|
18382
|
+
kind: "wrapper",
|
|
18383
|
+
defaultActive: true,
|
|
18384
|
+
deviceTypes: [DeviceType.Camera],
|
|
18385
|
+
exposesDeviceSettings: true,
|
|
18386
|
+
methods: {
|
|
18387
|
+
getSnapshot: method(object({
|
|
18388
|
+
deviceId: number(),
|
|
18389
|
+
streamId: string().optional(),
|
|
18390
|
+
/**
|
|
18391
|
+
* Bypass the cache freshness check and fetch directly from the
|
|
18392
|
+
* native (or stream-broker fallback). Triggered by the UI's
|
|
18393
|
+
* "refresh" button so an operator can force a fresh frame
|
|
18394
|
+
* even when the cache is well within the device's
|
|
18395
|
+
* `snapshotMaxAgeS` window.
|
|
18396
|
+
*
|
|
18397
|
+
* **`force` is an OPERATOR signal, not a freshness preference.** On a
|
|
18398
|
+
* battery camera it is the one thing that walks past the wrapper's
|
|
18399
|
+
* sleep gate and wakes the camera, so a background caller — a poller,
|
|
18400
|
+
* an event handler, a thumbnail — must NEVER set it. Every such caller
|
|
18401
|
+
* gets the cached frame, which on a sleeping battery camera is the
|
|
18402
|
+
* correct answer: stale but honest beats woken.
|
|
18403
|
+
*/
|
|
18404
|
+
force: boolean().optional()
|
|
18405
|
+
}), SnapshotImageSchema.nullable()),
|
|
18406
|
+
invalidateCache: method(object({ deviceId: number() }), _void(), {
|
|
18407
|
+
kind: "mutation",
|
|
18408
|
+
auth: "admin"
|
|
18409
|
+
}),
|
|
18410
|
+
/**
|
|
18411
|
+
* Cache-only batch overview — answers from the wrapper's in-memory cache in
|
|
18412
|
+
* O(n) and NEVER triggers a capture. Lets a grid skip requesting images for
|
|
18413
|
+
* devices that never produced a frame, and gives it an ETag per device for
|
|
18414
|
+
* conditional (304-able) image fetches. `lastCapturedAt`/`cacheAgeMs`/`etag`
|
|
18415
|
+
* are null for a device with no cached frame.
|
|
18416
|
+
*/
|
|
18417
|
+
getSnapshotOverview: systemMethod(object({ deviceIds: array(number()).min(1).max(200) }), array(object({
|
|
18418
|
+
deviceId: number(),
|
|
18419
|
+
lastCapturedAt: number().nullable(),
|
|
18420
|
+
cacheAgeMs: number().nullable(),
|
|
18421
|
+
etag: string().nullable()
|
|
18422
|
+
}))),
|
|
18423
|
+
/**
|
|
18424
|
+
* Signed, expiring links to a CLIENT-SIZED frame — and the demand signal
|
|
18425
|
+
* that makes those frames current.
|
|
18426
|
+
*
|
|
18427
|
+
* ## The problem it replaces
|
|
18428
|
+
*
|
|
18429
|
+
* `getSnapshotOverview` is cache-only by contract: it answers from whatever
|
|
18430
|
+
* the wrapper happens to hold and never captures. Under D93 the client
|
|
18431
|
+
* versions its image URL on that answer, and an image REQUEST was the only
|
|
18432
|
+
* demand signal. Both of those are satisfiable by the client's own image
|
|
18433
|
+
* cache — `expo-image` is URL-keyed and never revalidates — so a URL painted
|
|
18434
|
+
* in a previous session comes off disk with no network, no demand, and no
|
|
18435
|
+
* capture. Measured on the live hub: reopening after two minutes idle
|
|
18436
|
+
* painted 15 of 16 tiles at **168 s old** with zero HTTP requests, and the
|
|
18437
|
+
* fleet only recovered because a later poll happened to observe a different
|
|
18438
|
+
* identity.
|
|
18439
|
+
*
|
|
18440
|
+
* ## The two properties that fix it
|
|
18441
|
+
*
|
|
18442
|
+
* **It is an RPC, so no client cache can answer it.** The demand signal
|
|
18443
|
+
* always reaches the wrapper. This method therefore CAPTURES, where
|
|
18444
|
+
* `getSnapshotOverview` must never (D93) — the distinction is not "one is
|
|
18445
|
+
* newer" but that the overview poll is app-wide (a capturing overview would
|
|
18446
|
+
* dial every camera on the install) while this is called by a rendered
|
|
18447
|
+
* surface naming the tiles it is actually painting, at the width it is
|
|
18448
|
+
* painting them.
|
|
18449
|
+
*
|
|
18450
|
+
* Since 2026-08-11 this is the ONLY thing that refreshes a snapshot: the
|
|
18451
|
+
* server-side keep-warm loop was removed (operator directive — on-demand,
|
|
18452
|
+
* always), so a camera nobody is looking at costs nothing at all.
|
|
18453
|
+
*
|
|
18454
|
+
* **It waits, briefly and boundedly, for the capture it triggered.** The
|
|
18455
|
+
* returned `capturedAt` is the frame the link will serve, not the frame the
|
|
18456
|
+
* cache held when the client asked, so a first paint is honest and current
|
|
18457
|
+
* instead of a generation behind. A device that does not settle inside the
|
|
18458
|
+
* bound still gets a link and its real (older) `capturedAt` — the next poll
|
|
18459
|
+
* carries it forward.
|
|
18460
|
+
*
|
|
18461
|
+
* `force` is never set on behalf of a client here. A sleeping battery camera
|
|
18462
|
+
* is reported with `sleeping: true` and the last frame it produced, however
|
|
18463
|
+
* old; the wrapper's existing sleep gate owns that decision and this method
|
|
18464
|
+
* adds no second one.
|
|
18465
|
+
*/
|
|
18466
|
+
getSnapshotLinks: systemMethod(object({
|
|
18467
|
+
/** The tiles a surface is actually rendering. One entry per (device,
|
|
18468
|
+
* width) the caller will paint — the width is snapped to the server's
|
|
18469
|
+
* ladder and becomes part of the link's SIGNED identity. */
|
|
18289
18470
|
targets: array(object({
|
|
18290
|
-
|
|
18291
|
-
|
|
18292
|
-
|
|
18293
|
-
|
|
18294
|
-
})).min(1).max(200) }), array(object({
|
|
18295
|
-
|
|
18296
|
-
|
|
18297
|
-
|
|
18298
|
-
|
|
18299
|
-
|
|
18300
|
-
|
|
18301
|
-
|
|
18302
|
-
|
|
18303
|
-
|
|
18304
|
-
|
|
18305
|
-
|
|
18306
|
-
|
|
18307
|
-
|
|
18308
|
-
|
|
18309
|
-
|
|
18310
|
-
|
|
18311
|
-
|
|
18312
|
-
|
|
18313
|
-
|
|
18314
|
-
|
|
18315
|
-
|
|
18316
|
-
})))
|
|
18471
|
+
deviceId: number(),
|
|
18472
|
+
/** Target width in px. Omit for the frame as captured — correct
|
|
18473
|
+
* for a full-bleed surface, wrong (and expensive) for a grid. */
|
|
18474
|
+
width: number().int().positive().optional()
|
|
18475
|
+
})).min(1).max(200) }), array(object({
|
|
18476
|
+
deviceId: number(),
|
|
18477
|
+
/** Root-relative signed path, or null when the link plane is not
|
|
18478
|
+
* served (no data-plane facility). Present even for a device that has
|
|
18479
|
+
* never captured — the request is what triggers the first one (D94). */
|
|
18480
|
+
url: string().nullable(),
|
|
18481
|
+
/** Epoch ms of the frame this link serves. Null = never captured.
|
|
18482
|
+
* THE honest age: the tRPC path carried none before this. */
|
|
18483
|
+
capturedAt: number().nullable(),
|
|
18484
|
+
/** Age of that frame at the moment the answer was built. */
|
|
18485
|
+
ageMs: number().nullable(),
|
|
18486
|
+
/** Epoch ms after which `url` stops verifying. */
|
|
18487
|
+
expiresAt: number().nullable(),
|
|
18488
|
+
/** Ladder rung the bytes are at; null = the frame as captured. */
|
|
18489
|
+
width: number().nullable(),
|
|
18490
|
+
/** The device has never produced a frame. An empty state, not a
|
|
18491
|
+
* failure — and never a reason to withhold the link (D94). */
|
|
18492
|
+
neverCaptured: boolean(),
|
|
18493
|
+
/** A sleeping battery camera: the frame is deliberately stale and will
|
|
18494
|
+
* NOT refresh in the background. A surface should say so rather than
|
|
18495
|
+
* present it as current. */
|
|
18496
|
+
sleeping: boolean()
|
|
18497
|
+
})))
|
|
18498
|
+
},
|
|
18499
|
+
status: {
|
|
18500
|
+
schema: SnapshotStatusSchema,
|
|
18501
|
+
kind: "poll"
|
|
18502
|
+
}
|
|
18503
|
+
};
|
|
18317
18504
|
/**
|
|
18318
18505
|
* `sso-bridge` — internal hub-only cap that lets SSO-style auth
|
|
18319
18506
|
* providers (OIDC, SAML, magic-link, …) mint an HMAC-signed token
|
|
@@ -18480,6 +18667,13 @@ method(object({ locationId: string() }), EvictableUsageSchema), method(object({
|
|
|
18480
18667
|
locationId: string(),
|
|
18481
18668
|
targetBytes: number().int().positive()
|
|
18482
18669
|
}), EvictResultSchema, { kind: "mutation" });
|
|
18670
|
+
method(StorageMigrationInputSchema, StorageMigrationPlanSchema, { auth: "admin" }), method(StorageMigrationInputSchema, object({ jobId: string() }), {
|
|
18671
|
+
kind: "mutation",
|
|
18672
|
+
auth: "admin"
|
|
18673
|
+
}), method(object({ jobId: string().optional() }), StorageMigrationJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
|
|
18674
|
+
kind: "mutation",
|
|
18675
|
+
auth: "admin"
|
|
18676
|
+
});
|
|
18483
18677
|
var ProviderInfoSchema = discriminatedUnion("shouldSaveDiskSpace", [object({
|
|
18484
18678
|
providerId: string().min(1),
|
|
18485
18679
|
displayName: string().min(1),
|
|
@@ -18583,6 +18777,28 @@ var TerminalProfileInfoSchema = object({
|
|
|
18583
18777
|
label: string(),
|
|
18584
18778
|
description: string().optional()
|
|
18585
18779
|
});
|
|
18780
|
+
/**
|
|
18781
|
+
* A durable operator-created Terminal instance. Profiles are templates; only
|
|
18782
|
+
* an instance declares a camera.
|
|
18783
|
+
*/
|
|
18784
|
+
var TerminalInstanceInfoSchema = object({
|
|
18785
|
+
instanceId: string(),
|
|
18786
|
+
cameraStableId: string(),
|
|
18787
|
+
nodeId: string(),
|
|
18788
|
+
profileId: string(),
|
|
18789
|
+
profileLabel: string(),
|
|
18790
|
+
name: string(),
|
|
18791
|
+
enabled: boolean()
|
|
18792
|
+
});
|
|
18793
|
+
var TerminalLegacyCameraSchema = object({
|
|
18794
|
+
stableId: string(),
|
|
18795
|
+
nodeId: string(),
|
|
18796
|
+
profileId: string(),
|
|
18797
|
+
profileLabel: string(),
|
|
18798
|
+
name: string(),
|
|
18799
|
+
/** Only legacy monitor cameras can retain their historic stable identity. */
|
|
18800
|
+
adoptable: boolean()
|
|
18801
|
+
});
|
|
18586
18802
|
var TerminalOutputEventSchema = discriminatedUnion("kind", [object({
|
|
18587
18803
|
seq: number().int().positive(),
|
|
18588
18804
|
kind: literal("data"),
|
|
@@ -18602,10 +18818,9 @@ var TerminalOutputBatchSchema = object({
|
|
|
18602
18818
|
/**
|
|
18603
18819
|
* terminal-session — singleton system capability for interactive TTY sessions.
|
|
18604
18820
|
*
|
|
18605
|
-
*
|
|
18606
|
-
*
|
|
18607
|
-
*
|
|
18608
|
-
* change this contract.
|
|
18821
|
+
* Owns both live PTY lifecycle and durable Terminal instance management.
|
|
18822
|
+
* Profiles are allowlisted templates; an explicit instance is the only path
|
|
18823
|
+
* that declares a camera.
|
|
18609
18824
|
*/
|
|
18610
18825
|
var terminalSessionCapability = {
|
|
18611
18826
|
name: "terminal-session",
|
|
@@ -18614,6 +18829,37 @@ var terminalSessionCapability = {
|
|
|
18614
18829
|
methods: {
|
|
18615
18830
|
/** Pre-declared profiles the operator may open. */
|
|
18616
18831
|
listProfiles: method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }),
|
|
18832
|
+
/** Explicit durable Terminal instances, managed centrally on the hub. */
|
|
18833
|
+
listInstances: method(_void(), array(TerminalInstanceInfoSchema).readonly(), { auth: "admin" }),
|
|
18834
|
+
createInstance: method(object({
|
|
18835
|
+
targetNodeId: string().min(1),
|
|
18836
|
+
profileId: string().min(1),
|
|
18837
|
+
name: string().trim().min(1).max(160).optional()
|
|
18838
|
+
}), TerminalInstanceInfoSchema, {
|
|
18839
|
+
kind: "mutation",
|
|
18840
|
+
auth: "admin"
|
|
18841
|
+
}),
|
|
18842
|
+
deleteInstance: method(object({ instanceId: string().min(1) }), _void(), {
|
|
18843
|
+
kind: "mutation",
|
|
18844
|
+
auth: "admin"
|
|
18845
|
+
}),
|
|
18846
|
+
setInstanceEnabled: method(object({
|
|
18847
|
+
instanceId: string().min(1),
|
|
18848
|
+
enabled: boolean()
|
|
18849
|
+
}), TerminalInstanceInfoSchema, {
|
|
18850
|
+
kind: "mutation",
|
|
18851
|
+
auth: "admin"
|
|
18852
|
+
}),
|
|
18853
|
+
/** Existing automatic cameras are shown for explicit migration only. */
|
|
18854
|
+
listLegacyCameras: method(_void(), array(TerminalLegacyCameraSchema).readonly(), { auth: "admin" }),
|
|
18855
|
+
/** Explicitly adopt one legacy monitor camera, retaining its stable id. */
|
|
18856
|
+
adoptLegacyMonitor: method(object({
|
|
18857
|
+
stableId: string().min(1),
|
|
18858
|
+
name: string().trim().min(1).max(160).optional()
|
|
18859
|
+
}), TerminalInstanceInfoSchema, {
|
|
18860
|
+
kind: "mutation",
|
|
18861
|
+
auth: "admin"
|
|
18862
|
+
}),
|
|
18617
18863
|
/** Live sessions currently hosted by the provider. */
|
|
18618
18864
|
listSessions: method(_void(), array(TerminalSessionInfoSchema).readonly(), { auth: "admin" }),
|
|
18619
18865
|
/**
|
|
@@ -18645,7 +18891,13 @@ var terminalSessionCapability = {
|
|
|
18645
18891
|
pullOutput: method(object({
|
|
18646
18892
|
sessionId: string(),
|
|
18647
18893
|
afterSeq: number().int().nonnegative(),
|
|
18648
|
-
waitMs: number().int().min(0).max(2e3).default(0)
|
|
18894
|
+
waitMs: number().int().min(0).max(2e3).default(0),
|
|
18895
|
+
/**
|
|
18896
|
+
* Wait when a just-opened session has no output yet. Kept opt-in so a
|
|
18897
|
+
* browser's initial repaint remains immediate; the camera snapshot
|
|
18898
|
+
* relay uses it to avoid encoding a blank startup frame.
|
|
18899
|
+
*/
|
|
18900
|
+
waitForOutput: boolean().optional()
|
|
18649
18901
|
}), TerminalOutputBatchSchema, {
|
|
18650
18902
|
kind: "mutation",
|
|
18651
18903
|
auth: "admin",
|
|
@@ -24624,13 +24876,19 @@ method(object({
|
|
|
24624
24876
|
}), {
|
|
24625
24877
|
kind: "mutation",
|
|
24626
24878
|
auth: "admin"
|
|
24627
|
-
}), method(
|
|
24879
|
+
}), method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
|
|
24628
24880
|
kind: "mutation",
|
|
24629
24881
|
auth: "admin"
|
|
24630
|
-
}), method(object({
|
|
24631
|
-
kind: "
|
|
24882
|
+
}), method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
|
|
24883
|
+
kind: "mutation",
|
|
24884
|
+
auth: "admin"
|
|
24885
|
+
}), method(StorageMigrationLeaseInputSchema, object({ refreshed: literal(true) }), {
|
|
24886
|
+
kind: "mutation",
|
|
24632
24887
|
auth: "admin"
|
|
24633
|
-
}), method(object({ jobId: string() }),
|
|
24888
|
+
}), method(StorageMigrationFootageMoveInputSchema, object({ jobId: string() }), {
|
|
24889
|
+
kind: "mutation",
|
|
24890
|
+
auth: "admin"
|
|
24891
|
+
}), method(object({ jobId: string() }), RelocateJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
|
|
24634
24892
|
kind: "mutation",
|
|
24635
24893
|
auth: "admin"
|
|
24636
24894
|
});
|
|
@@ -27160,9 +27418,10 @@ var DeclaredDevices = class {
|
|
|
27160
27418
|
}
|
|
27161
27419
|
const integrationId = spec.integrationId ?? await this.ensureIntegration(spec.integrationName);
|
|
27162
27420
|
const index = await this.readIndex();
|
|
27421
|
+
const live = await this.readLiveByStableId();
|
|
27163
27422
|
const outcomes = [];
|
|
27164
27423
|
for (const declaration of spec.devices) {
|
|
27165
|
-
const outcome = await this.applyDeclaration(declaration, integrationId, index);
|
|
27424
|
+
const outcome = await this.applyDeclaration(declaration, integrationId, index, live);
|
|
27166
27425
|
if (outcome !== null) outcomes.push(outcome);
|
|
27167
27426
|
}
|
|
27168
27427
|
return {
|
|
@@ -27208,6 +27467,26 @@ var DeclaredDevices = class {
|
|
|
27208
27467
|
return new Map(rows.map((row) => [row.stableId, row]));
|
|
27209
27468
|
}
|
|
27210
27469
|
/**
|
|
27470
|
+
* Devices this kernel already has CONSTRUCTED, by stableId.
|
|
27471
|
+
*
|
|
27472
|
+
* Distinct from {@link readIndex}, and the distinction is the bug: the index
|
|
27473
|
+
* is persisted rows, this is live objects. A row without an object must be
|
|
27474
|
+
* adopted; an object must be left exactly as it is.
|
|
27475
|
+
*
|
|
27476
|
+
* Failure is non-fatal and deliberately so — an empty map degrades to the
|
|
27477
|
+
* previous behaviour (attempt the adopt) rather than skipping a device that
|
|
27478
|
+
* genuinely needs bringing up.
|
|
27479
|
+
*/
|
|
27480
|
+
async readLiveByStableId() {
|
|
27481
|
+
try {
|
|
27482
|
+
const devices = await this.ports.devices.getAll();
|
|
27483
|
+
return new Map(devices.map((device) => [device.stableId, device]));
|
|
27484
|
+
} catch (err) {
|
|
27485
|
+
this.ports.logger.warn("could not read live devices — falling back to adopt-by-row", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
27486
|
+
return /* @__PURE__ */ new Map();
|
|
27487
|
+
}
|
|
27488
|
+
}
|
|
27489
|
+
/**
|
|
27211
27490
|
* One declaration: adopt what exists, create what does not.
|
|
27212
27491
|
*
|
|
27213
27492
|
* The create branch is the destructive one — it seeds `initialMeta`, and
|
|
@@ -27216,8 +27495,15 @@ var DeclaredDevices = class {
|
|
|
27216
27495
|
* the declared name over the operator's rename. D49: that branch needs a
|
|
27217
27496
|
* second read to agree.
|
|
27218
27497
|
*/
|
|
27219
|
-
async applyDeclaration(declaration, integrationId, index) {
|
|
27498
|
+
async applyDeclaration(declaration, integrationId, index, live) {
|
|
27220
27499
|
try {
|
|
27500
|
+
const alreadyLive = live.get(declaration.stableId);
|
|
27501
|
+
if (alreadyLive !== void 0) return {
|
|
27502
|
+
stableId: declaration.stableId,
|
|
27503
|
+
deviceId: alreadyLive.id,
|
|
27504
|
+
device: alreadyLive,
|
|
27505
|
+
created: false
|
|
27506
|
+
};
|
|
27221
27507
|
let existing = index.get(declaration.stableId);
|
|
27222
27508
|
if (existing === void 0) {
|
|
27223
27509
|
existing = (await this.readIndex()).get(declaration.stableId);
|
|
@@ -30322,7 +30608,7 @@ Object.freeze({
|
|
|
30322
30608
|
addonId: null,
|
|
30323
30609
|
access: "create"
|
|
30324
30610
|
},
|
|
30325
|
-
"pipelineAnalytics.
|
|
30611
|
+
"pipelineAnalytics.cancelStorageMigrationMove": {
|
|
30326
30612
|
capName: "pipeline-analytics",
|
|
30327
30613
|
capScope: "device",
|
|
30328
30614
|
addonId: null,
|
|
@@ -30394,12 +30680,6 @@ Object.freeze({
|
|
|
30394
30680
|
addonId: null,
|
|
30395
30681
|
access: "view"
|
|
30396
30682
|
},
|
|
30397
|
-
"pipelineAnalytics.getMediaRelocateStatus": {
|
|
30398
|
-
capName: "pipeline-analytics",
|
|
30399
|
-
capScope: "device",
|
|
30400
|
-
addonId: null,
|
|
30401
|
-
access: "view"
|
|
30402
|
-
},
|
|
30403
30683
|
"pipelineAnalytics.getMotionEvents": {
|
|
30404
30684
|
capName: "pipeline-analytics",
|
|
30405
30685
|
capScope: "device",
|
|
@@ -30436,6 +30716,12 @@ Object.freeze({
|
|
|
30436
30716
|
addonId: null,
|
|
30437
30717
|
access: "view"
|
|
30438
30718
|
},
|
|
30719
|
+
"pipelineAnalytics.getStorageMigrationMoveStatus": {
|
|
30720
|
+
capName: "pipeline-analytics",
|
|
30721
|
+
capScope: "device",
|
|
30722
|
+
addonId: null,
|
|
30723
|
+
access: "view"
|
|
30724
|
+
},
|
|
30439
30725
|
"pipelineAnalytics.getTrack": {
|
|
30440
30726
|
capName: "pipeline-analytics",
|
|
30441
30727
|
capScope: "device",
|
|
@@ -30514,6 +30800,12 @@ Object.freeze({
|
|
|
30514
30800
|
addonId: null,
|
|
30515
30801
|
access: "view"
|
|
30516
30802
|
},
|
|
30803
|
+
"pipelineAnalytics.pauseForStorageMigration": {
|
|
30804
|
+
capName: "pipeline-analytics",
|
|
30805
|
+
capScope: "device",
|
|
30806
|
+
addonId: null,
|
|
30807
|
+
access: "create"
|
|
30808
|
+
},
|
|
30517
30809
|
"pipelineAnalytics.proposeRetrainAnnotations": {
|
|
30518
30810
|
capName: "pipeline-analytics",
|
|
30519
30811
|
capScope: "device",
|
|
@@ -30544,7 +30836,7 @@ Object.freeze({
|
|
|
30544
30836
|
addonId: null,
|
|
30545
30837
|
access: "create"
|
|
30546
30838
|
},
|
|
30547
|
-
"pipelineAnalytics.
|
|
30839
|
+
"pipelineAnalytics.refreshStorageLocationsForMigration": {
|
|
30548
30840
|
capName: "pipeline-analytics",
|
|
30549
30841
|
capScope: "device",
|
|
30550
30842
|
addonId: null,
|
|
@@ -30556,6 +30848,12 @@ Object.freeze({
|
|
|
30556
30848
|
addonId: null,
|
|
30557
30849
|
access: "create"
|
|
30558
30850
|
},
|
|
30851
|
+
"pipelineAnalytics.resumeForStorageMigration": {
|
|
30852
|
+
capName: "pipeline-analytics",
|
|
30853
|
+
capScope: "device",
|
|
30854
|
+
addonId: null,
|
|
30855
|
+
access: "create"
|
|
30856
|
+
},
|
|
30559
30857
|
"pipelineAnalytics.saveRetrainAnnotations": {
|
|
30560
30858
|
capName: "pipeline-analytics",
|
|
30561
30859
|
capScope: "device",
|
|
@@ -30580,6 +30878,12 @@ Object.freeze({
|
|
|
30580
30878
|
addonId: null,
|
|
30581
30879
|
access: "create"
|
|
30582
30880
|
},
|
|
30881
|
+
"pipelineAnalytics.startStorageMigrationMove": {
|
|
30882
|
+
capName: "pipeline-analytics",
|
|
30883
|
+
capScope: "device",
|
|
30884
|
+
addonId: null,
|
|
30885
|
+
access: "create"
|
|
30886
|
+
},
|
|
30583
30887
|
"pipelineAnalytics.wipeAllAnalytics": {
|
|
30584
30888
|
capName: "pipeline-analytics",
|
|
30585
30889
|
capScope: "device",
|
|
@@ -30946,6 +31250,12 @@ Object.freeze({
|
|
|
30946
31250
|
addonId: null,
|
|
30947
31251
|
access: "view"
|
|
30948
31252
|
},
|
|
31253
|
+
"pipelineOrchestrator.pauseForStorageMigration": {
|
|
31254
|
+
capName: "pipeline-orchestrator",
|
|
31255
|
+
capScope: "system",
|
|
31256
|
+
addonId: null,
|
|
31257
|
+
access: "create"
|
|
31258
|
+
},
|
|
30949
31259
|
"pipelineOrchestrator.rebalance": {
|
|
30950
31260
|
capName: "pipeline-orchestrator",
|
|
30951
31261
|
capScope: "system",
|
|
@@ -30970,6 +31280,12 @@ Object.freeze({
|
|
|
30970
31280
|
addonId: null,
|
|
30971
31281
|
access: "view"
|
|
30972
31282
|
},
|
|
31283
|
+
"pipelineOrchestrator.resumeForStorageMigration": {
|
|
31284
|
+
capName: "pipeline-orchestrator",
|
|
31285
|
+
capScope: "system",
|
|
31286
|
+
addonId: null,
|
|
31287
|
+
access: "create"
|
|
31288
|
+
},
|
|
30973
31289
|
"pipelineOrchestrator.saveTemplate": {
|
|
30974
31290
|
capName: "pipeline-orchestrator",
|
|
30975
31291
|
capScope: "system",
|
|
@@ -31366,7 +31682,7 @@ Object.freeze({
|
|
|
31366
31682
|
addonId: null,
|
|
31367
31683
|
access: "create"
|
|
31368
31684
|
},
|
|
31369
|
-
"recording.
|
|
31685
|
+
"recording.cancelStorageMigrationMove": {
|
|
31370
31686
|
capName: "recording",
|
|
31371
31687
|
capScope: "system",
|
|
31372
31688
|
addonId: null,
|
|
@@ -31402,7 +31718,7 @@ Object.freeze({
|
|
|
31402
31718
|
addonId: null,
|
|
31403
31719
|
access: "view"
|
|
31404
31720
|
},
|
|
31405
|
-
"recording.
|
|
31721
|
+
"recording.getStorageMigrationMoveStatus": {
|
|
31406
31722
|
capName: "recording",
|
|
31407
31723
|
capScope: "system",
|
|
31408
31724
|
addonId: null,
|
|
@@ -31426,6 +31742,12 @@ Object.freeze({
|
|
|
31426
31742
|
addonId: null,
|
|
31427
31743
|
access: "view"
|
|
31428
31744
|
},
|
|
31745
|
+
"recording.pauseForStorageMigration": {
|
|
31746
|
+
capName: "recording",
|
|
31747
|
+
capScope: "system",
|
|
31748
|
+
addonId: null,
|
|
31749
|
+
access: "create"
|
|
31750
|
+
},
|
|
31429
31751
|
"recording.pruneFootage": {
|
|
31430
31752
|
capName: "recording",
|
|
31431
31753
|
capScope: "system",
|
|
@@ -31444,7 +31766,7 @@ Object.freeze({
|
|
|
31444
31766
|
addonId: null,
|
|
31445
31767
|
access: "view"
|
|
31446
31768
|
},
|
|
31447
|
-
"recording.
|
|
31769
|
+
"recording.refreshStorageLocationsForMigration": {
|
|
31448
31770
|
capName: "recording",
|
|
31449
31771
|
capScope: "system",
|
|
31450
31772
|
addonId: null,
|
|
@@ -31468,12 +31790,24 @@ Object.freeze({
|
|
|
31468
31790
|
addonId: null,
|
|
31469
31791
|
access: "create"
|
|
31470
31792
|
},
|
|
31793
|
+
"recording.resumeForStorageMigration": {
|
|
31794
|
+
capName: "recording",
|
|
31795
|
+
capScope: "system",
|
|
31796
|
+
addonId: null,
|
|
31797
|
+
access: "create"
|
|
31798
|
+
},
|
|
31471
31799
|
"recording.setDeviceConfig": {
|
|
31472
31800
|
capName: "recording",
|
|
31473
31801
|
capScope: "system",
|
|
31474
31802
|
addonId: null,
|
|
31475
31803
|
access: "create"
|
|
31476
31804
|
},
|
|
31805
|
+
"recording.startStorageMigrationMove": {
|
|
31806
|
+
capName: "recording",
|
|
31807
|
+
capScope: "system",
|
|
31808
|
+
addonId: null,
|
|
31809
|
+
access: "create"
|
|
31810
|
+
},
|
|
31477
31811
|
"recordingExport.cancelExport": {
|
|
31478
31812
|
capName: "recordingExport",
|
|
31479
31813
|
capScope: "system",
|
|
@@ -31864,6 +32198,30 @@ Object.freeze({
|
|
|
31864
32198
|
addonId: null,
|
|
31865
32199
|
access: "view"
|
|
31866
32200
|
},
|
|
32201
|
+
"storageMigration.cancel": {
|
|
32202
|
+
capName: "storage-migration",
|
|
32203
|
+
capScope: "system",
|
|
32204
|
+
addonId: null,
|
|
32205
|
+
access: "create"
|
|
32206
|
+
},
|
|
32207
|
+
"storageMigration.plan": {
|
|
32208
|
+
capName: "storage-migration",
|
|
32209
|
+
capScope: "system",
|
|
32210
|
+
addonId: null,
|
|
32211
|
+
access: "view"
|
|
32212
|
+
},
|
|
32213
|
+
"storageMigration.start": {
|
|
32214
|
+
capName: "storage-migration",
|
|
32215
|
+
capScope: "system",
|
|
32216
|
+
addonId: null,
|
|
32217
|
+
access: "create"
|
|
32218
|
+
},
|
|
32219
|
+
"storageMigration.status": {
|
|
32220
|
+
capName: "storage-migration",
|
|
32221
|
+
capScope: "system",
|
|
32222
|
+
addonId: null,
|
|
32223
|
+
access: "view"
|
|
32224
|
+
},
|
|
31867
32225
|
"storageProvider.abortUpload": {
|
|
31868
32226
|
capName: "storage-provider",
|
|
31869
32227
|
capScope: "system",
|
|
@@ -32242,12 +32600,42 @@ Object.freeze({
|
|
|
32242
32600
|
addonId: null,
|
|
32243
32601
|
access: "create"
|
|
32244
32602
|
},
|
|
32603
|
+
"terminalSession.adoptLegacyMonitor": {
|
|
32604
|
+
capName: "terminal-session",
|
|
32605
|
+
capScope: "system",
|
|
32606
|
+
addonId: null,
|
|
32607
|
+
access: "create"
|
|
32608
|
+
},
|
|
32245
32609
|
"terminalSession.close": {
|
|
32246
32610
|
capName: "terminal-session",
|
|
32247
32611
|
capScope: "system",
|
|
32248
32612
|
addonId: null,
|
|
32249
32613
|
access: "create"
|
|
32250
32614
|
},
|
|
32615
|
+
"terminalSession.createInstance": {
|
|
32616
|
+
capName: "terminal-session",
|
|
32617
|
+
capScope: "system",
|
|
32618
|
+
addonId: null,
|
|
32619
|
+
access: "create"
|
|
32620
|
+
},
|
|
32621
|
+
"terminalSession.deleteInstance": {
|
|
32622
|
+
capName: "terminal-session",
|
|
32623
|
+
capScope: "system",
|
|
32624
|
+
addonId: null,
|
|
32625
|
+
access: "delete"
|
|
32626
|
+
},
|
|
32627
|
+
"terminalSession.listInstances": {
|
|
32628
|
+
capName: "terminal-session",
|
|
32629
|
+
capScope: "system",
|
|
32630
|
+
addonId: null,
|
|
32631
|
+
access: "view"
|
|
32632
|
+
},
|
|
32633
|
+
"terminalSession.listLegacyCameras": {
|
|
32634
|
+
capName: "terminal-session",
|
|
32635
|
+
capScope: "system",
|
|
32636
|
+
addonId: null,
|
|
32637
|
+
access: "view"
|
|
32638
|
+
},
|
|
32251
32639
|
"terminalSession.listProfiles": {
|
|
32252
32640
|
capName: "terminal-session",
|
|
32253
32641
|
capScope: "system",
|
|
@@ -32278,6 +32666,12 @@ Object.freeze({
|
|
|
32278
32666
|
addonId: null,
|
|
32279
32667
|
access: "create"
|
|
32280
32668
|
},
|
|
32669
|
+
"terminalSession.setInstanceEnabled": {
|
|
32670
|
+
capName: "terminal-session",
|
|
32671
|
+
capScope: "system",
|
|
32672
|
+
addonId: null,
|
|
32673
|
+
access: "create"
|
|
32674
|
+
},
|
|
32281
32675
|
"terminalSession.writeInput": {
|
|
32282
32676
|
capName: "terminal-session",
|
|
32283
32677
|
capScope: "system",
|
|
@@ -33080,26 +33474,56 @@ async function warmNodePty() {
|
|
|
33080
33474
|
}
|
|
33081
33475
|
//#endregion
|
|
33082
33476
|
//#region src/terminal-camera-declarations.ts
|
|
33083
|
-
|
|
33084
|
-
|
|
33085
|
-
|
|
33086
|
-
|
|
33087
|
-
|
|
33088
|
-
|
|
33089
|
-
|
|
33090
|
-
|
|
33091
|
-
|
|
33092
|
-
|
|
33093
|
-
|
|
33094
|
-
|
|
33095
|
-
|
|
33477
|
+
/**
|
|
33478
|
+
* Feed DeclaredDevices every live declaration plus one deterministic orphan
|
|
33479
|
+
* batch. The generic sweep intentionally refuses an over-limit set; selecting
|
|
33480
|
+
* a batch here drains large historical Terminal orphan sets across convergence
|
|
33481
|
+
* passes without weakening that global safety guard.
|
|
33482
|
+
*/
|
|
33483
|
+
function terminalCameraReconciliationIndex(rows, integrationId, declarations, managedStableIds = /* @__PURE__ */ new Set()) {
|
|
33484
|
+
if (!integrationId) return [];
|
|
33485
|
+
const declared = new Set(declarations.map((camera) => camera.stableId));
|
|
33486
|
+
const owned = rows.filter((row) => row.integrationId === integrationId && (declared.has(row.stableId) || managedStableIds.has(row.stableId) || row.stableId.startsWith("terminal-camera-instance-")));
|
|
33487
|
+
return [...owned.filter((row) => declared.has(row.stableId)), ...owned.filter((row) => !declared.has(row.stableId)).sort((left, right) => left.id - right.id).slice(0, 32)];
|
|
33488
|
+
}
|
|
33489
|
+
/** Explicit persisted instances, never the node × profile template matrix. */
|
|
33490
|
+
function buildTerminalInstanceCameraDeclarations(instances) {
|
|
33491
|
+
return instances.filter((instance) => instance.enabled).map((instance) => ({
|
|
33492
|
+
stableId: instance.cameraStableId,
|
|
33493
|
+
name: instance.name,
|
|
33494
|
+
config: {
|
|
33495
|
+
instanceId: instance.id,
|
|
33496
|
+
nodeId: instance.nodeId,
|
|
33497
|
+
profileId: instance.profileId,
|
|
33498
|
+
profileLabel: instance.profileLabel
|
|
33499
|
+
}
|
|
33500
|
+
}));
|
|
33501
|
+
}
|
|
33502
|
+
/**
|
|
33503
|
+
* `DeviceConfig` materializes schema defaults in memory, so comparing
|
|
33504
|
+
* `config.get()` cannot detect a legacy `{ nodeId }` row. Reconciliation must
|
|
33505
|
+
* inspect the raw persisted blob to make the profile migration durable.
|
|
33506
|
+
*/
|
|
33507
|
+
function needsTerminalCameraConfigMigration(persistedConfig, declaration) {
|
|
33508
|
+
return persistedConfig.instanceId !== declaration.config.instanceId || persistedConfig.nodeId !== declaration.config.nodeId || persistedConfig.profileId !== declaration.config.profileId || persistedConfig.profileLabel !== declaration.config.profileLabel;
|
|
33096
33509
|
}
|
|
33097
33510
|
function escapeXml(value) {
|
|
33098
33511
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
33099
33512
|
}
|
|
33100
|
-
/**
|
|
33513
|
+
/**
|
|
33514
|
+
* Render already-interpreted terminal rows into a compact MJPEG frame.
|
|
33515
|
+
*
|
|
33516
|
+
* `xml:space="preserve"` is load-bearing, not tidiness. SVG `<text>` collapses
|
|
33517
|
+
* runs of whitespace by default, and a terminal's entire column alignment IS
|
|
33518
|
+
* runs of whitespace — Glances pads every field with spaces. Without it the
|
|
33519
|
+
* frame drew each line at roughly half its true width, crammed into the
|
|
33520
|
+
* top-left of a mostly-black image, while the SAME session over `attach`
|
|
33521
|
+
* looked perfect — which is exactly how the operator reported it. Measured in
|
|
33522
|
+
* the hub's own rasterizer 2026-08-11: `AAA<10 spaces>BBB<3>CCC` drew 92 px
|
|
33523
|
+
* collapsed against 178 px preserved.
|
|
33524
|
+
*/
|
|
33101
33525
|
async function renderTerminalJpeg(lines) {
|
|
33102
|
-
const renderedLines = lines.slice(0, 40).map((line, index) => `<text x="8" y="${String(18 + index * 15)}">${escapeXml(line.slice(0, 120))}</text>`).join("");
|
|
33526
|
+
const renderedLines = lines.slice(0, 40).map((line, index) => `<text xml:space="preserve" x="8" y="${String(18 + index * 15)}">${escapeXml(line.slice(0, 120))}</text>`).join("");
|
|
33103
33527
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${String(960)}" height="${String(640)}"><rect width="100%" height="100%" fill="#0b0d10"/><g fill="#d7dce2" font-family="DejaVu Sans Mono,monospace" font-size="13">${renderedLines}</g></svg>`;
|
|
33104
33528
|
return (0, sharp.default)(Buffer.from(svg)).jpeg({
|
|
33105
33529
|
quality: 82,
|
|
@@ -33109,22 +33533,35 @@ async function renderTerminalJpeg(lines) {
|
|
|
33109
33533
|
//#endregion
|
|
33110
33534
|
//#region src/terminal-camera-device.ts
|
|
33111
33535
|
var terminalCameraSchema = object({
|
|
33536
|
+
instanceId: string().min(1).optional(),
|
|
33112
33537
|
nodeId: string().min(1),
|
|
33113
|
-
profileId: string().min(1),
|
|
33114
|
-
profileLabel: string().min(1)
|
|
33538
|
+
profileId: string().min(1).default("monitor"),
|
|
33539
|
+
profileLabel: string().min(1).default("BTM")
|
|
33115
33540
|
});
|
|
33116
33541
|
var relay = null;
|
|
33117
33542
|
function installTerminalCameraRelay(next) {
|
|
33118
33543
|
relay = next;
|
|
33119
33544
|
}
|
|
33120
33545
|
var TerminalCameraDevice = class extends BaseDevice {
|
|
33121
|
-
features = [];
|
|
33546
|
+
features = [DeviceFeature.NativeSnapshot];
|
|
33122
33547
|
constructor(ctx) {
|
|
33123
33548
|
super(ctx, terminalCameraSchema, { type: ctx.deviceMeta.type });
|
|
33124
33549
|
this.ctx.registerNativeCap(streamCatalogCapability, { getCatalog: async ({ deviceId }) => {
|
|
33125
33550
|
if (deviceId !== this.id) return [];
|
|
33126
33551
|
return this.catalog();
|
|
33127
33552
|
} });
|
|
33553
|
+
this.ctx.registerNativeCap(snapshotCapability, {
|
|
33554
|
+
getSnapshot: async ({ deviceId }) => {
|
|
33555
|
+
if (deviceId !== this.id) throw new Error(`TerminalCameraDevice: deviceId mismatch, expected ${String(this.id)}, got ${String(deviceId)}`);
|
|
33556
|
+
const activeRelay = relay;
|
|
33557
|
+
if (!activeRelay) throw new Error("terminal camera relay is unavailable");
|
|
33558
|
+
return {
|
|
33559
|
+
base64: (await activeRelay.snapshot(this.relayInstanceId(), this.config.get("nodeId"), this.config.get("profileId"))).toString("base64"),
|
|
33560
|
+
contentType: "image/jpeg"
|
|
33561
|
+
};
|
|
33562
|
+
},
|
|
33563
|
+
invalidateCache: async () => {}
|
|
33564
|
+
});
|
|
33128
33565
|
this.markOnline(true);
|
|
33129
33566
|
}
|
|
33130
33567
|
async catalog() {
|
|
@@ -33132,10 +33569,11 @@ var TerminalCameraDevice = class extends BaseDevice {
|
|
|
33132
33569
|
if (!activeRelay) throw new Error("terminal camera relay is unavailable");
|
|
33133
33570
|
const nodeId = this.config.get("nodeId");
|
|
33134
33571
|
const profileId = this.config.get("profileId");
|
|
33572
|
+
const instanceId = this.relayInstanceId();
|
|
33135
33573
|
return [{
|
|
33136
33574
|
camStreamId: profileId,
|
|
33137
33575
|
kind: "pull-http",
|
|
33138
|
-
url: activeRelay.streamUrl(nodeId, profileId),
|
|
33576
|
+
url: activeRelay.streamUrl(instanceId, nodeId, profileId),
|
|
33139
33577
|
codec: "h264",
|
|
33140
33578
|
resolution: {
|
|
33141
33579
|
width: 960,
|
|
@@ -33147,6 +33585,13 @@ var TerminalCameraDevice = class extends BaseDevice {
|
|
|
33147
33585
|
}
|
|
33148
33586
|
setNodeOnline(online) {
|
|
33149
33587
|
this.markOnline(online);
|
|
33588
|
+
if (!online) relay?.closeInstance(this.relayInstanceId());
|
|
33589
|
+
}
|
|
33590
|
+
async removeDevice() {
|
|
33591
|
+
await relay?.closeInstance(this.relayInstanceId());
|
|
33592
|
+
}
|
|
33593
|
+
relayInstanceId() {
|
|
33594
|
+
return this.config.get("instanceId") ?? `legacy:${this.stableId}`;
|
|
33150
33595
|
}
|
|
33151
33596
|
};
|
|
33152
33597
|
//#endregion
|
|
@@ -37995,18 +38440,24 @@ function createXtermScreen(cols, rows) {
|
|
|
37995
38440
|
//#region src/terminal-camera-relay.ts
|
|
37996
38441
|
var MJPEG_BOUNDARY = "camstack-terminal-frame";
|
|
37997
38442
|
var SESSION_IDLE_MS = 3e4;
|
|
37998
|
-
|
|
37999
|
-
|
|
38443
|
+
var SNAPSHOT_STARTUP_WAIT_MS = 1500;
|
|
38444
|
+
var CLOSE_RETRY_BASE_MS = 50;
|
|
38445
|
+
var CLOSE_RETRY_MAX_MS = 1e3;
|
|
38446
|
+
var CLOSE_ATTEMPTS_PER_PASS = 3;
|
|
38447
|
+
function relayKey(instanceId) {
|
|
38448
|
+
return instanceId;
|
|
38000
38449
|
}
|
|
38001
38450
|
function parseStreamPath(url) {
|
|
38002
38451
|
const parts = ((url ?? "").split("?")[0] ?? "").split("/").filter(Boolean);
|
|
38003
|
-
if (parts.length !==
|
|
38452
|
+
if (parts.length !== 4 || parts[0] !== "stream") return null;
|
|
38004
38453
|
try {
|
|
38005
|
-
const
|
|
38006
|
-
const
|
|
38454
|
+
const instanceId = decodeURIComponent(parts[1] ?? "");
|
|
38455
|
+
const nodeId = decodeURIComponent(parts[2] ?? "");
|
|
38456
|
+
const profilePart = parts[3] ?? "";
|
|
38007
38457
|
if (!profilePart.endsWith(".mjpeg")) return null;
|
|
38008
38458
|
const profileId = decodeURIComponent(profilePart.slice(0, -6));
|
|
38009
|
-
return nodeId && profileId ? {
|
|
38459
|
+
return instanceId && nodeId && profileId ? {
|
|
38460
|
+
instanceId,
|
|
38010
38461
|
nodeId,
|
|
38011
38462
|
profileId
|
|
38012
38463
|
} : null;
|
|
@@ -38033,7 +38484,7 @@ var TerminalCameraRelay = class {
|
|
|
38033
38484
|
res.writeHead(404).end();
|
|
38034
38485
|
return;
|
|
38035
38486
|
}
|
|
38036
|
-
this.serve(target.nodeId, target.profileId, res);
|
|
38487
|
+
this.serve(target.instanceId, target.nodeId, target.profileId, res);
|
|
38037
38488
|
});
|
|
38038
38489
|
await new Promise((resolve, reject) => {
|
|
38039
38490
|
server.once("error", reject);
|
|
@@ -38047,55 +38498,105 @@ var TerminalCameraRelay = class {
|
|
|
38047
38498
|
this.server = server;
|
|
38048
38499
|
this.baseUrl = `http://127.0.0.1:${String(address.port)}`;
|
|
38049
38500
|
}
|
|
38050
|
-
streamUrl(nodeId, profileId) {
|
|
38501
|
+
streamUrl(instanceId, nodeId, profileId) {
|
|
38051
38502
|
if (!this.baseUrl) throw new Error("terminal camera relay is not started");
|
|
38052
|
-
return `${this.baseUrl}/stream/${encodeURIComponent(nodeId)}/${encodeURIComponent(profileId)}.mjpeg`;
|
|
38503
|
+
return `${this.baseUrl}/stream/${encodeURIComponent(instanceId)}/${encodeURIComponent(nodeId)}/${encodeURIComponent(profileId)}.mjpeg`;
|
|
38053
38504
|
}
|
|
38054
38505
|
async listProfiles(nodeId) {
|
|
38055
38506
|
return this.api.listProfiles(nodeId);
|
|
38056
38507
|
}
|
|
38057
|
-
state(nodeId, profileId) {
|
|
38058
|
-
const key = relayKey(
|
|
38508
|
+
state(instanceId, nodeId, profileId) {
|
|
38509
|
+
const key = relayKey(instanceId);
|
|
38059
38510
|
const existing = this.states.get(key);
|
|
38060
38511
|
if (existing) return existing;
|
|
38061
38512
|
const created = {
|
|
38513
|
+
instanceId,
|
|
38062
38514
|
nodeId,
|
|
38063
38515
|
profileId,
|
|
38064
38516
|
screen: createXtermScreen(120, 40),
|
|
38065
38517
|
sessionId: null,
|
|
38066
38518
|
cursor: 0,
|
|
38067
38519
|
clients: 0,
|
|
38520
|
+
leases: 0,
|
|
38068
38521
|
jpeg: null,
|
|
38069
38522
|
renderedCursor: -1,
|
|
38070
38523
|
framePromise: null,
|
|
38071
|
-
|
|
38524
|
+
openPromise: null,
|
|
38525
|
+
idleTimer: null,
|
|
38526
|
+
closing: false,
|
|
38527
|
+
closePromise: null,
|
|
38528
|
+
closeRetryTimer: null,
|
|
38529
|
+
closeAttempts: 0,
|
|
38530
|
+
closed: false,
|
|
38531
|
+
responses: /* @__PURE__ */ new Set()
|
|
38072
38532
|
};
|
|
38073
38533
|
this.states.set(key, created);
|
|
38074
38534
|
return created;
|
|
38075
38535
|
}
|
|
38076
38536
|
async ensureSession(state) {
|
|
38537
|
+
if (state.closed || state.closing) throw new Error("terminal camera relay is waiting for prior session cleanup");
|
|
38077
38538
|
if (state.sessionId) return state.sessionId;
|
|
38078
|
-
|
|
38539
|
+
if (state.openPromise) {
|
|
38540
|
+
const opened = await state.openPromise;
|
|
38541
|
+
if (state.closed || state.closing) throw new Error("terminal camera relay state closed while opening its session");
|
|
38542
|
+
return opened.sessionId;
|
|
38543
|
+
}
|
|
38544
|
+
const opening = this.api.openSession(state.nodeId, {
|
|
38079
38545
|
profileId: state.profileId,
|
|
38080
38546
|
cols: 120,
|
|
38081
38547
|
rows: 40
|
|
38082
38548
|
});
|
|
38083
|
-
state.
|
|
38084
|
-
|
|
38085
|
-
|
|
38549
|
+
state.openPromise = opening;
|
|
38550
|
+
try {
|
|
38551
|
+
const opened = await opening;
|
|
38552
|
+
state.openPromise = null;
|
|
38553
|
+
if (state.closed || state.closing) {
|
|
38554
|
+
state.sessionId = opened.sessionId;
|
|
38555
|
+
await this.closeState(state);
|
|
38556
|
+
throw new Error("terminal camera relay state closed while opening its session");
|
|
38557
|
+
}
|
|
38558
|
+
state.sessionId = opened.sessionId;
|
|
38559
|
+
state.cursor = 0;
|
|
38560
|
+
return opened.sessionId;
|
|
38561
|
+
} catch (error) {
|
|
38562
|
+
if (state.closing && !state.sessionId) this.finishClose(state);
|
|
38563
|
+
throw error;
|
|
38564
|
+
} finally {
|
|
38565
|
+
if (state.openPromise === opening) state.openPromise = null;
|
|
38566
|
+
}
|
|
38086
38567
|
}
|
|
38087
|
-
async nextFrame(state) {
|
|
38088
|
-
if (state.framePromise)
|
|
38568
|
+
async nextFrame(state, forceRender = false, waitForInitialOutput = false) {
|
|
38569
|
+
if (state.framePromise) {
|
|
38570
|
+
await state.framePromise;
|
|
38571
|
+
return forceRender ? this.nextFrame(state, true, waitForInitialOutput) : state.jpeg ?? this.nextFrame(state, false, waitForInitialOutput);
|
|
38572
|
+
}
|
|
38089
38573
|
const render = async () => {
|
|
38574
|
+
const openingSession = state.sessionId === null;
|
|
38090
38575
|
const sessionId = await this.ensureSession(state);
|
|
38091
38576
|
let batch;
|
|
38092
38577
|
try {
|
|
38093
38578
|
batch = await this.api.pullOutput(state.nodeId, {
|
|
38094
38579
|
sessionId,
|
|
38095
|
-
afterSeq: state.cursor
|
|
38580
|
+
afterSeq: state.cursor,
|
|
38581
|
+
...openingSession && waitForInitialOutput ? {
|
|
38582
|
+
waitMs: SNAPSHOT_STARTUP_WAIT_MS,
|
|
38583
|
+
waitForOutput: true
|
|
38584
|
+
} : {}
|
|
38096
38585
|
});
|
|
38097
38586
|
} catch (error) {
|
|
38098
|
-
state.sessionId
|
|
38587
|
+
if (state.sessionId === sessionId) {
|
|
38588
|
+
let closed = false;
|
|
38589
|
+
await this.api.closeSession(state.nodeId, sessionId).then(() => {
|
|
38590
|
+
closed = true;
|
|
38591
|
+
}).catch((closeError) => {
|
|
38592
|
+
this.logger.warn("terminal camera session cleanup after output failure failed", { meta: {
|
|
38593
|
+
nodeId: state.nodeId,
|
|
38594
|
+
sessionId,
|
|
38595
|
+
error: closeError instanceof Error ? closeError.message : String(closeError)
|
|
38596
|
+
} });
|
|
38597
|
+
});
|
|
38598
|
+
if (closed) state.sessionId = null;
|
|
38599
|
+
}
|
|
38099
38600
|
state.cursor = 0;
|
|
38100
38601
|
throw error;
|
|
38101
38602
|
}
|
|
@@ -38112,7 +38613,7 @@ var TerminalCameraRelay = class {
|
|
|
38112
38613
|
}
|
|
38113
38614
|
state.cursor = exited ? 0 : batch.cursor;
|
|
38114
38615
|
await state.screen.flush();
|
|
38115
|
-
if (state.jpeg === null || state.renderedCursor !== state.cursor) {
|
|
38616
|
+
if (forceRender || state.jpeg === null || state.renderedCursor !== state.cursor) {
|
|
38116
38617
|
state.jpeg = await renderTerminalJpeg(state.screen.lines());
|
|
38117
38618
|
state.renderedCursor = state.cursor;
|
|
38118
38619
|
}
|
|
@@ -38123,14 +38624,15 @@ var TerminalCameraRelay = class {
|
|
|
38123
38624
|
});
|
|
38124
38625
|
return state.framePromise;
|
|
38125
38626
|
}
|
|
38126
|
-
async serve(nodeId, profileId, res) {
|
|
38627
|
+
async serve(instanceId, nodeId, profileId, res) {
|
|
38127
38628
|
this.responses.add(res);
|
|
38128
|
-
const state = this.state(nodeId, profileId);
|
|
38629
|
+
const state = this.state(instanceId, nodeId, profileId);
|
|
38129
38630
|
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
38130
38631
|
state.idleTimer = null;
|
|
38131
38632
|
state.clients += 1;
|
|
38633
|
+
state.responses.add(res);
|
|
38132
38634
|
try {
|
|
38133
|
-
const first = await this.nextFrame(state);
|
|
38635
|
+
const first = await this.nextFrame(state, false, true);
|
|
38134
38636
|
res.writeHead(200, {
|
|
38135
38637
|
"content-type": `multipart/x-mixed-replace; boundary=${MJPEG_BOUNDARY}`,
|
|
38136
38638
|
"cache-control": "no-store",
|
|
@@ -38158,11 +38660,13 @@ var TerminalCameraRelay = class {
|
|
|
38158
38660
|
res.end("terminal camera unavailable");
|
|
38159
38661
|
} finally {
|
|
38160
38662
|
this.responses.delete(res);
|
|
38663
|
+
state.responses.delete(res);
|
|
38161
38664
|
state.clients = Math.max(0, state.clients - 1);
|
|
38162
|
-
if (state.clients === 0) this.scheduleIdleClose(state);
|
|
38665
|
+
if (state.clients === 0 && state.leases === 0) this.scheduleIdleClose(state);
|
|
38163
38666
|
}
|
|
38164
38667
|
}
|
|
38165
38668
|
scheduleIdleClose(state) {
|
|
38669
|
+
if (state.closed || state.closing || state.clients > 0 || state.leases > 0) return;
|
|
38166
38670
|
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
38167
38671
|
state.idleTimer = setTimeout(() => {
|
|
38168
38672
|
this.closeState(state);
|
|
@@ -38170,26 +38674,116 @@ var TerminalCameraRelay = class {
|
|
|
38170
38674
|
state.idleTimer.unref?.();
|
|
38171
38675
|
}
|
|
38172
38676
|
async closeState(state) {
|
|
38173
|
-
if (state.
|
|
38174
|
-
if (state.
|
|
38677
|
+
if (state.closed) return;
|
|
38678
|
+
if (state.clients > 0 || state.leases > 0) return;
|
|
38679
|
+
if (state.closePromise) {
|
|
38680
|
+
await state.closePromise;
|
|
38681
|
+
return;
|
|
38682
|
+
}
|
|
38683
|
+
if (state.openPromise && !state.sessionId) {
|
|
38684
|
+
state.closing = true;
|
|
38685
|
+
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
38686
|
+
state.idleTimer = null;
|
|
38687
|
+
return;
|
|
38688
|
+
}
|
|
38689
|
+
if (state.closing && !state.sessionId) return;
|
|
38690
|
+
state.closing = true;
|
|
38691
|
+
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
38692
|
+
state.idleTimer = null;
|
|
38693
|
+
state.closePromise = this.closeWithRetries(state).finally(() => {
|
|
38694
|
+
state.closePromise = null;
|
|
38695
|
+
});
|
|
38696
|
+
await state.closePromise;
|
|
38697
|
+
}
|
|
38698
|
+
async closeWithRetries(state) {
|
|
38699
|
+
const sessionId = state.sessionId;
|
|
38700
|
+
if (!sessionId) {
|
|
38701
|
+
this.finishClose(state);
|
|
38702
|
+
return;
|
|
38703
|
+
}
|
|
38704
|
+
for (let attempt = 0; attempt < CLOSE_ATTEMPTS_PER_PASS; attempt += 1) try {
|
|
38705
|
+
await this.api.closeSession(state.nodeId, sessionId);
|
|
38706
|
+
if (state.sessionId === sessionId) this.finishClose(state);
|
|
38707
|
+
return;
|
|
38708
|
+
} catch (error) {
|
|
38709
|
+
state.closeAttempts += 1;
|
|
38175
38710
|
this.logger.warn("terminal camera session close failed", { meta: {
|
|
38176
38711
|
nodeId: state.nodeId,
|
|
38177
|
-
sessionId
|
|
38712
|
+
sessionId,
|
|
38713
|
+
attempt: state.closeAttempts,
|
|
38178
38714
|
error: error instanceof Error ? error.message : String(error)
|
|
38179
38715
|
} });
|
|
38180
|
-
|
|
38716
|
+
if (attempt + 1 < CLOSE_ATTEMPTS_PER_PASS) await new Promise((resolve) => setTimeout(resolve, this.closeRetryDelay(state.closeAttempts)));
|
|
38717
|
+
}
|
|
38718
|
+
this.scheduleCloseRetry(state);
|
|
38719
|
+
}
|
|
38720
|
+
scheduleCloseRetry(state) {
|
|
38721
|
+
if (state.closeRetryTimer || !state.sessionId) return;
|
|
38722
|
+
state.closeRetryTimer = setTimeout(() => {
|
|
38723
|
+
state.closeRetryTimer = null;
|
|
38724
|
+
state.closing = false;
|
|
38725
|
+
this.closeState(state);
|
|
38726
|
+
}, this.closeRetryDelay(state.closeAttempts));
|
|
38727
|
+
state.closeRetryTimer.unref?.();
|
|
38728
|
+
}
|
|
38729
|
+
closeRetryDelay(attempt) {
|
|
38730
|
+
return Math.min(CLOSE_RETRY_BASE_MS * 2 ** Math.min(attempt - 1, 5), CLOSE_RETRY_MAX_MS);
|
|
38731
|
+
}
|
|
38732
|
+
finishClose(state) {
|
|
38733
|
+
if (state.closed) return;
|
|
38734
|
+
state.closed = true;
|
|
38735
|
+
if (state.closeRetryTimer) clearTimeout(state.closeRetryTimer);
|
|
38736
|
+
state.closeRetryTimer = null;
|
|
38737
|
+
state.sessionId = null;
|
|
38738
|
+
state.closeAttempts = 0;
|
|
38739
|
+
state.closing = false;
|
|
38740
|
+
if (this.states.get(relayKey(state.instanceId)) === state) this.states.delete(relayKey(state.instanceId));
|
|
38181
38741
|
state.screen.dispose();
|
|
38182
|
-
|
|
38742
|
+
}
|
|
38743
|
+
/**
|
|
38744
|
+
* Capture one fresh JPEG using the same xterm renderer as the MJPEG relay.
|
|
38745
|
+
* A snapshot-only caller owns a short lease and tears the state down as soon
|
|
38746
|
+
* as the image is rendered, so snapshots never leave a monitor PTY running.
|
|
38747
|
+
*/
|
|
38748
|
+
async snapshot(instanceId, nodeId, profileId) {
|
|
38749
|
+
const state = this.state(instanceId, nodeId, profileId);
|
|
38750
|
+
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
38751
|
+
state.idleTimer = null;
|
|
38752
|
+
state.leases += 1;
|
|
38753
|
+
try {
|
|
38754
|
+
return await this.nextFrame(state, true, true);
|
|
38755
|
+
} finally {
|
|
38756
|
+
state.leases = Math.max(0, state.leases - 1);
|
|
38757
|
+
if (state.clients === 0 && state.leases === 0) await this.closeState(state);
|
|
38758
|
+
}
|
|
38759
|
+
}
|
|
38760
|
+
/** Stop a withdrawn/offline camera's relay, including active HTTP readers. */
|
|
38761
|
+
async closeInstance(instanceId) {
|
|
38762
|
+
const state = this.states.get(relayKey(instanceId));
|
|
38763
|
+
if (!state) return;
|
|
38764
|
+
for (const response of state.responses) response.destroy();
|
|
38765
|
+
state.responses.clear();
|
|
38766
|
+
state.clients = 0;
|
|
38767
|
+
state.leases = 0;
|
|
38768
|
+
await this.closeState(state);
|
|
38769
|
+
if (state.openPromise) {
|
|
38770
|
+
await state.openPromise.catch(() => {});
|
|
38771
|
+
await this.closeState(state);
|
|
38772
|
+
}
|
|
38183
38773
|
}
|
|
38184
38774
|
async dispose() {
|
|
38185
38775
|
for (const response of this.responses) response.destroy();
|
|
38186
38776
|
this.responses.clear();
|
|
38187
|
-
for (const state of this.states.values()) {
|
|
38777
|
+
for (const state of [...this.states.values()]) {
|
|
38188
38778
|
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
38189
38779
|
state.clients = 0;
|
|
38780
|
+
state.leases = 0;
|
|
38190
38781
|
await this.closeState(state);
|
|
38782
|
+
if (state.openPromise) {
|
|
38783
|
+
await state.openPromise.catch(() => {});
|
|
38784
|
+
await this.closeState(state);
|
|
38785
|
+
}
|
|
38191
38786
|
}
|
|
38192
|
-
this.states.clear();
|
|
38193
38787
|
if (this.server) {
|
|
38194
38788
|
const server = this.server;
|
|
38195
38789
|
this.server = null;
|
|
@@ -38305,6 +38899,113 @@ function createTerminalDataPlaneHandler(manager) {
|
|
|
38305
38899
|
};
|
|
38306
38900
|
}
|
|
38307
38901
|
//#endregion
|
|
38902
|
+
//#region src/terminal-instances.ts
|
|
38903
|
+
var TerminalInstanceSchema = object({
|
|
38904
|
+
id: string().uuid(),
|
|
38905
|
+
cameraStableId: string().min(1).max(256),
|
|
38906
|
+
nodeId: string().min(1).max(256),
|
|
38907
|
+
profileId: string().min(1).max(64),
|
|
38908
|
+
profileLabel: string().min(1).max(120),
|
|
38909
|
+
name: string().min(1).max(160),
|
|
38910
|
+
enabled: boolean()
|
|
38911
|
+
});
|
|
38912
|
+
/**
|
|
38913
|
+
* Config is operator-writable, so malformed or duplicate rows are ignored
|
|
38914
|
+
* rather than allowed to make declaration reconciliation destructive.
|
|
38915
|
+
*/
|
|
38916
|
+
function readTerminalInstances(raw, onInvalid) {
|
|
38917
|
+
const ids = /* @__PURE__ */ new Set();
|
|
38918
|
+
const stableIds = /* @__PURE__ */ new Set();
|
|
38919
|
+
const instances = [];
|
|
38920
|
+
for (const value of raw) {
|
|
38921
|
+
const parsed = TerminalInstanceSchema.safeParse(value);
|
|
38922
|
+
if (!parsed.success) {
|
|
38923
|
+
onInvalid?.("Ignoring malformed Terminal instance configuration");
|
|
38924
|
+
continue;
|
|
38925
|
+
}
|
|
38926
|
+
const instance = parsed.data;
|
|
38927
|
+
if (ids.has(instance.id) || stableIds.has(instance.cameraStableId)) {
|
|
38928
|
+
onInvalid?.(`Ignoring duplicate Terminal instance ${instance.id}`);
|
|
38929
|
+
continue;
|
|
38930
|
+
}
|
|
38931
|
+
ids.add(instance.id);
|
|
38932
|
+
stableIds.add(instance.cameraStableId);
|
|
38933
|
+
instances.push(instance);
|
|
38934
|
+
}
|
|
38935
|
+
return instances;
|
|
38936
|
+
}
|
|
38937
|
+
function newTerminalCameraStableId(instanceId) {
|
|
38938
|
+
return `terminal-camera-instance-${instanceId}`;
|
|
38939
|
+
}
|
|
38940
|
+
/**
|
|
38941
|
+
* Legacy automatic cameras are migration candidates only. A tombstone is
|
|
38942
|
+
* durable deletion intent, so a lingering failed device removal must never
|
|
38943
|
+
* make that camera adoptable again.
|
|
38944
|
+
*/
|
|
38945
|
+
function listLegacyTerminalCameraCandidates(rows, instanceStableIds, tombstones) {
|
|
38946
|
+
const legacy = [];
|
|
38947
|
+
for (const row of rows) {
|
|
38948
|
+
if (tombstones.has(row.stableId) || instanceStableIds.has(row.stableId) || row.stableId.startsWith("terminal-camera-instance-") || !row.stableId.startsWith("terminal-camera-")) continue;
|
|
38949
|
+
const nodeId = typeof row.config.nodeId === "string" ? row.config.nodeId : null;
|
|
38950
|
+
if (!nodeId) continue;
|
|
38951
|
+
const profileId = typeof row.config.profileId === "string" ? row.config.profileId : "monitor";
|
|
38952
|
+
const profileLabel = typeof row.config.profileLabel === "string" ? row.config.profileLabel : profileId === "monitor" ? "BTM" : profileId;
|
|
38953
|
+
legacy.push({
|
|
38954
|
+
stableId: row.stableId,
|
|
38955
|
+
nodeId,
|
|
38956
|
+
profileId,
|
|
38957
|
+
profileLabel,
|
|
38958
|
+
name: row.name,
|
|
38959
|
+
adoptable: profileId === "monitor" && row.stableId === `terminal-camera-${nodeId}`
|
|
38960
|
+
});
|
|
38961
|
+
}
|
|
38962
|
+
return legacy;
|
|
38963
|
+
}
|
|
38964
|
+
/** Serializes config read-modify-write operations and their reconciliation. */
|
|
38965
|
+
var TerminalInstanceMutationQueue = class {
|
|
38966
|
+
tail = Promise.resolve();
|
|
38967
|
+
async run(mutation) {
|
|
38968
|
+
const previous = this.tail;
|
|
38969
|
+
let release;
|
|
38970
|
+
this.tail = new Promise((resolve) => {
|
|
38971
|
+
release = resolve;
|
|
38972
|
+
});
|
|
38973
|
+
await previous;
|
|
38974
|
+
try {
|
|
38975
|
+
return await mutation();
|
|
38976
|
+
} finally {
|
|
38977
|
+
release?.();
|
|
38978
|
+
}
|
|
38979
|
+
}
|
|
38980
|
+
};
|
|
38981
|
+
/**
|
|
38982
|
+
* Coalesces periodic/config reconciliation requests onto the same serialized
|
|
38983
|
+
* lane as instance mutations. A pass never applies a declaration snapshot
|
|
38984
|
+
* concurrently with a create/delete/enable write.
|
|
38985
|
+
*/
|
|
38986
|
+
var TerminalInstanceReconcileCoordinator = class {
|
|
38987
|
+
queue;
|
|
38988
|
+
dirty = false;
|
|
38989
|
+
running = null;
|
|
38990
|
+
constructor(queue) {
|
|
38991
|
+
this.queue = queue;
|
|
38992
|
+
}
|
|
38993
|
+
request(apply) {
|
|
38994
|
+
this.dirty = true;
|
|
38995
|
+
if (this.running) return this.running;
|
|
38996
|
+
const running = this.queue.run(async () => {
|
|
38997
|
+
while (this.dirty) {
|
|
38998
|
+
this.dirty = false;
|
|
38999
|
+
await apply();
|
|
39000
|
+
}
|
|
39001
|
+
});
|
|
39002
|
+
this.running = running.finally(() => {
|
|
39003
|
+
this.running = null;
|
|
39004
|
+
});
|
|
39005
|
+
return this.running;
|
|
39006
|
+
}
|
|
39007
|
+
};
|
|
39008
|
+
//#endregion
|
|
38308
39009
|
//#region src/profiles.ts
|
|
38309
39010
|
/**
|
|
38310
39011
|
* The allowlist of programs an operator may open. The capability accepts a
|
|
@@ -38433,6 +39134,7 @@ var TerminalSessionManager = class {
|
|
|
38433
39134
|
now;
|
|
38434
39135
|
resolveBinary;
|
|
38435
39136
|
maxSessions;
|
|
39137
|
+
instanceControl = null;
|
|
38436
39138
|
constructor(opts) {
|
|
38437
39139
|
this.opts = opts;
|
|
38438
39140
|
this.profiles = buildProfiles({
|
|
@@ -38471,6 +39173,27 @@ var TerminalSessionManager = class {
|
|
|
38471
39173
|
...p.description !== void 0 ? { description: p.description } : {}
|
|
38472
39174
|
}));
|
|
38473
39175
|
}
|
|
39176
|
+
setInstanceControl(control) {
|
|
39177
|
+
this.instanceControl = control;
|
|
39178
|
+
}
|
|
39179
|
+
async listInstances() {
|
|
39180
|
+
return this.instanceControl?.listInstances() ?? [];
|
|
39181
|
+
}
|
|
39182
|
+
async createInstance(input) {
|
|
39183
|
+
return this.requireInstanceControl().createInstance(input);
|
|
39184
|
+
}
|
|
39185
|
+
async deleteInstance(input) {
|
|
39186
|
+
await this.requireInstanceControl().deleteInstance(input);
|
|
39187
|
+
}
|
|
39188
|
+
async setInstanceEnabled(input) {
|
|
39189
|
+
return this.requireInstanceControl().setInstanceEnabled(input);
|
|
39190
|
+
}
|
|
39191
|
+
async listLegacyCameras() {
|
|
39192
|
+
return this.instanceControl?.listLegacyCameras() ?? [];
|
|
39193
|
+
}
|
|
39194
|
+
async adoptLegacyMonitor(input) {
|
|
39195
|
+
return this.requireInstanceControl().adoptLegacyMonitor(input);
|
|
39196
|
+
}
|
|
38474
39197
|
async listSessions() {
|
|
38475
39198
|
return [...this.sessions.values()].filter((s) => !s.exited).map((s) => s.info);
|
|
38476
39199
|
}
|
|
@@ -38518,10 +39241,12 @@ var TerminalSessionManager = class {
|
|
|
38518
39241
|
outputWaiters: /* @__PURE__ */ new Set(),
|
|
38519
39242
|
outputChars: 0,
|
|
38520
39243
|
nextSeq: 1,
|
|
38521
|
-
exited: false
|
|
39244
|
+
exited: false,
|
|
39245
|
+
disposed: false
|
|
38522
39246
|
};
|
|
38523
39247
|
this.sessions.set(sessionId, session);
|
|
38524
39248
|
pty.onData((data) => {
|
|
39249
|
+
if (session.exited) return;
|
|
38525
39250
|
session.screen.write(data);
|
|
38526
39251
|
this.appendOutput(session, {
|
|
38527
39252
|
kind: "data",
|
|
@@ -38535,27 +39260,7 @@ var TerminalSessionManager = class {
|
|
|
38535
39260
|
} catch {}
|
|
38536
39261
|
});
|
|
38537
39262
|
pty.onExit((event) => {
|
|
38538
|
-
|
|
38539
|
-
session.lastExit = event;
|
|
38540
|
-
this.appendOutput(session, {
|
|
38541
|
-
kind: "exit",
|
|
38542
|
-
exitCode: event.exitCode,
|
|
38543
|
-
...event.signal !== void 0 ? { signal: event.signal } : {}
|
|
38544
|
-
});
|
|
38545
|
-
for (const sink of session.sinks) try {
|
|
38546
|
-
sink({
|
|
38547
|
-
kind: "exit",
|
|
38548
|
-
exitCode: event.exitCode,
|
|
38549
|
-
signal: event.signal
|
|
38550
|
-
});
|
|
38551
|
-
} catch {}
|
|
38552
|
-
session.sinks.clear();
|
|
38553
|
-
const retire = setTimeout(() => {
|
|
38554
|
-
session.screen.dispose();
|
|
38555
|
-
this.sessions.delete(sessionId);
|
|
38556
|
-
}, EXITED_RETENTION_MS);
|
|
38557
|
-
retire.unref?.();
|
|
38558
|
-
session.retireTimer = retire;
|
|
39263
|
+
this.finishSession(sessionId, session, event, true);
|
|
38559
39264
|
});
|
|
38560
39265
|
this.opts.logger.info("terminal: session opened", { meta: {
|
|
38561
39266
|
sessionId,
|
|
@@ -38586,6 +39291,7 @@ var TerminalSessionManager = class {
|
|
|
38586
39291
|
async close(input) {
|
|
38587
39292
|
const session = this.sessions.get(input.sessionId);
|
|
38588
39293
|
if (!session) return;
|
|
39294
|
+
this.finishSession(input.sessionId, session, { exitCode: 0 }, false);
|
|
38589
39295
|
try {
|
|
38590
39296
|
session.pty.kill();
|
|
38591
39297
|
} catch {}
|
|
@@ -38594,7 +39300,7 @@ var TerminalSessionManager = class {
|
|
|
38594
39300
|
async pullOutput(input) {
|
|
38595
39301
|
const session = this.sessions.get(input.sessionId);
|
|
38596
39302
|
if (!session) throw new Error(`No such terminal session: ${input.sessionId}`);
|
|
38597
|
-
if (input.afterSeq > 0 && input.afterSeq === session.nextSeq - 1 && !session.exited && (input.waitMs ?? 0) > 0) await new Promise((resolve) => {
|
|
39303
|
+
if ((input.afterSeq > 0 || input.waitForOutput === true) && input.afterSeq === session.nextSeq - 1 && !session.exited && (input.waitMs ?? 0) > 0) await new Promise((resolve) => {
|
|
38598
39304
|
const wake = () => {
|
|
38599
39305
|
clearTimeout(timer);
|
|
38600
39306
|
session.outputWaiters.delete(wake);
|
|
@@ -38605,6 +39311,11 @@ var TerminalSessionManager = class {
|
|
|
38605
39311
|
session.outputWaiters.add(wake);
|
|
38606
39312
|
});
|
|
38607
39313
|
const cursor = session.nextSeq - 1;
|
|
39314
|
+
if (session.disposed) return {
|
|
39315
|
+
cursor,
|
|
39316
|
+
reset: false,
|
|
39317
|
+
events: session.output.filter((event) => event.seq > input.afterSeq)
|
|
39318
|
+
};
|
|
38608
39319
|
const oldestSeq = session.output[0]?.seq ?? session.nextSeq;
|
|
38609
39320
|
if (input.afterSeq === 0 || input.afterSeq < oldestSeq - 1 || input.afterSeq > cursor) {
|
|
38610
39321
|
await session.screen.flush();
|
|
@@ -38685,14 +39396,51 @@ var TerminalSessionManager = class {
|
|
|
38685
39396
|
}
|
|
38686
39397
|
/** Kill every live session — called on addon shutdown. */
|
|
38687
39398
|
disposeAll() {
|
|
38688
|
-
for (const session of this.sessions
|
|
38689
|
-
|
|
39399
|
+
for (const [sessionId, session] of this.sessions) {
|
|
39400
|
+
this.finishSession(sessionId, session, { exitCode: 0 }, false);
|
|
38690
39401
|
try {
|
|
38691
39402
|
session.pty.kill();
|
|
38692
39403
|
} catch {}
|
|
38693
|
-
session.screen.dispose();
|
|
38694
39404
|
}
|
|
38695
|
-
|
|
39405
|
+
}
|
|
39406
|
+
finishSession(sessionId, session, exit, retainForLateExit) {
|
|
39407
|
+
if (session.exited) return;
|
|
39408
|
+
session.exited = true;
|
|
39409
|
+
session.lastExit = exit;
|
|
39410
|
+
this.appendOutput(session, {
|
|
39411
|
+
kind: "exit",
|
|
39412
|
+
exitCode: exit.exitCode,
|
|
39413
|
+
...exit.signal !== void 0 ? { signal: exit.signal } : {}
|
|
39414
|
+
});
|
|
39415
|
+
for (const sink of session.sinks) try {
|
|
39416
|
+
sink({
|
|
39417
|
+
kind: "exit",
|
|
39418
|
+
exitCode: exit.exitCode,
|
|
39419
|
+
...exit.signal !== void 0 ? { signal: exit.signal } : {}
|
|
39420
|
+
});
|
|
39421
|
+
} catch {}
|
|
39422
|
+
session.sinks.clear();
|
|
39423
|
+
if (!retainForLateExit) {
|
|
39424
|
+
this.disposeSession(session);
|
|
39425
|
+
this.sessions.delete(sessionId);
|
|
39426
|
+
return;
|
|
39427
|
+
}
|
|
39428
|
+
const retire = setTimeout(() => {
|
|
39429
|
+
this.disposeSession(session);
|
|
39430
|
+
this.sessions.delete(sessionId);
|
|
39431
|
+
}, EXITED_RETENTION_MS);
|
|
39432
|
+
retire.unref?.();
|
|
39433
|
+
session.retireTimer = retire;
|
|
39434
|
+
}
|
|
39435
|
+
disposeSession(session) {
|
|
39436
|
+
if (session.retireTimer !== void 0) clearTimeout(session.retireTimer);
|
|
39437
|
+
if (session.disposed) return;
|
|
39438
|
+
session.disposed = true;
|
|
39439
|
+
session.screen.dispose();
|
|
39440
|
+
}
|
|
39441
|
+
requireInstanceControl() {
|
|
39442
|
+
if (!this.instanceControl) throw new Error("Terminal instances are managed on the hub");
|
|
39443
|
+
return this.instanceControl;
|
|
38696
39444
|
}
|
|
38697
39445
|
};
|
|
38698
39446
|
//#endregion
|
|
@@ -38710,7 +39458,9 @@ var DEFAULTS = {
|
|
|
38710
39458
|
allowShell: false,
|
|
38711
39459
|
shellPath: "",
|
|
38712
39460
|
maxSessions: 4,
|
|
38713
|
-
customProfiles: []
|
|
39461
|
+
customProfiles: [],
|
|
39462
|
+
terminalInstances: [],
|
|
39463
|
+
terminalCameraTombstones: []
|
|
38714
39464
|
};
|
|
38715
39465
|
var DATA_PLANE_PREFIX = "io";
|
|
38716
39466
|
var CAMERA_RECONCILE_MS = 6e4;
|
|
@@ -38721,6 +39471,11 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38721
39471
|
cameraRelay = null;
|
|
38722
39472
|
cameraReconcileTimer = null;
|
|
38723
39473
|
cameraProfilesByNode = /* @__PURE__ */ new Map();
|
|
39474
|
+
/** Prevent repeat writes when a legacy device stays live after migration. */
|
|
39475
|
+
migratedTerminalCameraConfigIds = /* @__PURE__ */ new Set();
|
|
39476
|
+
terminalCameraTombstones = /* @__PURE__ */ new Set();
|
|
39477
|
+
instanceMutationQueue = new TerminalInstanceMutationQueue();
|
|
39478
|
+
terminalReconcileCoordinator = new TerminalInstanceReconcileCoordinator(this.instanceMutationQueue);
|
|
38724
39479
|
glancesPythonPath = "";
|
|
38725
39480
|
constructor() {
|
|
38726
39481
|
super({ ...DEFAULTS });
|
|
@@ -38749,18 +39504,22 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38749
39504
|
customProfiles: this.config.customProfiles
|
|
38750
39505
|
});
|
|
38751
39506
|
this.manager = manager;
|
|
39507
|
+
this.replaceTerminalCameraTombstones(this.config.terminalCameraTombstones);
|
|
38752
39508
|
if (declarationOwnerNodeId(this.ctx.kernel.localNodeId) === "hub") {
|
|
39509
|
+
const localNodeId = declarationOwnerNodeId(this.ctx.kernel.localNodeId);
|
|
38753
39510
|
const cameraRelay = new TerminalCameraRelay({
|
|
38754
|
-
listProfiles: (nodeId) => this.ctx.api.terminalSession.listProfiles.query(void 0, nodePin(nodeId)),
|
|
38755
|
-
openSession: (nodeId, input) => this.ctx.api.terminalSession.openSession.mutate(input, nodePin(nodeId)),
|
|
38756
|
-
pullOutput: (nodeId, input) => this.ctx.api.terminalSession.pullOutput.mutate(input, nodePin(nodeId)),
|
|
39511
|
+
listProfiles: (nodeId) => nodeId === localNodeId ? manager.listProfiles() : this.ctx.api.terminalSession.listProfiles.query(void 0, nodePin(nodeId)),
|
|
39512
|
+
openSession: (nodeId, input) => nodeId === localNodeId ? manager.openSession(input) : this.ctx.api.terminalSession.openSession.mutate(input, nodePin(nodeId)),
|
|
39513
|
+
pullOutput: (nodeId, input) => nodeId === localNodeId ? manager.pullOutput(input) : this.ctx.api.terminalSession.pullOutput.mutate(input, nodePin(nodeId)),
|
|
38757
39514
|
closeSession: async (nodeId, sessionId) => {
|
|
38758
|
-
await
|
|
39515
|
+
if (nodeId === localNodeId) await manager.close({ sessionId });
|
|
39516
|
+
else await this.ctx.api.terminalSession.close.mutate({ sessionId }, nodePin(nodeId));
|
|
38759
39517
|
}
|
|
38760
39518
|
}, this.ctx.logger.child("camera"));
|
|
38761
39519
|
await cameraRelay.start();
|
|
38762
39520
|
this.cameraRelay = cameraRelay;
|
|
38763
39521
|
installTerminalCameraRelay(cameraRelay);
|
|
39522
|
+
manager.setInstanceControl(this.terminalInstanceControl());
|
|
38764
39523
|
await this.reconcileTerminalCameras().catch((error) => {
|
|
38765
39524
|
this.ctx.logger.warn("initial terminal camera reconciliation failed — will retry", { meta: { error: error instanceof Error ? error.message : String(error) } });
|
|
38766
39525
|
});
|
|
@@ -38785,6 +39544,7 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38785
39544
|
}];
|
|
38786
39545
|
}
|
|
38787
39546
|
async onConfigChanged() {
|
|
39547
|
+
this.replaceTerminalCameraTombstones(this.config.terminalCameraTombstones);
|
|
38788
39548
|
this.manager?.reconfigureProfiles({
|
|
38789
39549
|
btmPath: this.config.btmPath,
|
|
38790
39550
|
btmEnabled: this.config.btmEnabled,
|
|
@@ -38801,6 +39561,9 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38801
39561
|
maxSessions: this.config.maxSessions,
|
|
38802
39562
|
customProfiles: this.config.customProfiles
|
|
38803
39563
|
});
|
|
39564
|
+
if (this.cameraRelay) this.reconcileTerminalCameras().catch((error) => {
|
|
39565
|
+
this.ctx.logger.warn("terminal camera reconciliation after config change failed", { meta: { error: error instanceof Error ? error.message : String(error) } });
|
|
39566
|
+
});
|
|
38804
39567
|
}
|
|
38805
39568
|
async onShutdown() {
|
|
38806
39569
|
if (this.cameraReconcileTimer) clearInterval(this.cameraReconcileTimer);
|
|
@@ -38816,55 +39579,46 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38816
39579
|
this.manager = null;
|
|
38817
39580
|
}
|
|
38818
39581
|
async reconcileTerminalCameras() {
|
|
39582
|
+
return this.terminalReconcileCoordinator.request(() => this.applyTerminalCameraReconciliation());
|
|
39583
|
+
}
|
|
39584
|
+
async applyTerminalCameraReconciliation() {
|
|
38819
39585
|
if (!this.cameraRelay) return;
|
|
39586
|
+
let terminalIntegrationId;
|
|
38820
39587
|
const topology = await this.ctx.api.nodes.topology.query();
|
|
38821
39588
|
if (topology.length === 0) throw new Error("cluster topology returned no nodes; refusing to withdraw declared cameras");
|
|
38822
39589
|
const nodes = topology.filter((node) => typeof node.id === "string" && node.id.length > 0);
|
|
39590
|
+
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
39591
|
+
for (const cachedNodeId of this.cameraProfilesByNode.keys()) if (!nodeIds.has(cachedNodeId)) this.cameraProfilesByNode.delete(cachedNodeId);
|
|
38823
39592
|
const unavailableNodeIds = /* @__PURE__ */ new Set();
|
|
38824
39593
|
await Promise.all(nodes.map(async (node) => {
|
|
38825
39594
|
try {
|
|
38826
39595
|
this.cameraProfilesByNode.set(node.id, await this.cameraRelay.listProfiles(node.id));
|
|
38827
39596
|
} catch (error) {
|
|
38828
|
-
|
|
38829
|
-
|
|
38830
|
-
|
|
38831
|
-
|
|
38832
|
-
|
|
38833
|
-
|
|
38834
|
-
}
|
|
39597
|
+
unavailableNodeIds.add(node.id);
|
|
39598
|
+
this.ctx.logger.warn("terminal profiles unavailable — keeping Terminal instance cameras offline", { meta: {
|
|
39599
|
+
nodeId: node.id,
|
|
39600
|
+
cachedProfiles: this.cameraProfilesByNode.has(node.id),
|
|
39601
|
+
error: error instanceof Error ? error.message : String(error)
|
|
39602
|
+
} });
|
|
38835
39603
|
}
|
|
38836
39604
|
}));
|
|
38837
|
-
const cameraDeclarations =
|
|
38838
|
-
|
|
38839
|
-
const existing = await this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id });
|
|
38840
|
-
const unavailableNodes = nodes.filter((node) => unavailableNodeIds.has(node.id)).sort((left, right) => right.id.length - left.id.length);
|
|
38841
|
-
for (const row of existing) {
|
|
38842
|
-
const node = unavailableNodes.find((candidate) => {
|
|
38843
|
-
const base = `terminal-camera-${candidate.id}`;
|
|
38844
|
-
return row.stableId === base || row.stableId.startsWith(`${base}-`);
|
|
38845
|
-
});
|
|
38846
|
-
if (!node || cameraDeclarations.some((camera) => camera.stableId === row.stableId)) continue;
|
|
38847
|
-
const base = `terminal-camera-${node.id}`;
|
|
38848
|
-
const profileId = row.stableId === base ? "monitor" : row.stableId.slice(base.length + 1);
|
|
38849
|
-
const profileLabel = profileId === "monitor" ? "BTM" : profileId;
|
|
38850
|
-
cameraDeclarations.push({
|
|
38851
|
-
stableId: row.stableId,
|
|
38852
|
-
name: `Terminal ${profileLabel} - ${node.isHub ? "Hub" : node.name}`,
|
|
38853
|
-
config: {
|
|
38854
|
-
nodeId: node.id,
|
|
38855
|
-
profileId,
|
|
38856
|
-
profileLabel
|
|
38857
|
-
}
|
|
38858
|
-
});
|
|
38859
|
-
}
|
|
38860
|
-
}
|
|
39605
|
+
const cameraDeclarations = buildTerminalInstanceCameraDeclarations(this.terminalInstances());
|
|
39606
|
+
const declarationsByStableId = new Map(cameraDeclarations.map((camera) => [camera.stableId, camera]));
|
|
38861
39607
|
const result = await new DeclaredDevices({
|
|
38862
39608
|
logger: this.ctx.logger.child("camera-declaration"),
|
|
38863
39609
|
addonId: this.ctx.id,
|
|
38864
39610
|
devices: this.ctx.kernel.devices,
|
|
38865
39611
|
localNodeId: this.ctx.kernel.localNodeId,
|
|
38866
|
-
getIntegration: async (addonId) =>
|
|
38867
|
-
|
|
39612
|
+
getIntegration: async (addonId) => {
|
|
39613
|
+
const integration = await this.ctx.api.integrations.getByAddonId.query({ addonId });
|
|
39614
|
+
terminalIntegrationId = integration?.id ?? null;
|
|
39615
|
+
return integration;
|
|
39616
|
+
},
|
|
39617
|
+
createIntegration: async (input) => {
|
|
39618
|
+
const integration = await this.ctx.api.integrations.create.mutate(input);
|
|
39619
|
+
terminalIntegrationId = integration.id;
|
|
39620
|
+
return integration;
|
|
39621
|
+
},
|
|
38868
39622
|
updateIntegration: async ({ id, info }) => {
|
|
38869
39623
|
await this.ctx.api.integrations.update.mutate({
|
|
38870
39624
|
id,
|
|
@@ -38872,7 +39626,9 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38872
39626
|
skipRestart: true
|
|
38873
39627
|
});
|
|
38874
39628
|
},
|
|
38875
|
-
listOwnDevices: async () =>
|
|
39629
|
+
listOwnDevices: async () => {
|
|
39630
|
+
return terminalCameraReconciliationIndex(await this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id }), terminalIntegrationId, cameraDeclarations, this.terminalCameraTombstones);
|
|
39631
|
+
}
|
|
38876
39632
|
}).reconcile({
|
|
38877
39633
|
integrationName: TERMINAL_CAMERA_INTEGRATION,
|
|
38878
39634
|
placement: "hub",
|
|
@@ -38885,12 +39641,176 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
38885
39641
|
role: "terminal-camera"
|
|
38886
39642
|
}))
|
|
38887
39643
|
});
|
|
38888
|
-
const onlineByNode = new Map(nodes.map((node) => [node.id, node.isOnline]));
|
|
39644
|
+
const onlineByNode = new Map(nodes.map((node) => [node.id, node.isOnline && !unavailableNodeIds.has(node.id)]));
|
|
38889
39645
|
for (const outcome of result.devices) if (outcome.device instanceof TerminalCameraDevice) {
|
|
39646
|
+
const declaration = declarationsByStableId.get(outcome.stableId);
|
|
39647
|
+
if (declaration) {
|
|
39648
|
+
const config = outcome.device.config;
|
|
39649
|
+
if (!(config.get("instanceId") === declaration.config.instanceId && config.get("nodeId") === declaration.config.nodeId && config.get("profileId") === declaration.config.profileId && config.get("profileLabel") === declaration.config.profileLabel) || !this.migratedTerminalCameraConfigIds.has(outcome.device.id) && needsTerminalCameraConfigMigration(outcome.device.ctx.persistedConfig ?? {}, declaration)) {
|
|
39650
|
+
await config.setAll(declaration.config);
|
|
39651
|
+
this.migratedTerminalCameraConfigIds.add(outcome.device.id);
|
|
39652
|
+
}
|
|
39653
|
+
}
|
|
39654
|
+
if (outcome.created) await this.silenceAnalysisFor(outcome.device.id);
|
|
38890
39655
|
const nodeId = outcome.device.config.get("nodeId");
|
|
38891
|
-
outcome.device.
|
|
39656
|
+
const profileId = outcome.device.config.get("profileId");
|
|
39657
|
+
const profileAvailable = this.cameraProfilesByNode.get(nodeId)?.some((profile) => profile.profileId === profileId) ?? false;
|
|
39658
|
+
outcome.device.setNodeOnline((onlineByNode.get(nodeId) ?? false) && profileAvailable);
|
|
39659
|
+
}
|
|
39660
|
+
}
|
|
39661
|
+
/**
|
|
39662
|
+
* A Terminal camera is a rendered screen. Object detection on it finds
|
|
39663
|
+
* nothing, forever, at full cost.
|
|
39664
|
+
*
|
|
39665
|
+
* Measured on the hub 2026-08-11, for ONE terminal camera: 19 frames per 10 s
|
|
39666
|
+
* through the detection pipeline at ~61 ms of inference each, plus 115
|
|
39667
|
+
* capture-scheduler requests a minute — against `detections=0`. Multiply by
|
|
39668
|
+
* one terminal per node and it is a standing tax on a hub that was already
|
|
39669
|
+
* shedding 86 % of its capture queue.
|
|
39670
|
+
*
|
|
39671
|
+
* Written through `setCameraSwitch`, which is the authority that already owns
|
|
39672
|
+
* this function — [D62] forbids a second store that disagrees with it. And
|
|
39673
|
+
* written ONLY on creation: an operator who deliberately turns detection back
|
|
39674
|
+
* on for a terminal must win, and a reconcile that re-asserted every pass
|
|
39675
|
+
* would silently overrule them once a minute.
|
|
39676
|
+
*/
|
|
39677
|
+
async silenceAnalysisFor(deviceId) {
|
|
39678
|
+
for (const switchId of ["object-detection", "audio-analysis"]) try {
|
|
39679
|
+
await this.ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
|
|
39680
|
+
deviceId,
|
|
39681
|
+
switchId,
|
|
39682
|
+
enabled: false
|
|
39683
|
+
});
|
|
39684
|
+
} catch (err) {
|
|
39685
|
+
this.ctx.logger.warn("could not switch off analysis for a Terminal camera", {
|
|
39686
|
+
tags: { deviceId },
|
|
39687
|
+
meta: {
|
|
39688
|
+
switchId,
|
|
39689
|
+
error: err instanceof Error ? err.message : String(err)
|
|
39690
|
+
}
|
|
39691
|
+
});
|
|
38892
39692
|
}
|
|
38893
39693
|
}
|
|
39694
|
+
terminalInstanceControl() {
|
|
39695
|
+
return {
|
|
39696
|
+
listInstances: async () => this.terminalInstances().map((instance) => this.instanceInfo(instance)),
|
|
39697
|
+
createInstance: async (input) => this.createTerminalInstance(input),
|
|
39698
|
+
deleteInstance: async ({ instanceId }) => this.deleteTerminalInstance(instanceId),
|
|
39699
|
+
setInstanceEnabled: async ({ instanceId, enabled }) => this.setTerminalInstanceEnabled(instanceId, enabled),
|
|
39700
|
+
listLegacyCameras: async () => this.listLegacyTerminalCameras(),
|
|
39701
|
+
adoptLegacyMonitor: async ({ stableId, name }) => this.adoptLegacyMonitor(stableId, name)
|
|
39702
|
+
};
|
|
39703
|
+
}
|
|
39704
|
+
terminalInstances() {
|
|
39705
|
+
return readTerminalInstances(this.config.terminalInstances, (message) => {
|
|
39706
|
+
this.ctx.logger.warn(message);
|
|
39707
|
+
});
|
|
39708
|
+
}
|
|
39709
|
+
instanceInfo(instance) {
|
|
39710
|
+
return {
|
|
39711
|
+
instanceId: instance.id,
|
|
39712
|
+
cameraStableId: instance.cameraStableId,
|
|
39713
|
+
nodeId: instance.nodeId,
|
|
39714
|
+
profileId: instance.profileId,
|
|
39715
|
+
profileLabel: instance.profileLabel,
|
|
39716
|
+
name: instance.name,
|
|
39717
|
+
enabled: instance.enabled
|
|
39718
|
+
};
|
|
39719
|
+
}
|
|
39720
|
+
replaceTerminalCameraTombstones(stableIds) {
|
|
39721
|
+
this.terminalCameraTombstones.clear();
|
|
39722
|
+
for (const stableId of stableIds) if (typeof stableId === "string" && stableId.length > 0) this.terminalCameraTombstones.add(stableId);
|
|
39723
|
+
}
|
|
39724
|
+
async createTerminalInstance(input) {
|
|
39725
|
+
const instance = await this.instanceMutationQueue.run(() => this.createTerminalInstanceUnlocked(input));
|
|
39726
|
+
await this.reconcileTerminalCameras();
|
|
39727
|
+
return instance;
|
|
39728
|
+
}
|
|
39729
|
+
async createTerminalInstanceUnlocked(input) {
|
|
39730
|
+
const relay = this.cameraRelay;
|
|
39731
|
+
if (!relay) throw new Error("Terminal instances are managed on the hub");
|
|
39732
|
+
const profile = (await relay.listProfiles(input.targetNodeId)).find((candidate) => candidate.profileId === input.profileId);
|
|
39733
|
+
if (!profile) throw new Error(`Terminal profile '${input.profileId}' is not available on ${input.targetNodeId}`);
|
|
39734
|
+
const node = (await this.ctx.api.nodes.topology.query()).find((candidate) => candidate.id === input.targetNodeId);
|
|
39735
|
+
if (!node) throw new Error(`Terminal node '${input.targetNodeId}' no longer exists`);
|
|
39736
|
+
const id = crypto.randomUUID();
|
|
39737
|
+
const name = input.name?.trim() || `Terminal ${profile.label} - ${node.isHub ? "Hub" : node.name}`;
|
|
39738
|
+
const instance = {
|
|
39739
|
+
id,
|
|
39740
|
+
cameraStableId: newTerminalCameraStableId(id),
|
|
39741
|
+
nodeId: input.targetNodeId,
|
|
39742
|
+
profileId: profile.profileId,
|
|
39743
|
+
profileLabel: profile.label,
|
|
39744
|
+
name,
|
|
39745
|
+
enabled: true
|
|
39746
|
+
};
|
|
39747
|
+
await this.updateGlobalSettings({ terminalInstances: [...this.config.terminalInstances, instance] });
|
|
39748
|
+
return this.instanceInfo(instance);
|
|
39749
|
+
}
|
|
39750
|
+
async deleteTerminalInstance(instanceId) {
|
|
39751
|
+
await this.instanceMutationQueue.run(() => this.deleteTerminalInstanceUnlocked(instanceId));
|
|
39752
|
+
await this.reconcileTerminalCameras();
|
|
39753
|
+
}
|
|
39754
|
+
async deleteTerminalInstanceUnlocked(instanceId) {
|
|
39755
|
+
const instance = this.terminalInstances().find((candidate) => candidate.id === instanceId);
|
|
39756
|
+
if (!instance) return;
|
|
39757
|
+
this.terminalCameraTombstones.add(instance.cameraStableId);
|
|
39758
|
+
await this.cameraRelay?.closeInstance(instance.id);
|
|
39759
|
+
await this.updateGlobalSettings({
|
|
39760
|
+
terminalInstances: this.config.terminalInstances.filter((candidate) => candidate.id !== instanceId),
|
|
39761
|
+
terminalCameraTombstones: [...new Set([...this.config.terminalCameraTombstones, instance.cameraStableId])]
|
|
39762
|
+
});
|
|
39763
|
+
}
|
|
39764
|
+
async setTerminalInstanceEnabled(instanceId, enabled) {
|
|
39765
|
+
const instance = await this.instanceMutationQueue.run(() => this.setTerminalInstanceEnabledUnlocked(instanceId, enabled));
|
|
39766
|
+
await this.reconcileTerminalCameras();
|
|
39767
|
+
return instance;
|
|
39768
|
+
}
|
|
39769
|
+
async setTerminalInstanceEnabledUnlocked(instanceId, enabled) {
|
|
39770
|
+
const instance = this.terminalInstances().find((candidate) => candidate.id === instanceId);
|
|
39771
|
+
if (!instance) throw new Error(`No such Terminal instance: ${instanceId}`);
|
|
39772
|
+
if (!enabled) {
|
|
39773
|
+
this.terminalCameraTombstones.add(instance.cameraStableId);
|
|
39774
|
+
await this.cameraRelay?.closeInstance(instance.id);
|
|
39775
|
+
}
|
|
39776
|
+
const updated = {
|
|
39777
|
+
...instance,
|
|
39778
|
+
enabled
|
|
39779
|
+
};
|
|
39780
|
+
const terminalInstances = this.config.terminalInstances.map((candidate) => candidate.id === instanceId ? updated : candidate);
|
|
39781
|
+
const terminalCameraTombstones = enabled ? this.config.terminalCameraTombstones.filter((stableId) => stableId !== instance.cameraStableId) : [...new Set([...this.config.terminalCameraTombstones, instance.cameraStableId])];
|
|
39782
|
+
await this.updateGlobalSettings({
|
|
39783
|
+
terminalInstances,
|
|
39784
|
+
terminalCameraTombstones
|
|
39785
|
+
});
|
|
39786
|
+
return this.instanceInfo(updated);
|
|
39787
|
+
}
|
|
39788
|
+
async listLegacyTerminalCameras() {
|
|
39789
|
+
const instances = this.terminalInstances();
|
|
39790
|
+
const instanceStableIds = new Set(instances.map((instance) => instance.cameraStableId));
|
|
39791
|
+
return listLegacyTerminalCameraCandidates(await this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id }), instanceStableIds, this.terminalCameraTombstones);
|
|
39792
|
+
}
|
|
39793
|
+
async adoptLegacyMonitor(stableId, requestedName) {
|
|
39794
|
+
const instance = await this.instanceMutationQueue.run(() => this.adoptLegacyMonitorUnlocked(stableId, requestedName));
|
|
39795
|
+
await this.reconcileTerminalCameras();
|
|
39796
|
+
return instance;
|
|
39797
|
+
}
|
|
39798
|
+
async adoptLegacyMonitorUnlocked(stableId, requestedName) {
|
|
39799
|
+
if (this.terminalCameraTombstones.has(stableId)) throw new Error("This legacy Terminal camera was deleted and cannot be adopted");
|
|
39800
|
+
const legacy = (await this.listLegacyTerminalCameras()).find((camera) => camera.stableId === stableId);
|
|
39801
|
+
if (!legacy?.adoptable) throw new Error("Only a legacy monitor camera with its original stable id can be adopted");
|
|
39802
|
+
const instance = {
|
|
39803
|
+
id: crypto.randomUUID(),
|
|
39804
|
+
cameraStableId: legacy.stableId,
|
|
39805
|
+
nodeId: legacy.nodeId,
|
|
39806
|
+
profileId: "monitor",
|
|
39807
|
+
profileLabel: legacy.profileLabel,
|
|
39808
|
+
name: requestedName?.trim() || legacy.name,
|
|
39809
|
+
enabled: true
|
|
39810
|
+
};
|
|
39811
|
+
await this.updateGlobalSettings({ terminalInstances: [...this.config.terminalInstances, instance] });
|
|
39812
|
+
return this.instanceInfo(instance);
|
|
39813
|
+
}
|
|
38894
39814
|
globalSettingsSchema() {
|
|
38895
39815
|
return this.schema({ sections: [{
|
|
38896
39816
|
id: "terminal",
|
|
@@ -39001,7 +39921,7 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39001
39921
|
}, {
|
|
39002
39922
|
id: "terminal-profiles",
|
|
39003
39923
|
title: "Custom profiles",
|
|
39004
|
-
description: "
|
|
39924
|
+
description: "Enabled profiles are available templates on this node. Create a Terminal instance on the Terminal page to declare a camera; session requests only carry the profile ID and commands cannot be overridden by clients.",
|
|
39005
39925
|
columns: 1,
|
|
39006
39926
|
fields: [this.field({
|
|
39007
39927
|
type: "editable-array",
|