@trillboards/edge-sdk 0.2.140 → 0.2.150
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/README.md +2 -2
- package/deploy/detector/pyproject.toml +20 -2
- package/deploy/detector/tests/test_execution.py +124 -0
- package/deploy/detector/tests/test_model_identity.py +80 -0
- package/deploy/detector/tests/test_registry.py +18 -0
- package/deploy/detector/tests/test_server.py +3 -1
- package/deploy/detector/tests/test_version_stamp.py +4 -1
- package/deploy/detector/trillboards_detector/_version.py +2 -2
- package/deploy/detector/trillboards_detector/cli.py +3 -0
- package/deploy/detector/trillboards_detector/detections.py +7 -0
- package/deploy/detector/trillboards_detector/errors.py +4 -0
- package/deploy/detector/trillboards_detector/execution.py +109 -0
- package/deploy/detector/trillboards_detector/loader.py +6 -3
- package/deploy/detector/trillboards_detector/registry.py +27 -5
- package/deploy/detector/trillboards_detector/server.py +29 -2
- package/deploy/detector/trillboards_detector/session.py +15 -3
- package/deploy/litertlm/test_trillboards_litertlm_shim.py +81 -0
- package/deploy/litertlm/test_verify_model_proof.py +30 -0
- package/deploy/litertlm/trillboards_litertlm_shim.py +150 -1
- package/deploy/litertlm/verify_model_proof.py +30 -0
- package/deploy/macos/build-macos-pkg.sh +10 -10
- package/deploy/macos/scripts/setup-detector.sh +2 -2
- package/deploy/macos/scripts/setup-litertlm.sh +66 -30
- package/deploy/macos/test-setup-litertlm-reinstall.sh +57 -4
- package/deploy/systemd/setup-detector.sh +2 -2
- package/deploy/systemd/setup-litertlm.sh +51 -27
- package/deploy/windows/build-windows.ps1 +10 -6
- package/deploy/windows/bundle-windows-installer.ps1 +3 -3
- package/deploy/windows/e2e/test-local-sense.ps1 +6 -5
- package/deploy/windows/install-update.ps1 +52 -9
- package/deploy/windows/set-executable-icon.ps1 +24 -5
- package/deploy/windows/setup-detector.ps1 +142 -4
- package/deploy/windows/setup-litertlm.ps1 +53 -10
- package/dist/AudienceAnalyzeClient.d.ts +10 -0
- package/dist/AudienceAnalyzeClient.d.ts.map +1 -1
- package/dist/AudienceAnalyzeClient.js +5 -4
- package/dist/AudienceAnalyzeClient.js.map +1 -1
- package/dist/CloudSensingManager.d.ts +38 -1
- package/dist/CloudSensingManager.d.ts.map +1 -1
- package/dist/CloudSensingManager.js +403 -25
- package/dist/CloudSensingManager.js.map +1 -1
- package/dist/DetectorFieldProducer.d.ts +5 -1
- package/dist/DetectorFieldProducer.d.ts.map +1 -1
- package/dist/DetectorFieldProducer.js +8 -1
- package/dist/DetectorFieldProducer.js.map +1 -1
- package/dist/DurableVlmCandidateSpool.d.ts +83 -2
- package/dist/DurableVlmCandidateSpool.d.ts.map +1 -1
- package/dist/DurableVlmCandidateSpool.js +596 -30
- package/dist/DurableVlmCandidateSpool.js.map +1 -1
- package/dist/EdgeAgent.js +1 -1
- package/dist/FrameGroupingShadow.d.ts +51 -0
- package/dist/FrameGroupingShadow.d.ts.map +1 -0
- package/dist/FrameGroupingShadow.js +158 -0
- package/dist/FrameGroupingShadow.js.map +1 -0
- package/dist/ImageClassifierFieldProducer.d.ts +36 -0
- package/dist/ImageClassifierFieldProducer.d.ts.map +1 -0
- package/dist/ImageClassifierFieldProducer.js +150 -0
- package/dist/ImageClassifierFieldProducer.js.map +1 -0
- package/dist/LiteRtVlmProvider.d.ts +5 -0
- package/dist/LiteRtVlmProvider.d.ts.map +1 -1
- package/dist/LiteRtVlmProvider.js +72 -7
- package/dist/LiteRtVlmProvider.js.map +1 -1
- package/dist/LocalDetectorClient.d.ts +6 -0
- package/dist/LocalDetectorClient.d.ts.map +1 -1
- package/dist/LocalDetectorClient.js +8 -0
- package/dist/LocalDetectorClient.js.map +1 -1
- package/dist/OrderedFrameReview.d.ts +57 -0
- package/dist/OrderedFrameReview.d.ts.map +1 -0
- package/dist/OrderedFrameReview.js +121 -0
- package/dist/OrderedFrameReview.js.map +1 -0
- package/dist/OrderedReviewClient.d.ts +38 -0
- package/dist/OrderedReviewClient.d.ts.map +1 -0
- package/dist/OrderedReviewClient.js +121 -0
- package/dist/OrderedReviewClient.js.map +1 -0
- package/dist/OrderedSequenceStore.d.ts +69 -0
- package/dist/OrderedSequenceStore.d.ts.map +1 -0
- package/dist/OrderedSequenceStore.js +257 -0
- package/dist/OrderedSequenceStore.js.map +1 -0
- package/dist/ProfileReviewWork.d.ts +68 -0
- package/dist/ProfileReviewWork.d.ts.map +1 -0
- package/dist/ProfileReviewWork.js +150 -0
- package/dist/ProfileReviewWork.js.map +1 -0
- package/dist/RetainedFieldContract.d.ts +21 -0
- package/dist/RetainedFieldContract.d.ts.map +1 -0
- package/dist/RetainedFieldContract.js +92 -0
- package/dist/RetainedFieldContract.js.map +1 -0
- package/dist/ReviewModelUse.d.ts +7 -0
- package/dist/ReviewModelUse.d.ts.map +1 -0
- package/dist/ReviewModelUse.js +30 -0
- package/dist/ReviewModelUse.js.map +1 -0
- package/dist/ReviewPriority.d.ts +12 -0
- package/dist/ReviewPriority.d.ts.map +1 -0
- package/dist/ReviewPriority.js +45 -0
- package/dist/ReviewPriority.js.map +1 -0
- package/dist/RuntimeModelSelection.d.ts +10 -0
- package/dist/RuntimeModelSelection.d.ts.map +1 -0
- package/dist/RuntimeModelSelection.js +58 -0
- package/dist/RuntimeModelSelection.js.map +1 -0
- package/dist/SceneChangeDetector.d.ts +1 -1
- package/dist/SceneChangeDetector.d.ts.map +1 -1
- package/dist/SceneChangeDetector.js +18 -6
- package/dist/SceneChangeDetector.js.map +1 -1
- package/dist/ScopedVlmReview.d.ts +76 -0
- package/dist/ScopedVlmReview.d.ts.map +1 -0
- package/dist/ScopedVlmReview.js +267 -0
- package/dist/ScopedVlmReview.js.map +1 -0
- package/dist/SensingProfileClient.d.ts +50 -0
- package/dist/SensingProfileClient.d.ts.map +1 -1
- package/dist/SensingProfileClient.js +58 -2
- package/dist/SensingProfileClient.js.map +1 -1
- package/dist/TemporalReviewClient.d.ts +43 -0
- package/dist/TemporalReviewClient.d.ts.map +1 -0
- package/dist/TemporalReviewClient.js +117 -0
- package/dist/TemporalReviewClient.js.map +1 -0
- package/dist/TemporalReviewJournal.d.ts +91 -0
- package/dist/TemporalReviewJournal.d.ts.map +1 -0
- package/dist/TemporalReviewJournal.js +337 -0
- package/dist/TemporalReviewJournal.js.map +1 -0
- package/dist/UrgentReviewTriage.d.ts +35 -0
- package/dist/UrgentReviewTriage.d.ts.map +1 -0
- package/dist/UrgentReviewTriage.js +52 -0
- package/dist/UrgentReviewTriage.js.map +1 -0
- package/dist/VideoSegmentUploader.d.ts +293 -0
- package/dist/VideoSegmentUploader.d.ts.map +1 -0
- package/dist/VideoSegmentUploader.js +1002 -0
- package/dist/VideoSegmentUploader.js.map +1 -0
- package/dist/VlmCandidateBuffer.d.ts +19 -0
- package/dist/VlmCandidateBuffer.d.ts.map +1 -1
- package/dist/VlmCandidateBuffer.js.map +1 -1
- package/dist/autoSense.d.ts +2 -2
- package/dist/autoSense.d.ts.map +1 -1
- package/dist/autoSense.js +16 -11
- package/dist/autoSense.js.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/continuousReview.d.ts +71 -0
- package/dist/continuousReview.d.ts.map +1 -0
- package/dist/continuousReview.js +53 -0
- package/dist/continuousReview.js.map +1 -0
- package/dist/detectorCountReview.d.ts +9 -0
- package/dist/detectorCountReview.d.ts.map +1 -0
- package/dist/detectorCountReview.js +79 -0
- package/dist/detectorCountReview.js.map +1 -0
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/dist/installerOnboardingMode.d.ts.map +1 -1
- package/dist/installerOnboardingMode.js +5 -3
- package/dist/installerOnboardingMode.js.map +1 -1
- package/dist/localInferenceSchema.d.ts +8 -0
- package/dist/localInferenceSchema.d.ts.map +1 -1
- package/dist/localInferenceSchema.js +3 -3
- package/dist/localInferenceSchema.js.map +1 -1
- package/dist/localSenseE2eHarness.d.ts +12 -0
- package/dist/localSenseE2eHarness.d.ts.map +1 -1
- package/dist/localSenseE2eHarness.js +77 -4
- package/dist/localSenseE2eHarness.js.map +1 -1
- package/dist/localSensing.d.ts +91 -18
- package/dist/localSensing.d.ts.map +1 -1
- package/dist/localSensing.js +678 -153
- package/dist/localSensing.js.map +1 -1
- package/dist/mixedScopedCounts.d.ts +17 -0
- package/dist/mixedScopedCounts.d.ts.map +1 -0
- package/dist/mixedScopedCounts.js +90 -0
- package/dist/mixedScopedCounts.js.map +1 -0
- package/dist/sensingCatchRate.d.ts +9 -5
- package/dist/sensingCatchRate.d.ts.map +1 -1
- package/dist/sensingCatchRate.js +10 -6
- package/dist/sensingCatchRate.js.map +1 -1
- package/dist/sensingRunMode.d.ts +5 -0
- package/dist/sensingRunMode.d.ts.map +1 -1
- package/dist/sensingRunMode.js +56 -0
- package/dist/sensingRunMode.js.map +1 -1
- package/package.json +7 -4
- package/retained/ArchiveLifecycle.cjs +79 -0
- package/retained/ModelStageScheduler.cjs +66 -0
- package/retained/ProfileAuthorityClient.cjs +88 -0
- package/retained/RetainedCaptureClient.cjs +164 -0
- package/retained/WaitRecheckCaptureClient.cjs +109 -0
- package/retained/__tests__/archive-lifecycle.test.cjs +117 -0
- package/retained/__tests__/runtime.test.cjs +384 -0
- package/retained/alert-rule-policy.cjs +37 -0
- package/retained/manager.cjs +133 -0
- package/retained/pilot-profile-client.cjs +82 -0
- package/retained/temporal-grouping.cjs +78 -0
- package/retained/website-api-capability.cjs +122 -0
|
@@ -6,7 +6,14 @@ exports.buildSensingLoops = buildSensingLoops;
|
|
|
6
6
|
exports.buildTiledCameras = buildTiledCameras;
|
|
7
7
|
exports.resolveFeeds = resolveFeeds;
|
|
8
8
|
const events_1 = require("events");
|
|
9
|
+
const crypto_1 = require("crypto");
|
|
10
|
+
const FrameGroupingShadow_1 = require("./FrameGroupingShadow");
|
|
11
|
+
const ProfileReviewWork_1 = require("./ProfileReviewWork");
|
|
12
|
+
const ReviewPriority_1 = require("./ReviewPriority");
|
|
13
|
+
const continuousReview_1 = require("./continuousReview");
|
|
9
14
|
const localNetworkAccess_1 = require("./localNetworkAccess");
|
|
15
|
+
const LocalDetectorClient_1 = require("./LocalDetectorClient");
|
|
16
|
+
const UrgentReviewTriage_1 = require("./UrgentReviewTriage");
|
|
10
17
|
const MosaicSource_1 = require("./MosaicSource");
|
|
11
18
|
const runtimePaths_1 = require("./runtimePaths");
|
|
12
19
|
const legacyRecorderProfile_1 = require("./legacyRecorderProfile");
|
|
@@ -265,6 +272,15 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
265
272
|
*/
|
|
266
273
|
this.localNetworkVerdict = null;
|
|
267
274
|
this.localNetworkProbeInFlight = false;
|
|
275
|
+
this.urgentTriageClients = new Map();
|
|
276
|
+
this.urgentTriageBusy = false;
|
|
277
|
+
this.frameGroupers = new Map();
|
|
278
|
+
this.reviewsInFlight = new Set();
|
|
279
|
+
// Physical reservations outlive lease expiry and manager stop/start until
|
|
280
|
+
// the original promise settles, including the original feed identity.
|
|
281
|
+
this.modelReviewsInFlight = new Set();
|
|
282
|
+
this.uploadsInFlight = new Set();
|
|
283
|
+
this.deliveryRetryAtMs = 0;
|
|
268
284
|
this.candidatePumpTimer = null;
|
|
269
285
|
/**
|
|
270
286
|
* What the ARMED gate actually did, connector-wide.
|
|
@@ -365,12 +381,14 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
365
381
|
// cloud path (a genuinely concurrent backend) exactly as it is today; the
|
|
366
382
|
// local path passes 1 because its backend is single-conversation.
|
|
367
383
|
this.admissionGate = new VlmAdmissionGate_1.VlmAdmissionGate({
|
|
368
|
-
maxInFlight: Math.max(1, Math.trunc(options.maxConcurrentAnalyses ??
|
|
384
|
+
maxInFlight: Math.max(1, Math.trunc(options.candidateBuffer?.continuousReview?.parallelModelReview === true ? 2 : options.maxConcurrentAnalyses ??
|
|
385
|
+
(options.candidateBuffer?.continuousReview?.enabled ? 1 : this.loops.length))),
|
|
369
386
|
maxInFlightMs: options.maxAnalysisInFlightMs,
|
|
370
387
|
// Fairness only matters when feeds actually compete, which is exactly the
|
|
371
388
|
// single-slot local path. With a slot per loop nobody is starved and the
|
|
372
389
|
// policy would only add a hold window for nothing.
|
|
373
|
-
fairShareAcrossFeeds:
|
|
390
|
+
fairShareAcrossFeeds: options.candidateBuffer?.continuousReview?.enabled !== true &&
|
|
391
|
+
Math.max(1, Math.trunc(options.maxConcurrentAnalyses ?? this.loops.length)) < this.loops.length,
|
|
374
392
|
// Forwarded, NOT defaulted here: the gate owns both defaults, so an
|
|
375
393
|
// omitted option leaves its behaviour byte-for-byte unchanged.
|
|
376
394
|
fairShareGraceMs: options.fairShareGraceMs,
|
|
@@ -438,11 +456,58 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
438
456
|
// without a classifier, because that is the queue the gate refuses.
|
|
439
457
|
const candidateBufferEnabled = motionGateEnabled && options.candidateBuffer?.enabled !== false;
|
|
440
458
|
const diskSpool = options.candidateBuffer?.diskSpool;
|
|
459
|
+
const review = options.candidateBuffer?.continuousReview;
|
|
460
|
+
const urgentTriage = (0, UrgentReviewTriage_1.freezeUrgentTriage)(review?.urgentTriage, options.loops.map(loop => loop.name), review?.enabled === true && review.independentUploads === true);
|
|
461
|
+
if (urgentTriage)
|
|
462
|
+
for (const [name, policy] of Object.entries(urgentTriage.byFeed)) {
|
|
463
|
+
this.urgentTriageClients.set(name, new LocalDetectorClient_1.LocalDetectorClient({ baseUrl: urgentTriage.baseUrl,
|
|
464
|
+
timeoutMs: urgentTriage.timeoutMs, classes: [policy.label], minConfidence: policy.minConfidence,
|
|
465
|
+
expectedModel: { modelId: policy.modelId, sha256: policy.modelSha256 } }));
|
|
466
|
+
}
|
|
467
|
+
this.reviewPriorities = (0, ReviewPriority_1.freezeReviewPriorities)(review?.priorityByFeed, options.loops.map(loop => loop.name), review?.enabled === true);
|
|
468
|
+
this.separateProfileWork = review?.enabled === true && review.frameGrouping?.separateProfileWork === true;
|
|
469
|
+
this.continuousReview = review?.enabled === true ? {
|
|
470
|
+
enabled: true,
|
|
471
|
+
reviewWindowMs: review.reviewWindowMs ?? continuousReview_1.DEFAULT_REVIEW_WINDOW_MS,
|
|
472
|
+
retentionMs: review.retentionMs ?? continuousReview_1.DEFAULT_REVIEW_RETENTION_MS,
|
|
473
|
+
reuseExactPendingImages: review.reuseExactPendingImages === true,
|
|
474
|
+
reuseExactRecentImages: review.reuseExactRecentImages === true,
|
|
475
|
+
independentUploads: review.independentUploads === true,
|
|
476
|
+
parallelModelReview: review.parallelModelReview === true,
|
|
477
|
+
} : null;
|
|
478
|
+
if (this.continuousReview && review?.frameGrouping) {
|
|
479
|
+
for (const loop of options.loops)
|
|
480
|
+
this.frameGroupers.set(loop.name, new FrameGroupingShadow_1.FrameGroupingShadow(review.frameGrouping));
|
|
481
|
+
}
|
|
482
|
+
if (this.continuousReview && (!candidateBufferEnabled || diskSpool?.enabled === false || !diskSpool?.directory ||
|
|
483
|
+
[this.continuousReview.reviewWindowMs, this.continuousReview.retentionMs]
|
|
484
|
+
.some(v => !Number.isFinite(v) || v < 1) ||
|
|
485
|
+
this.continuousReview.retentionMs < this.continuousReview.reviewWindowMs))
|
|
486
|
+
throw new Error('Continuous review requires durable storage and valid separate freshness/retention limits');
|
|
487
|
+
if (this.continuousReview) {
|
|
488
|
+
const pairedApi = review?.deliveryTarget === 'configured-api' ? options.retainedApiBaseUrl : undefined;
|
|
489
|
+
if (review?.deliveryTarget === 'configured-api' && !pairedApi)
|
|
490
|
+
throw Error('Configured API review requires the paired API');
|
|
491
|
+
for (const loop of options.loops)
|
|
492
|
+
(0, continuousReview_1.assertRetainedReviewReceiver)(loop.client.getEndpoint(), pairedApi);
|
|
493
|
+
}
|
|
494
|
+
if (this.continuousReview?.independentUploads &&
|
|
495
|
+
options.loops.some(loop => typeof loop.client.reviewCandidate !== 'function')) {
|
|
496
|
+
throw new Error('Independent uploads require a local client with durable review support');
|
|
497
|
+
}
|
|
498
|
+
if (options.loops.some(loop => loop.client.retainOrderedCapture)
|
|
499
|
+
&& (!this.continuousReview?.independentUploads || !candidateBufferEnabled || !diskSpool?.directory))
|
|
500
|
+
throw Error('Ordered capture requires the continuous durable review workflow');
|
|
501
|
+
if (review?.parallelModelReview && (!this.continuousReview?.independentUploads
|
|
502
|
+
|| (options.maxConcurrentAnalyses ?? 1) !== 1 || urgentTriage
|
|
503
|
+
|| options.detectorShadow?.isEnabled())) {
|
|
504
|
+
throw Error('Parallel model review requires one slot per model, independent uploads, and no auxiliary inference');
|
|
505
|
+
}
|
|
441
506
|
const candidateTtlMs = Math.max(1, Math.trunc(options.candidateBuffer?.ttlMs ?? VlmCandidateBuffer_1.DEFAULT_CANDIDATE_TTL_MS));
|
|
442
507
|
// Disk durability must never widen the alert-freshness window. Operators
|
|
443
508
|
// may narrow it, but a legacy 24-hour diskSpool.retentionMs is clamped to
|
|
444
509
|
// the same TTL the in-memory hold would have enforced.
|
|
445
|
-
const durableRetentionMs = Math.min(candidateTtlMs, Math.max(1, Math.trunc(diskSpool?.retentionMs ?? candidateTtlMs)));
|
|
510
|
+
const durableRetentionMs = this.continuousReview?.retentionMs ?? Math.min(candidateTtlMs, Math.max(1, Math.trunc(diskSpool?.retentionMs ?? candidateTtlMs)));
|
|
446
511
|
this.candidates = !candidateBufferEnabled
|
|
447
512
|
? null
|
|
448
513
|
: diskSpool?.enabled !== false && diskSpool?.directory
|
|
@@ -450,6 +515,15 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
450
515
|
...diskSpool,
|
|
451
516
|
directory: diskSpool.directory,
|
|
452
517
|
retentionMs: durableRetentionMs,
|
|
518
|
+
// Do not allow the lower-level disk option to bypass the manager's
|
|
519
|
+
// explicit captured profile/feed policy and loopback review guard.
|
|
520
|
+
priorityScheduling: this.continuousReview !== null,
|
|
521
|
+
urgentTriagePolicies: urgentTriage?.byFeed,
|
|
522
|
+
...(this.continuousReview ? {
|
|
523
|
+
reviewWindowMs: this.continuousReview.reviewWindowMs,
|
|
524
|
+
reuseExactPendingImages: this.continuousReview.reuseExactPendingImages,
|
|
525
|
+
reuseExactRecentImages: this.continuousReview.reuseExactRecentImages,
|
|
526
|
+
} : {}),
|
|
453
527
|
now: this.now,
|
|
454
528
|
})
|
|
455
529
|
: new VlmCandidateBuffer_1.VlmCandidateBuffer({ ...options.candidateBuffer, now: this.now });
|
|
@@ -723,6 +797,11 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
723
797
|
writeFailed: candidateStats?.writeFailed ?? 0,
|
|
724
798
|
readFailed: candidateStats?.readFailed ?? 0,
|
|
725
799
|
deleteFailed: candidateStats?.deleteFailed ?? 0,
|
|
800
|
+
...(this.continuousReview ? {
|
|
801
|
+
reviewWindowMs: this.continuousReview.reviewWindowMs,
|
|
802
|
+
delayed: candidateStats?.delayed ?? 0,
|
|
803
|
+
capacityRejected: candidateStats?.capacityRejected ?? 0,
|
|
804
|
+
} : {}),
|
|
726
805
|
},
|
|
727
806
|
accelerator: foldAcceleratorHealth(this.loops.map((s) => acceleratorHealthFor(s)).filter((a) => a !== null)),
|
|
728
807
|
detectorShadow: this.detectorShadow?.getStats() ?? EMPTY_DETECTOR_SHADOW_STATS,
|
|
@@ -758,6 +837,8 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
758
837
|
// in-memory implementation resets here, preserving its historical clean
|
|
759
838
|
// stop/start semantics.
|
|
760
839
|
this.candidates?.startRun();
|
|
840
|
+
for (const grouper of this.frameGroupers.values())
|
|
841
|
+
grouper.reset();
|
|
761
842
|
this.healthOnlyMode = opts.healthOnly === true;
|
|
762
843
|
this.running = true;
|
|
763
844
|
this.runStartedAtMs = this.now();
|
|
@@ -1016,6 +1097,10 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
1016
1097
|
if (startupDelayMs > 0) {
|
|
1017
1098
|
await sleep(startupDelayMs);
|
|
1018
1099
|
}
|
|
1100
|
+
if (!this.isCurrentStart(generation))
|
|
1101
|
+
return;
|
|
1102
|
+
if (this.continuousReview)
|
|
1103
|
+
await s.deps.client.prepareReview?.();
|
|
1019
1104
|
if (!this.isCurrentStart(generation))
|
|
1020
1105
|
return;
|
|
1021
1106
|
needsOpen = !s.deps.camera.isOpen();
|
|
@@ -1099,7 +1184,7 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
1099
1184
|
// A health-only run is not sensing, so neither the face-triggered cadence
|
|
1100
1185
|
// nor the motion scan interval applies — it beats on the camera-health
|
|
1101
1186
|
// heartbeat, which is exactly the clock the portal ages a feed against.
|
|
1102
|
-
if (this.healthOnlyMode)
|
|
1187
|
+
if (this.healthOnlyMode || s.deps.client.isReviewActive?.() === false)
|
|
1103
1188
|
return this.cameraHealthHeartbeatIntervalMs;
|
|
1104
1189
|
const scan = this.motionScanIntervalMs;
|
|
1105
1190
|
if (scan !== null) {
|
|
@@ -1482,7 +1567,8 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
1482
1567
|
frameProven: true,
|
|
1483
1568
|
// Two different reasons a proven frame is not being analyzed: the gate
|
|
1484
1569
|
// is full (sensing IS running), or there is no model to analyze with.
|
|
1485
|
-
sensingState: this.healthOnlyMode ? 'unavailable'
|
|
1570
|
+
sensingState: this.healthOnlyMode ? 'unavailable'
|
|
1571
|
+
: s.deps.client.isReviewActive?.() === false ? 'idle' : 'waiting_for_capacity',
|
|
1486
1572
|
force: true,
|
|
1487
1573
|
});
|
|
1488
1574
|
this.emit('frameCaptured', {
|
|
@@ -1530,10 +1616,19 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
1530
1616
|
* cheaper to hold than the argument about whether the delta mattered.
|
|
1531
1617
|
*/
|
|
1532
1618
|
captureAndPost(s) {
|
|
1619
|
+
if (s.deps.client.isReviewActive?.() === false) {
|
|
1620
|
+
// Refresh in the background so saving a profile on the website arms this
|
|
1621
|
+
// feed without restarting the connector. Saved pending evidence keeps its
|
|
1622
|
+
// own old contract and is still drained by the independent review pump.
|
|
1623
|
+
void s.deps.client.prepareReview?.().catch(error => this.emitLoopError(s, error));
|
|
1624
|
+
return this.captureHealthFrame(s).then(() => null);
|
|
1625
|
+
}
|
|
1533
1626
|
if (!s.deps.camera.isOpen()) {
|
|
1534
1627
|
return Promise.reject(new Error(`CloudSensingManager: camera "${s.deps.name}" is not open`));
|
|
1535
1628
|
}
|
|
1536
1629
|
if (this.candidates && s.motionGate) {
|
|
1630
|
+
if (this.continuousReview)
|
|
1631
|
+
return this.lookAndRetain(s);
|
|
1537
1632
|
return this.lookThenAdmit(s, this.candidates, s.motionGate);
|
|
1538
1633
|
}
|
|
1539
1634
|
return this.admitThenLook(s);
|
|
@@ -1546,6 +1641,100 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
1546
1641
|
* goes into the bounded hold instead of onto the floor; anything the cascade
|
|
1547
1642
|
* rejected is dropped exactly as before and is never offered.
|
|
1548
1643
|
*/
|
|
1644
|
+
async lookAndRetain(s) {
|
|
1645
|
+
const generation = this.startGeneration;
|
|
1646
|
+
const frame = await this.captureCameraFrame(s);
|
|
1647
|
+
const capturedAtMs = this.now();
|
|
1648
|
+
const res = safeGetResolution(s.deps.camera);
|
|
1649
|
+
const jpeg = await this.toJpeg(frame, res?.width, res?.height);
|
|
1650
|
+
if (!this.isCurrentStart(generation))
|
|
1651
|
+
return null;
|
|
1652
|
+
s.captureCount++;
|
|
1653
|
+
this.reportCameraOpen(s, { frameProven: true, sensingState: 'waiting_for_capacity' });
|
|
1654
|
+
this.emit('frameCaptured', { name: s.deps.name, faceDetected: s.faceDetected });
|
|
1655
|
+
// The gate still classifies motion/calibration, but completed analyses do
|
|
1656
|
+
// not veto new evidence. All expensive work runs in the shared pump.
|
|
1657
|
+
const grouper = this.frameGroupers.get(s.deps.name);
|
|
1658
|
+
let groupingFrame;
|
|
1659
|
+
let backgroundCalibrating = false;
|
|
1660
|
+
const verdict = await s.motionGate.evaluate(jpeg, undefined, true, grouper ? (grey, motion) => {
|
|
1661
|
+
groupingFrame = grey;
|
|
1662
|
+
backgroundCalibrating = motion.calibrating;
|
|
1663
|
+
} : undefined);
|
|
1664
|
+
if (!this.isCurrentStart(generation))
|
|
1665
|
+
return null;
|
|
1666
|
+
const reviewContext = s.deps.client.captureReviewContext?.();
|
|
1667
|
+
if (reviewContext?.profileState === 'inactive') {
|
|
1668
|
+
// A website refresh can finish during decode/motion evaluation. Do not
|
|
1669
|
+
// archive or infer a frame under the now-empty/default profile snapshot.
|
|
1670
|
+
this.reportCameraOpen(s, { frameProven: true, sensingState: 'idle' });
|
|
1671
|
+
return null;
|
|
1672
|
+
}
|
|
1673
|
+
// The explicit local temporal adapter needs observations while a scene is
|
|
1674
|
+
// still (e.g. an open freezer). Motion alone cannot prove elapsed state.
|
|
1675
|
+
const temporalObservation = !!reviewContext?.temporalReviewContract || !!reviewContext?.orderedCaptureContract || !!s.deps.client.retainOrderedCapture;
|
|
1676
|
+
let groupingShadow;
|
|
1677
|
+
let profileWork;
|
|
1678
|
+
if (grouper) {
|
|
1679
|
+
try {
|
|
1680
|
+
groupingShadow = grouper.observe(groupingFrame, (0, FrameGroupingShadow_1.groupingScopes)(reviewContext), reviewContext, capturedAtMs, (0, crypto_1.createHash)('sha256').update(jpeg).digest('hex'),
|
|
1681
|
+
// Background calibration does not invalidate these decoded raw pixels.
|
|
1682
|
+
// Grouping owns its reference; the first view and visible changes select.
|
|
1683
|
+
verdict.reason === 'audio_onset' || verdict.reason === 'undecodable');
|
|
1684
|
+
}
|
|
1685
|
+
catch (error) {
|
|
1686
|
+
// An optional experiment must never stop the current evidence path.
|
|
1687
|
+
grouper.reset();
|
|
1688
|
+
this.emit('frameGroupingError', { name: s.deps.name, capturedAtMs, error: String(error) });
|
|
1689
|
+
}
|
|
1690
|
+
if (groupingShadow)
|
|
1691
|
+
this.emit('frameGroupingShadow', {
|
|
1692
|
+
name: s.deps.name, currentAdmission: verdict.analyze, currentReason: verdict.reason,
|
|
1693
|
+
backgroundCalibrating, ...groupingShadow,
|
|
1694
|
+
});
|
|
1695
|
+
if (this.separateProfileWork && groupingShadow) {
|
|
1696
|
+
profileWork = (0, ProfileReviewWork_1.planProfileReview)(reviewContext, groupingShadow);
|
|
1697
|
+
this.emit('frameProfileWork', { name: s.deps.name, currentAdmission: verdict.analyze,
|
|
1698
|
+
planned: !!profileWork, work: profileWork });
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
if (this.motionDrivesCadence)
|
|
1702
|
+
this.applyMotionCadence(s, verdict.motion);
|
|
1703
|
+
// An explicit raw-capture archive must retain quiet originals too.
|
|
1704
|
+
if (!verdict.analyze && !temporalObservation && !s.deps.client.retainOrderedCapture) {
|
|
1705
|
+
s.motionSkipCount++;
|
|
1706
|
+
s.runMotionSkipped++;
|
|
1707
|
+
this.emit('motionSkipped', { name: s.deps.name, reason: verdict.reason,
|
|
1708
|
+
pctMotion: verdict.pctMotion, regions: verdict.boxes.length });
|
|
1709
|
+
return null;
|
|
1710
|
+
}
|
|
1711
|
+
const capturedPolicy = (0, ReviewPriority_1.parseReviewPolicy)(reviewContext?.reviewPolicy);
|
|
1712
|
+
const reviewPriority = capturedPolicy && 'priority' in capturedPolicy
|
|
1713
|
+
? capturedPolicy.priority : this.reviewPriorities?.get(s.deps.name) ?? 'normal';
|
|
1714
|
+
if (capturedPolicy && 'invalid' in capturedPolicy) {
|
|
1715
|
+
this.emit('profileReviewPolicyError', { name: s.deps.name, capturedAtMs, reason: 'invalid_profile_review_policy' });
|
|
1716
|
+
}
|
|
1717
|
+
const capturedCandidate = {
|
|
1718
|
+
name: s.deps.name, jpeg, enqueuedAtMs: capturedAtMs,
|
|
1719
|
+
captureMode: s.faceDetected ? 'face_triggered' : 'periodic',
|
|
1720
|
+
reason: verdict.reason, signature: verdict.signature,
|
|
1721
|
+
reviewKind: verdict.reason === 'calibrating' || (0, SceneChangeDetector_1.isChangeCandidate)(verdict) ? 'event' : 'sample',
|
|
1722
|
+
reviewContext, groupingShadow, profileWork,
|
|
1723
|
+
reviewPriority,
|
|
1724
|
+
imageWidth: res?.width, imageHeight: res?.height,
|
|
1725
|
+
};
|
|
1726
|
+
if (s.deps.client.retainOrderedCapture) {
|
|
1727
|
+
s.deps.client.retainOrderedCapture(capturedCandidate);
|
|
1728
|
+
this.emit('orderedEvidenceRetained', { name: s.deps.name, capturedAtMs });
|
|
1729
|
+
this.pumpCandidates(generation);
|
|
1730
|
+
return null;
|
|
1731
|
+
}
|
|
1732
|
+
const offered = this.candidates.offer(capturedCandidate);
|
|
1733
|
+
this.emit('evidenceOffered', { name: s.deps.name, capturedAtMs, outcome: offered,
|
|
1734
|
+
reviewPriority });
|
|
1735
|
+
this.pumpCandidates(generation);
|
|
1736
|
+
return null;
|
|
1737
|
+
}
|
|
1549
1738
|
async lookThenAdmit(s, candidates, motionGate) {
|
|
1550
1739
|
const frame = await this.captureCameraFrame(s);
|
|
1551
1740
|
const capturedAtMs = this.now();
|
|
@@ -1886,7 +2075,7 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
1886
2075
|
* try/catch around the call stays at each site, where its `unavailable`
|
|
1887
2076
|
* report is visible next to the thing that can fail.
|
|
1888
2077
|
*/
|
|
1889
|
-
recordAnalysis(s, response, signature) {
|
|
2078
|
+
recordAnalysis(s, response, signature, historical = false) {
|
|
1890
2079
|
s.postCount++;
|
|
1891
2080
|
// A RESOLVED POST IS NOT AN ACCEPTED ONE.
|
|
1892
2081
|
//
|
|
@@ -1958,9 +2147,11 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
1958
2147
|
// actually landed, so a frame lost to the network, to an eviction or to the
|
|
1959
2148
|
// freshness bound cannot silence a feed for a heartbeat window or suppress
|
|
1960
2149
|
// the rest of its event as `stationary`.
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
2150
|
+
if (!historical) {
|
|
2151
|
+
s.motionGate?.markAnalyzed(undefined, signature);
|
|
2152
|
+
this.reportCameraOpen(s, { sensingState: 'active' });
|
|
2153
|
+
}
|
|
2154
|
+
this.emit('analyzed', { name: s.deps.name, response, ...(historical ? { delayed: true } : {}) });
|
|
1964
2155
|
return 'accepted';
|
|
1965
2156
|
}
|
|
1966
2157
|
/**
|
|
@@ -1990,6 +2181,49 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
1990
2181
|
if (typeof this.candidatePumpTimer.unref === 'function')
|
|
1991
2182
|
this.candidatePumpTimer.unref();
|
|
1992
2183
|
}
|
|
2184
|
+
/** A single CPU detector request can overlap Gemma; capture never awaits it. */
|
|
2185
|
+
pumpUrgentTriage(generation) {
|
|
2186
|
+
if (this.urgentTriageBusy || !this.urgentTriageClients.size || !this.isCurrentStart(generation)
|
|
2187
|
+
|| !(this.candidates instanceof DurableVlmCandidateSpool_1.DurableVlmCandidateSpool))
|
|
2188
|
+
return;
|
|
2189
|
+
const spool = this.candidates;
|
|
2190
|
+
let work;
|
|
2191
|
+
try {
|
|
2192
|
+
work = spool.nextTriage();
|
|
2193
|
+
}
|
|
2194
|
+
catch (error) {
|
|
2195
|
+
this.emit('urgentTriageError', { error: String(error) });
|
|
2196
|
+
return;
|
|
2197
|
+
}
|
|
2198
|
+
if (!work)
|
|
2199
|
+
return;
|
|
2200
|
+
const { candidate, policy } = work;
|
|
2201
|
+
const client = this.urgentTriageClients.get(candidate.name);
|
|
2202
|
+
if (!client)
|
|
2203
|
+
return;
|
|
2204
|
+
this.urgentTriageBusy = true;
|
|
2205
|
+
const startedAtMs = this.now();
|
|
2206
|
+
void (async () => {
|
|
2207
|
+
let result;
|
|
2208
|
+
const identity = { version: 1, evidenceId: candidate.spoolId,
|
|
2209
|
+
jpegSha256: candidate.jpegSha256, capturedAtMs: candidate.enqueuedAtMs,
|
|
2210
|
+
policySha256: (0, UrgentReviewTriage_1.triageHash)(policy), startedAtMs };
|
|
2211
|
+
try {
|
|
2212
|
+
const detected = await client.detect(candidate.jpeg);
|
|
2213
|
+
const confidence = Math.max(0, ...detected.detections.filter(d => d.label === policy.label).map(d => d.confidence));
|
|
2214
|
+
result = { ...identity, completedAtMs: this.now(), confidence,
|
|
2215
|
+
status: confidence >= policy.minConfidence ? 'positive' : 'negative' };
|
|
2216
|
+
}
|
|
2217
|
+
catch (error) {
|
|
2218
|
+
result = { ...identity, completedAtMs: this.now(), status: 'error', error: String(error).slice(0, 200) };
|
|
2219
|
+
}
|
|
2220
|
+
// A late result must neither resurrect a deleted item nor cross stop/start.
|
|
2221
|
+
const current = this.isCurrentStart(generation);
|
|
2222
|
+
const persisted = current && spool.checkpointTriage(result);
|
|
2223
|
+
this.emit('urgentTriageResult', { name: candidate.name, policy, ...result, persisted, current });
|
|
2224
|
+
})().catch(error => this.emit('urgentTriageError', { name: candidate.name, error: String(error) }))
|
|
2225
|
+
.finally(() => { this.urgentTriageBusy = false; });
|
|
2226
|
+
}
|
|
1993
2227
|
/**
|
|
1994
2228
|
* Offer the hold to every slot that is free right now.
|
|
1995
2229
|
*
|
|
@@ -2000,19 +2234,45 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
2000
2234
|
* capacity.
|
|
2001
2235
|
*/
|
|
2002
2236
|
pumpCandidates(generation) {
|
|
2237
|
+
if (!this.isCurrentStart(generation))
|
|
2238
|
+
return;
|
|
2003
2239
|
const buffer = this.candidates;
|
|
2004
2240
|
if (!buffer)
|
|
2005
2241
|
return;
|
|
2242
|
+
// Re-offer durable sequence jobs even if the camera is currently offline.
|
|
2243
|
+
// Stable IDs make the capture/archive -> spool boundary idempotent.
|
|
2244
|
+
for (const s of this.loops) {
|
|
2245
|
+
if (!s.deps.client.pendingOrderedCandidates)
|
|
2246
|
+
continue;
|
|
2247
|
+
try {
|
|
2248
|
+
if (!(buffer instanceof DurableVlmCandidateSpool_1.DurableVlmCandidateSpool) || !this.continuousReview?.independentUploads)
|
|
2249
|
+
throw Error('Ordered review requires independent uploads and a durable spool');
|
|
2250
|
+
for (const candidate of s.deps.client.pendingOrderedCandidates()) {
|
|
2251
|
+
if (buffer.offer(candidate) === 'rejected')
|
|
2252
|
+
break;
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
catch (error) {
|
|
2256
|
+
this.emitLoopError(s, error);
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2006
2259
|
// Age out first: a candidate past its TTL must never win a slot from one
|
|
2007
2260
|
// that is still current.
|
|
2008
2261
|
buffer.expire();
|
|
2262
|
+
this.pumpUrgentTriage(generation);
|
|
2263
|
+
if (this.continuousReview?.independentUploads)
|
|
2264
|
+
this.pumpDeliveries(generation);
|
|
2009
2265
|
// Bounded by the slot count — this loop can hand out capacity, never spin.
|
|
2010
2266
|
for (let guard = this.admissionGate.getMaxInFlight(); guard > 0; guard--) {
|
|
2267
|
+
// A lease timeout or stop/start cannot create a second real model call.
|
|
2268
|
+
if (this.continuousReview && this.reviewsInFlight.size >= this.admissionGate.getMaxInFlight())
|
|
2269
|
+
return;
|
|
2011
2270
|
if (buffer.size() === 0)
|
|
2012
2271
|
return;
|
|
2013
2272
|
if (this.admissionGate.getInFlight() >= this.admissionGate.getMaxInFlight())
|
|
2014
2273
|
return;
|
|
2015
|
-
const next =
|
|
2274
|
+
const next = this.continuousReview?.independentUploads
|
|
2275
|
+
? buffer.peek(undefined, 'review', this.continuousReview.parallelModelReview ? candidate => this.canReviewModels(candidate) : undefined) : buffer.peek();
|
|
2016
2276
|
if (!next)
|
|
2017
2277
|
return;
|
|
2018
2278
|
const s = this.loops.find((l) => l.deps.name === next.name);
|
|
@@ -2024,12 +2284,17 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
2024
2284
|
// lands in the buffer's `served` counter, which would be a lie, and a
|
|
2025
2285
|
// quiet lie in the accounting is the exact defect this buffer exists to
|
|
2026
2286
|
// remove; the event is what makes it findable.
|
|
2027
|
-
const dropped =
|
|
2028
|
-
|
|
2029
|
-
|
|
2287
|
+
const dropped = this.continuousReview?.independentUploads
|
|
2288
|
+
? buffer.take(undefined, 'review') : buffer.take();
|
|
2289
|
+
if (dropped) {
|
|
2290
|
+
if (this.continuousReview)
|
|
2291
|
+
buffer.retry?.(dropped);
|
|
2292
|
+
else
|
|
2293
|
+
buffer.complete?.(dropped);
|
|
2294
|
+
}
|
|
2030
2295
|
this.emit('error', {
|
|
2031
2296
|
name: next.name,
|
|
2032
|
-
error: new Error(`CloudSensingManager: held candidate for unknown feed "${next.name}" — dropped`),
|
|
2297
|
+
error: new Error(`CloudSensingManager: held candidate for unknown feed "${next.name}" — ${this.continuousReview ? 'retained for retry' : 'dropped'}`),
|
|
2033
2298
|
});
|
|
2034
2299
|
continue;
|
|
2035
2300
|
}
|
|
@@ -2037,25 +2302,92 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
2037
2302
|
if (!ticket)
|
|
2038
2303
|
return; // lost the slot to a live tick — try again next pump
|
|
2039
2304
|
// Nothing above awaited, so this is the candidate `peek()` named.
|
|
2040
|
-
const candidate =
|
|
2305
|
+
const candidate = this.continuousReview?.independentUploads
|
|
2306
|
+
? buffer.take(undefined, 'review', this.continuousReview.parallelModelReview ? item => item.spoolId === next.spoolId : undefined) : buffer.take();
|
|
2041
2307
|
if (!candidate) {
|
|
2042
2308
|
this.admissionGate.release(ticket);
|
|
2043
2309
|
return;
|
|
2044
2310
|
}
|
|
2045
|
-
|
|
2311
|
+
const reservation = { feed: candidate.name, use: this.continuousReview?.parallelModelReview
|
|
2312
|
+
? this.reviewModelUse(candidate) : 'both' };
|
|
2313
|
+
this.modelReviewsInFlight.add(reservation);
|
|
2314
|
+
const work = this.serveCandidate(s, candidate, ticket, generation);
|
|
2315
|
+
this.reviewsInFlight.add(work);
|
|
2316
|
+
void work.finally(() => {
|
|
2317
|
+
this.reviewsInFlight.delete(work);
|
|
2318
|
+
this.modelReviewsInFlight.delete(reservation);
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
reviewModelUse(candidate) {
|
|
2323
|
+
try {
|
|
2324
|
+
const client = this.loops.find(loop => loop.deps.name === candidate.name)?.deps.client;
|
|
2325
|
+
const use = client?.getReviewModelUse?.(candidate.reviewContext);
|
|
2326
|
+
return use === 'detector' || use === 'vlm' ? use : 'both';
|
|
2327
|
+
}
|
|
2328
|
+
catch {
|
|
2329
|
+
return 'both';
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
canReviewModels(candidate) {
|
|
2333
|
+
const use = this.reviewModelUse(candidate);
|
|
2334
|
+
return [...this.modelReviewsInFlight].every(active => active.feed !== candidate.name
|
|
2335
|
+
&& active.use !== 'both' && use !== 'both' && active.use !== use);
|
|
2336
|
+
}
|
|
2337
|
+
/** One bounded upload worker, independent of the actual model-call limit. */
|
|
2338
|
+
pumpDeliveries(generation) {
|
|
2339
|
+
if (!this.isCurrentStart(generation) || this.uploadsInFlight.size > 0 ||
|
|
2340
|
+
this.now() < this.deliveryRetryAtMs)
|
|
2341
|
+
return;
|
|
2342
|
+
const spool = this.candidates;
|
|
2343
|
+
const candidate = spool.take(undefined, 'delivery');
|
|
2344
|
+
if (!candidate)
|
|
2345
|
+
return;
|
|
2346
|
+
const s = this.loops.find(loop => loop.deps.name === candidate.name);
|
|
2347
|
+
if (!s) {
|
|
2348
|
+
spool.retry(candidate);
|
|
2349
|
+
this.emit('reviewDeferred', { name: candidate.name, reason: 'delivery_feed_unavailable' });
|
|
2350
|
+
return;
|
|
2046
2351
|
}
|
|
2352
|
+
const work = this.serveCandidate(s, candidate, null, generation);
|
|
2353
|
+
this.uploadsInFlight.add(work);
|
|
2354
|
+
void work.finally(() => this.uploadsInFlight.delete(work));
|
|
2047
2355
|
}
|
|
2048
2356
|
/** Analyze one held candidate and give the slot back. Never throws. */
|
|
2049
2357
|
async serveCandidate(s, candidate, ticket, generation) {
|
|
2050
2358
|
const waitedMs = Math.max(0, this.now() - candidate.enqueuedAtMs);
|
|
2051
2359
|
try {
|
|
2360
|
+
let detectorShadow = candidate.detectorShadow;
|
|
2361
|
+
if (this.continuousReview && !candidate.cachedAnalysis && this.detectorShadow) {
|
|
2362
|
+
const observed = await this.detectorShadow.observe(candidate.jpeg, s.deps.name);
|
|
2363
|
+
detectorShadow = observed ? DetectorShadowProbe_1.DetectorShadowProbe.toWire(observed) : undefined;
|
|
2364
|
+
const cascade = (0, detectorCascadeGate_1.decideCascadeGate)({
|
|
2365
|
+
armed: s.cascadeArmed, verdict: observed?.verdict ?? null,
|
|
2366
|
+
motion: { reason: candidate.reason, motion: candidate.reviewKind === 'event' },
|
|
2367
|
+
});
|
|
2368
|
+
(0, detectorCascadeGate_1.foldCascadeGateStats)(this.cascadeGateStats, cascade);
|
|
2369
|
+
if (cascade.outcome === 'suppress' && !candidate.reviewContext?.temporalReviewContract && !candidate.reviewContext?.orderedSequence && !candidate.reviewContext?.retainedCapture) {
|
|
2370
|
+
s.cascadeSuppressedCount++;
|
|
2371
|
+
s.runCascadeSuppressed++;
|
|
2372
|
+
this.candidates?.complete?.(candidate);
|
|
2373
|
+
return;
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2052
2376
|
let response;
|
|
2053
2377
|
try {
|
|
2054
|
-
|
|
2378
|
+
const request = {
|
|
2055
2379
|
jpeg: candidate.jpeg,
|
|
2056
2380
|
capturedAt: candidate.enqueuedAtMs,
|
|
2057
2381
|
evidenceId: candidate.spoolId,
|
|
2058
2382
|
evidenceSha256: candidate.jpegSha256,
|
|
2383
|
+
...(this.continuousReview ? {
|
|
2384
|
+
reviewWindowMs: this.continuousReview.reviewWindowMs,
|
|
2385
|
+
cachedAnalysis: candidate.cachedAnalysis,
|
|
2386
|
+
scopedVlmProgress: candidate.scopedVlmProgress,
|
|
2387
|
+
reviewContext: candidate.reviewContext,
|
|
2388
|
+
checkpointAnalysis: (result) => this.candidates.checkpoint(candidate, result),
|
|
2389
|
+
checkpointScopedReview: progress => this.candidates.checkpointScopedReview(candidate, progress),
|
|
2390
|
+
} : {}),
|
|
2059
2391
|
captureMode: candidate.captureMode,
|
|
2060
2392
|
// The dimensions recorded when this frame was CAPTURED, not whatever
|
|
2061
2393
|
// the camera reports now — the frame may have waited in the buffer.
|
|
@@ -2066,23 +2398,58 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
2066
2398
|
// here: shadow mode measures one detector call per analyzed frame,
|
|
2067
2399
|
// and a second call would both double the cost and describe a frame
|
|
2068
2400
|
// that is no longer the one being analyzed.
|
|
2069
|
-
...(
|
|
2070
|
-
}
|
|
2401
|
+
...(detectorShadow ? { extra: { detectorShadow } } : {}),
|
|
2402
|
+
};
|
|
2403
|
+
if (this.continuousReview?.independentUploads && ticket) {
|
|
2404
|
+
await s.deps.client.reviewCandidate(request);
|
|
2405
|
+
this.candidates.readyForDelivery(candidate);
|
|
2406
|
+
this.emit('candidateReviewed', {
|
|
2407
|
+
name: candidate.name, evidenceId: candidate.spoolId,
|
|
2408
|
+
capturedAtMs: candidate.enqueuedAtMs,
|
|
2409
|
+
});
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
// The independent upload lane only takes checkpointed answers. The
|
|
2413
|
+
// local client's cached path validates their image identity and skips models.
|
|
2414
|
+
if (!ticket && !candidate.cachedAnalysis)
|
|
2415
|
+
throw new Error('Upload has no saved model result');
|
|
2416
|
+
response = await s.deps.client.analyze(request);
|
|
2071
2417
|
}
|
|
2072
2418
|
catch (err) {
|
|
2073
|
-
this.emit('inferenceError', {
|
|
2419
|
+
this.emit(ticket ? 'inferenceError' : 'deliveryError', {
|
|
2074
2420
|
name: s.deps.name,
|
|
2075
2421
|
error: err instanceof Error ? err : new Error(String(err)),
|
|
2076
2422
|
path: 'buffered',
|
|
2077
2423
|
});
|
|
2078
|
-
|
|
2424
|
+
if (ticket)
|
|
2425
|
+
this.reportCameraOpen(s, { sensingState: 'unavailable' });
|
|
2079
2426
|
throw err;
|
|
2080
2427
|
}
|
|
2081
|
-
|
|
2428
|
+
// Older servers may acknowledge stale captures as a terminal drop.
|
|
2429
|
+
// Continuous review keeps those results for retry instead of deleting evidence.
|
|
2430
|
+
const acknowledgedReview = response.body?.evidenceReview;
|
|
2431
|
+
if (this.continuousReview && (response.body?.accepted !== true ||
|
|
2432
|
+
response.body?.evidenceId !== candidate.spoolId ||
|
|
2433
|
+
acknowledgedReview?.version !== 1 ||
|
|
2434
|
+
acknowledgedReview.capturedAtMs !== candidate.enqueuedAtMs)) {
|
|
2435
|
+
this.deferCandidate(candidate, ticket === null &&
|
|
2436
|
+
(response.statusCode === 408 || response.statusCode >= 500));
|
|
2437
|
+
this.emit('reviewDeferred', { name: s.deps.name, reason: response.body?.reason ?? 'review_not_acknowledged' });
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
const delayed = !!this.continuousReview &&
|
|
2441
|
+
this.now() - candidate.enqueuedAtMs > this.continuousReview.reviewWindowMs;
|
|
2442
|
+
const outcome = this.isCurrentStart(generation)
|
|
2443
|
+
? this.recordAnalysis(s, response, candidate.signature, delayed)
|
|
2444
|
+
: response.ok && response.body !== null && typeof response.body === 'object'
|
|
2445
|
+
? 'accepted' : 'rejected';
|
|
2082
2446
|
if (outcome === 'rejected')
|
|
2083
2447
|
this.candidates?.retry?.(candidate);
|
|
2084
|
-
else
|
|
2448
|
+
else {
|
|
2085
2449
|
this.candidates?.complete?.(candidate);
|
|
2450
|
+
if (!ticket && this.isCurrentStart(generation))
|
|
2451
|
+
this.deliveryRetryAtMs = 0;
|
|
2452
|
+
}
|
|
2086
2453
|
if (outcome === 'accepted' && this.isCurrentStart(generation)) {
|
|
2087
2454
|
const body = response.body;
|
|
2088
2455
|
const observationId = body && (typeof body.observationId === 'string'
|
|
@@ -2098,16 +2465,27 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
2098
2465
|
evidenceId: candidate.spoolId ?? null,
|
|
2099
2466
|
jpegSha256: candidate.jpegSha256 ?? null,
|
|
2100
2467
|
observationId,
|
|
2468
|
+
...(candidate.reviewPriority ? { reviewPriority: candidate.reviewPriority } : {}),
|
|
2469
|
+
...(this.continuousReview ? { delayed } : {}),
|
|
2101
2470
|
});
|
|
2102
2471
|
}
|
|
2103
2472
|
}
|
|
2104
2473
|
catch (err) {
|
|
2105
|
-
this.
|
|
2474
|
+
this.deferCandidate(candidate, ticket === null);
|
|
2106
2475
|
this.emitLoopError(s, err);
|
|
2107
2476
|
}
|
|
2108
2477
|
finally {
|
|
2109
|
-
|
|
2478
|
+
if (ticket)
|
|
2479
|
+
this.admissionGate.release(ticket);
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
deferCandidate(candidate, deliveryUnavailable) {
|
|
2483
|
+
if (deliveryUnavailable && this.continuousReview?.independentUploads && candidate.cachedAnalysis) {
|
|
2484
|
+
this.deliveryRetryAtMs = this.candidates
|
|
2485
|
+
.retryDelivery(candidate);
|
|
2110
2486
|
}
|
|
2487
|
+
else
|
|
2488
|
+
this.candidates?.retry?.(candidate);
|
|
2111
2489
|
}
|
|
2112
2490
|
/**
|
|
2113
2491
|
* Convert a camera frame to JPEG bytes for the POST.
|