@trillboards/edge-sdk 0.2.138 → 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/TrillboardsSetupApp.m +131 -3
- package/deploy/macos/build-macos-pkg.sh +13 -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/setup-guidance.json +112 -0
- package/deploy/systemd/setup-detector.sh +2 -2
- package/deploy/systemd/setup-litertlm.sh +51 -27
- package/deploy/windows/build-windows.ps1 +11 -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/deploy/windows/trillboards-setup.ps1 +101 -2
- 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
|
@@ -38,6 +38,9 @@ const crypto = __importStar(require("crypto"));
|
|
|
38
38
|
const fs = __importStar(require("fs"));
|
|
39
39
|
const path = __importStar(require("path"));
|
|
40
40
|
const VlmCandidateBuffer_1 = require("./VlmCandidateBuffer");
|
|
41
|
+
const ReviewPriority_1 = require("./ReviewPriority");
|
|
42
|
+
const UrgentReviewTriage_1 = require("./UrgentReviewTriage");
|
|
43
|
+
const ProfileReviewWork_1 = require("./ProfileReviewWork");
|
|
41
44
|
const RECORD_VERSION = 1;
|
|
42
45
|
const DEFAULT_MAX_FRAMES = 4096;
|
|
43
46
|
const DEFAULT_MAX_PER_FEED = 128;
|
|
@@ -67,10 +70,24 @@ const DEFAULT_RETRY_MAX_MS = 5 * 60 * 1000;
|
|
|
67
70
|
*/
|
|
68
71
|
class DurableVlmCandidateSpool {
|
|
69
72
|
constructor(options) {
|
|
73
|
+
this.triageEligible = new Set();
|
|
74
|
+
this.triageFinished = new Set();
|
|
75
|
+
this.triagePositive = new Set();
|
|
76
|
+
this.triageOrder = new Map();
|
|
77
|
+
this.triageTick = 0;
|
|
78
|
+
this.priorityTurns = 0;
|
|
79
|
+
this.deliveryPriorityTurns = 0;
|
|
80
|
+
// At most one direct answer per camera, and 64 cameras total (under 8 MiB).
|
|
81
|
+
// No JPEGs, no chained reuse, no persistence or TTL renewal for this cache.
|
|
82
|
+
this.recentAnswers = new Map();
|
|
83
|
+
this.reusePrepared = 0;
|
|
84
|
+
this.capacityRejected = 0;
|
|
70
85
|
this.feeds = new Map();
|
|
71
86
|
this.inFlight = new Map();
|
|
72
87
|
this.serveOrder = new Map();
|
|
73
88
|
this.serveTick = 0;
|
|
89
|
+
this.deliveryServeTick = 0;
|
|
90
|
+
this.deliveryServeOrder = new Map();
|
|
74
91
|
this.queued = 0;
|
|
75
92
|
this.persisted = 0;
|
|
76
93
|
this.persistedBytes = 0;
|
|
@@ -91,7 +108,26 @@ class DurableVlmCandidateSpool {
|
|
|
91
108
|
this.maxPerEpisode = Math.min(this.maxPerFeed, Math.max(1, Math.trunc(options.maxPerEpisode ?? VlmCandidateBuffer_1.DEFAULT_CANDIDATES_PER_EPISODE)));
|
|
92
109
|
this.episodeGapMs = Math.max(1, Math.trunc(options.episodeGapMs ?? VlmCandidateBuffer_1.DEFAULT_CANDIDATE_EPISODE_GAP_MS));
|
|
93
110
|
this.maxBytes = Math.max(1, Math.trunc(options.maxBytes ?? DEFAULT_MAX_BYTES));
|
|
94
|
-
this.retentionMs = Math.max(1, Math.trunc(options.retentionMs ??
|
|
111
|
+
this.retentionMs = Math.max(1, Math.trunc(options.retentionMs ??
|
|
112
|
+
(options.reviewWindowMs === undefined ? DEFAULT_RETENTION_MS : 86400000)));
|
|
113
|
+
this.reviewWindowMs = options.reviewWindowMs ?? null;
|
|
114
|
+
if (options.preserveArchivedPending && this.reviewWindowMs === null)
|
|
115
|
+
throw Error('Archived pending protection requires continuous review');
|
|
116
|
+
this.preserveArchivedPending = options.preserveArchivedPending === true;
|
|
117
|
+
if (options.priorityScheduling && this.reviewWindowMs === null) {
|
|
118
|
+
throw Error('Priority scheduling requires continuous review');
|
|
119
|
+
}
|
|
120
|
+
this.priorityScheduling = options.priorityScheduling === true;
|
|
121
|
+
if (options.urgentTriagePolicies && !this.priorityScheduling)
|
|
122
|
+
throw Error('Urgent triage requires priority scheduling');
|
|
123
|
+
this.urgentTriagePolicies = JSON.parse(JSON.stringify(options.urgentTriagePolicies ?? {}));
|
|
124
|
+
this.reuseExactPendingImages = this.reviewWindowMs !== null && options.reuseExactPendingImages === true;
|
|
125
|
+
this.reuseExactRecentImages = this.reviewWindowMs !== null && options.reuseExactRecentImages === true;
|
|
126
|
+
if (this.reviewWindowMs !== null && (!Number.isFinite(this.reviewWindowMs) || this.reviewWindowMs < 1 ||
|
|
127
|
+
this.retentionMs < this.reviewWindowMs ||
|
|
128
|
+
[this.retentionMs, this.maxFrames, this.maxPerFeed, this.maxBytes]
|
|
129
|
+
.some(value => !Number.isFinite(value) || value < 1)))
|
|
130
|
+
throw new Error('Invalid continuous review storage limits');
|
|
95
131
|
this.retryInitialMs = Math.max(1, Math.trunc(options.retryInitialMs ?? DEFAULT_RETRY_INITIAL_MS));
|
|
96
132
|
this.retryMaxMs = Math.max(this.retryInitialMs, Math.trunc(options.retryMaxMs ?? DEFAULT_RETRY_MAX_MS));
|
|
97
133
|
this.now = options.now ?? Date.now;
|
|
@@ -119,7 +155,63 @@ class DurableVlmCandidateSpool {
|
|
|
119
155
|
const now = nowMs ?? this.now();
|
|
120
156
|
this.expire(now);
|
|
121
157
|
const byteLength = candidate.jpeg.length;
|
|
158
|
+
if (candidate.reviewPriority !== undefined && !(0, ReviewPriority_1.isReviewPriority)(candidate.reviewPriority)) {
|
|
159
|
+
this.writeFailed++;
|
|
160
|
+
return 'rejected';
|
|
161
|
+
}
|
|
162
|
+
const contextBytes = candidate.reviewContext ? Buffer.byteLength(JSON.stringify(candidate.reviewContext)) : 0;
|
|
163
|
+
const groupingBytes = candidate.groupingShadow ? Buffer.byteLength(JSON.stringify(candidate.groupingShadow)) : 0;
|
|
164
|
+
const reservedResultBytes = this.reviewWindowMs === null ? 0 : 64 * 1024;
|
|
165
|
+
const profileWork = candidate.profileWork && candidate.groupingShadow
|
|
166
|
+
? this.resolveProfileWork({ ...candidate, jpegSha256: crypto.createHash('sha256').update(candidate.jpeg).digest('hex'),
|
|
167
|
+
enqueuedAtMs: candidate.enqueuedAtMs ?? now }) : undefined;
|
|
168
|
+
// Separate experimental plans/results reserve 32 KiB each, without borrowing
|
|
169
|
+
// the normal full-result budget or duplicating the JPEG.
|
|
170
|
+
const profileBytes = profileWork ? 64 * 1024 : 0;
|
|
171
|
+
const configuredTriage = this.urgentTriagePolicies[candidate.name];
|
|
172
|
+
const urgentTriagePolicy = configuredTriage && candidate.reviewPriority === 'urgent'
|
|
173
|
+
&& configuredTriage.contextSha256 === (0, UrgentReviewTriage_1.triageHash)(candidate.reviewContext) ? configuredTriage : undefined;
|
|
174
|
+
const admissionBytes = byteLength + contextBytes + groupingBytes + reservedResultBytes + profileBytes
|
|
175
|
+
+ (urgentTriagePolicy ? UrgentReviewTriage_1.TRIAGE_RESERVED_BYTES : 0);
|
|
176
|
+
if (contextBytes > 64 * 1024) {
|
|
177
|
+
this.writeFailed++;
|
|
178
|
+
return 'rejected';
|
|
179
|
+
}
|
|
180
|
+
if (groupingBytes > 16 * 1024) {
|
|
181
|
+
this.writeFailed++;
|
|
182
|
+
return 'rejected';
|
|
183
|
+
}
|
|
122
184
|
const feed = this.feedQueue(candidate.name);
|
|
185
|
+
const pending = [...feed.items, ...this.inFlight.values()].filter(e => e.name === candidate.name);
|
|
186
|
+
const digest = crypto.createHash('sha256').update(candidate.jpeg).digest('hex');
|
|
187
|
+
if (candidate.retainedId !== undefined) {
|
|
188
|
+
if (this.reviewWindowMs === null || !/^[a-f0-9]{32}$/.test(candidate.retainedId)
|
|
189
|
+
|| (candidate.reviewContext?.orderedSequence?.id !== candidate.retainedId && !(candidate.reviewContext?.retainedCapture?.version === 1 && candidate.reviewContext?.retainedCapture?.kind === 'retained_single_capture' && candidate.reviewContext?.retainedCapture?.id === candidate.retainedId)))
|
|
190
|
+
return 'rejected';
|
|
191
|
+
const old = pending.find(e => e.id === candidate.retainedId);
|
|
192
|
+
if (old) {
|
|
193
|
+
if (old.jpegSha256 !== digest || old.enqueuedAtMs !== candidate.enqueuedAtMs
|
|
194
|
+
|| JSON.stringify(old.reviewContext) !== JSON.stringify(candidate.reviewContext))
|
|
195
|
+
return 'rejected';
|
|
196
|
+
return 'coalesced';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// Temporal rehearsal retains each captured timestamp, even for identical JPEGs.
|
|
200
|
+
// Otherwise a still-open door loses the observations needed to measure duration.
|
|
201
|
+
if (this.reviewWindowMs !== null && !candidate.reviewContext?.temporalReviewContract && !candidate.reviewContext?.orderedSequence && !candidate.reviewContext?.retainedCapture && pending.some(e => e.jpegSha256 === digest &&
|
|
202
|
+
(0, ReviewPriority_1.reviewPriorityRank)(e.reviewPriority) === (0, ReviewPriority_1.reviewPriorityRank)(candidate.reviewPriority) &&
|
|
203
|
+
JSON.stringify(e.reviewContext) === JSON.stringify(candidate.reviewContext) &&
|
|
204
|
+
(candidate.reviewKind === 'sample' ? e.reviewKind === 'sample' : now - e.enqueuedAtMs < 1000))) {
|
|
205
|
+
feed.coalesced++;
|
|
206
|
+
this.coalesced++;
|
|
207
|
+
return 'coalesced';
|
|
208
|
+
}
|
|
209
|
+
// Preserve admitted evidence under pressure; count rejected new arrivals.
|
|
210
|
+
if (this.reviewWindowMs !== null && (pending.length >= this.maxPerFeed || this.persisted >= this.maxFrames ||
|
|
211
|
+
this.persistedBytes + admissionBytes > this.maxBytes)) {
|
|
212
|
+
this.capacityRejected++;
|
|
213
|
+
return 'rejected';
|
|
214
|
+
}
|
|
123
215
|
if (byteLength > this.maxBytes) {
|
|
124
216
|
feed.evicted++;
|
|
125
217
|
this.evicted++;
|
|
@@ -131,9 +223,12 @@ class DurableVlmCandidateSpool {
|
|
|
131
223
|
const episodeStartedAtMs = startsNewEpisode
|
|
132
224
|
? enqueuedAtMs
|
|
133
225
|
: feed.episodeStartedAtMs ?? enqueuedAtMs;
|
|
134
|
-
const episodeEntries = this.
|
|
135
|
-
.
|
|
136
|
-
|
|
226
|
+
const episodeEntries = this.reviewWindowMs !== null ? [] :
|
|
227
|
+
this.currentEpisodeEntries(feed, episodeStartedAtMs)
|
|
228
|
+
.sort((a, b) => a.enqueuedAtMs - b.enqueuedAtMs || a.id.localeCompare(b.id));
|
|
229
|
+
// A later frame can contain a new short-lived action. Only legacy mode
|
|
230
|
+
// coalesces a whole motion episode to its first frames.
|
|
231
|
+
const episodeAtCapacity = this.reviewWindowMs === null && episodeEntries.length >= this.maxPerEpisode;
|
|
137
232
|
feed.episodeStartedAtMs = episodeStartedAtMs;
|
|
138
233
|
feed.lastCandidateAtMs = enqueuedAtMs;
|
|
139
234
|
if (episodeAtCapacity) {
|
|
@@ -149,7 +244,7 @@ class DurableVlmCandidateSpool {
|
|
|
149
244
|
}
|
|
150
245
|
const entry = {
|
|
151
246
|
version: RECORD_VERSION,
|
|
152
|
-
id: crypto.randomUUID().replace(/-/g, ''),
|
|
247
|
+
id: candidate.retainedId ?? crypto.randomUUID().replace(/-/g, ''),
|
|
153
248
|
name: candidate.name,
|
|
154
249
|
enqueuedAtMs,
|
|
155
250
|
availableAtMs: now,
|
|
@@ -159,10 +254,22 @@ class DurableVlmCandidateSpool {
|
|
|
159
254
|
imageWidth: candidate.imageWidth,
|
|
160
255
|
imageHeight: candidate.imageHeight,
|
|
161
256
|
byteLength,
|
|
162
|
-
jpegSha256:
|
|
257
|
+
jpegSha256: digest,
|
|
163
258
|
attempts: 0,
|
|
164
259
|
...(candidate.detectorShadow ? { detectorShadow: candidate.detectorShadow } : {}),
|
|
260
|
+
...(candidate.reviewKind ? { reviewKind: candidate.reviewKind } : {}),
|
|
261
|
+
...(candidate.reviewPriority ? { reviewPriority: candidate.reviewPriority } : {}),
|
|
262
|
+
...(candidate.reviewContext ? { reviewContext: candidate.reviewContext, contextBytes } : {}),
|
|
263
|
+
...(candidate.groupingShadow ? { groupingShadow: candidate.groupingShadow, groupingBytes } : {}),
|
|
264
|
+
...(reservedResultBytes ? { reservedResultBytes } : {}),
|
|
265
|
+
...(profileWork ? { profileWork } : {}),
|
|
266
|
+
...(urgentTriagePolicy ? { urgentTriagePolicy } : {}),
|
|
165
267
|
};
|
|
268
|
+
const recentAnswer = this.exactRecentAnswer(entry, now);
|
|
269
|
+
if (recentAnswer) {
|
|
270
|
+
entry.cachedAnalysis = recentAnswer;
|
|
271
|
+
entry.resultBytes = Buffer.byteLength(JSON.stringify(recentAnswer));
|
|
272
|
+
}
|
|
166
273
|
// Persist first. If the disk is unavailable, existing evidence remains
|
|
167
274
|
// untouched and the caller gets a counted rejection instead of a false
|
|
168
275
|
// claim that the event is durable.
|
|
@@ -172,7 +279,9 @@ class DurableVlmCandidateSpool {
|
|
|
172
279
|
return 'rejected';
|
|
173
280
|
}
|
|
174
281
|
this.persisted++;
|
|
175
|
-
this.persistedBytes +=
|
|
282
|
+
this.persistedBytes += this.entryBytes(entry);
|
|
283
|
+
if (recentAnswer)
|
|
284
|
+
this.reusePrepared++;
|
|
176
285
|
// A busy camera pays its own pressure first. Global pressure then evicts
|
|
177
286
|
// from the fullest queued feed; in-flight evidence is never deleted.
|
|
178
287
|
while (feed.items.length >= this.maxPerFeed)
|
|
@@ -185,29 +294,30 @@ class DurableVlmCandidateSpool {
|
|
|
185
294
|
if (this.persisted > this.maxFrames || this.persistedBytes > this.maxBytes) {
|
|
186
295
|
this.deletePair(entry);
|
|
187
296
|
this.persisted--;
|
|
188
|
-
this.persistedBytes -=
|
|
297
|
+
this.persistedBytes -= this.entryBytes(entry);
|
|
189
298
|
feed.evicted++;
|
|
190
299
|
this.evicted++;
|
|
191
300
|
return 'rejected';
|
|
192
301
|
}
|
|
193
302
|
feed.items.push(entry);
|
|
303
|
+
this.restoreTriage(entry);
|
|
194
304
|
this.sortFeed(feed);
|
|
195
305
|
this.queued++;
|
|
196
306
|
this.enqueued++;
|
|
197
307
|
feed.enqueued++;
|
|
198
308
|
return 'buffered';
|
|
199
309
|
}
|
|
200
|
-
peek(nowMs) {
|
|
310
|
+
peek(nowMs, stage, accept) {
|
|
201
311
|
const now = nowMs ?? this.now();
|
|
202
312
|
this.expire(now);
|
|
203
|
-
const selected = this.nextEligible(now);
|
|
313
|
+
const selected = this.nextEligible(now, stage, accept);
|
|
204
314
|
return selected ? this.asCandidate(selected.entry, Buffer.alloc(0)) : null;
|
|
205
315
|
}
|
|
206
|
-
take(nowMs) {
|
|
316
|
+
take(nowMs, stage, accept) {
|
|
207
317
|
const now = nowMs ?? this.now();
|
|
208
318
|
this.expire(now);
|
|
209
319
|
for (;;) {
|
|
210
|
-
const selected = this.nextEligible(now);
|
|
320
|
+
const selected = this.nextEligible(now, stage, accept);
|
|
211
321
|
if (!selected)
|
|
212
322
|
return null;
|
|
213
323
|
const { feed, index, entry } = selected;
|
|
@@ -237,7 +347,18 @@ class DurableVlmCandidateSpool {
|
|
|
237
347
|
this.inFlight.set(entry.id, entry);
|
|
238
348
|
feed.served++;
|
|
239
349
|
this.served++;
|
|
240
|
-
|
|
350
|
+
if (stage === 'delivery')
|
|
351
|
+
this.deliveryServeOrder.set(feed.name, ++this.deliveryServeTick);
|
|
352
|
+
else
|
|
353
|
+
this.serveOrder.set(feed.name, ++this.serveTick);
|
|
354
|
+
// Only a successful take spends a turn. peek(), blocked feeds and retry
|
|
355
|
+
// backoff never advance either independent lane's fairness state.
|
|
356
|
+
if (this.priorityScheduling) {
|
|
357
|
+
if (stage === 'delivery')
|
|
358
|
+
this.deliveryPriorityTurns = (this.deliveryPriorityTurns + 1) % (ReviewPriority_1.PRIORITY_BURST + 1);
|
|
359
|
+
else
|
|
360
|
+
this.priorityTurns = (this.priorityTurns + 1) % (ReviewPriority_1.PRIORITY_BURST + 1);
|
|
361
|
+
}
|
|
241
362
|
return this.asCandidate(entry, jpeg);
|
|
242
363
|
}
|
|
243
364
|
}
|
|
@@ -253,8 +374,247 @@ class DurableVlmCandidateSpool {
|
|
|
253
374
|
this.removePersisted(entry);
|
|
254
375
|
this.completed++;
|
|
255
376
|
}
|
|
377
|
+
/** Commit a model result before upload, so network retries do not repeat inference. */
|
|
378
|
+
checkpoint(candidate, result) {
|
|
379
|
+
const entry = candidate.spoolId ? this.inFlight.get(candidate.spoolId) : undefined;
|
|
380
|
+
if (!entry)
|
|
381
|
+
throw new Error('Evidence is not in flight');
|
|
382
|
+
const bytes = Buffer.byteLength(JSON.stringify(result));
|
|
383
|
+
const delta = entry.reservedResultBytes ? 0 : bytes - (entry.resultBytes ?? 0);
|
|
384
|
+
if (bytes > 64 * 1024 || this.persistedBytes + delta > this.maxBytes) {
|
|
385
|
+
this.writeFailed++;
|
|
386
|
+
throw new Error('Evidence result exceeds the storage budget');
|
|
387
|
+
}
|
|
388
|
+
entry.cachedAnalysis = result;
|
|
389
|
+
delete entry.scopedVlmProgress;
|
|
390
|
+
entry.resultBytes = bytes;
|
|
391
|
+
this.persistedBytes += delta;
|
|
392
|
+
if (!this.writeMetadata(entry)) {
|
|
393
|
+
// Keep the answer in memory while disk recovers. The client retries
|
|
394
|
+
// this checkpoint before uploading, without spending another model call.
|
|
395
|
+
throw new Error('Could not persist the model result');
|
|
396
|
+
}
|
|
397
|
+
this.prepareExactPendingAnswers(entry, result);
|
|
398
|
+
this.rememberDirectAnswer(entry, result);
|
|
399
|
+
}
|
|
400
|
+
/** Partial scope work uses the already reserved result budget, never upload eligibility. */
|
|
401
|
+
checkpointScopedReview(candidate, progress) {
|
|
402
|
+
const entry = candidate.spoolId ? this.inFlight.get(candidate.spoolId) : undefined;
|
|
403
|
+
if (!entry || entry.cachedAnalysis || !entry.reviewContext?.scopedVlmReview || entry.reservedResultBytes !== 64 * 1024) {
|
|
404
|
+
throw Error('Scoped checkpoint requires unreviewed durable evidence');
|
|
405
|
+
}
|
|
406
|
+
if (Buffer.byteLength(JSON.stringify(progress)) > entry.reservedResultBytes) {
|
|
407
|
+
this.writeFailed++;
|
|
408
|
+
throw Error('Scoped checkpoint exceeds the reserved result budget');
|
|
409
|
+
}
|
|
410
|
+
entry.scopedVlmProgress = JSON.parse(JSON.stringify(progress));
|
|
411
|
+
if (!this.writeMetadata(entry))
|
|
412
|
+
throw Error('Could not persist scoped review progress');
|
|
413
|
+
}
|
|
414
|
+
/** Revalidate group references against committed evidence, then commit the job selection. */
|
|
415
|
+
prepareProfileWork(candidate) {
|
|
416
|
+
const entry = candidate.spoolId ? this.inFlight.get(candidate.spoolId) : undefined;
|
|
417
|
+
if (!entry?.profileWork || !entry.reviewContext)
|
|
418
|
+
throw Error('No durable profile work');
|
|
419
|
+
const work = this.resolveProfileWork(entry);
|
|
420
|
+
if (!work)
|
|
421
|
+
throw Error('Invalid durable profile work');
|
|
422
|
+
entry.profileWork = work;
|
|
423
|
+
if (!this.writeMetadata(entry))
|
|
424
|
+
throw Error('Could not persist profile work selection');
|
|
425
|
+
return JSON.parse(JSON.stringify(work));
|
|
426
|
+
}
|
|
427
|
+
/** Rehearsal output is never cachedAnalysis and cannot become a delivery job. */
|
|
428
|
+
checkpointProfileWork(candidate, result) {
|
|
429
|
+
const entry = candidate.spoolId ? this.inFlight.get(candidate.spoolId) : undefined;
|
|
430
|
+
const work = entry?.profileWork;
|
|
431
|
+
if (!entry || !work || !entry.reviewContext || result.version !== 1 || result.kind !== 'profile_work_rehearsal' ||
|
|
432
|
+
result.evidenceId !== entry.id || result.imageSha256 !== entry.jpegSha256 || result.contextSha256 !== work.contextSha256 ||
|
|
433
|
+
result.capturedAtMs !== entry.enqueuedAtMs || !Number.isFinite(result.reviewedAtMs) ||
|
|
434
|
+
JSON.stringify(result.selectedFields) !== JSON.stringify(work.items.filter(i => i.selected).map(i => i.field)) ||
|
|
435
|
+
JSON.stringify(result.unreviewedFields) !== JSON.stringify(work.items.filter(i => !i.selected).map(i => i.field)) ||
|
|
436
|
+
!result.structured || typeof result.structured !== 'object' || Array.isArray(result.structured) ||
|
|
437
|
+
Object.keys(result.structured).some(f => !result.selectedFields.includes(f)) ||
|
|
438
|
+
!Array.isArray(result.unresolvedFields) || result.unresolvedFields.some(f => !result.selectedFields.includes(f)) ||
|
|
439
|
+
Buffer.byteLength(JSON.stringify(result)) > 32 * 1024)
|
|
440
|
+
throw Error('Invalid profile work result');
|
|
441
|
+
entry.profileWorkResult = JSON.parse(JSON.stringify(result));
|
|
442
|
+
if (!this.writeMetadata(entry))
|
|
443
|
+
throw Error('Could not persist profile work result');
|
|
444
|
+
}
|
|
445
|
+
resolveProfileWork(entry) {
|
|
446
|
+
if (!entry.reviewContext || !entry.groupingShadow || !entry.jpegSha256)
|
|
447
|
+
return undefined;
|
|
448
|
+
const work = (0, ProfileReviewWork_1.planProfileReview)(entry.reviewContext, entry.groupingShadow);
|
|
449
|
+
if (!work)
|
|
450
|
+
return undefined;
|
|
451
|
+
try {
|
|
452
|
+
(0, ProfileReviewWork_1.validateProfileWork)(work, entry.reviewContext, entry.jpegSha256, entry.enqueuedAtMs);
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
const pending = [...(this.feeds.get(entry.name)?.items ?? []), ...this.inFlight.values()];
|
|
458
|
+
for (const item of work.items.filter(i => !i.selected)) {
|
|
459
|
+
const source = pending.find(e => e.name === entry.name && e.enqueuedAtMs === item.referenceCapturedAtMs &&
|
|
460
|
+
e.enqueuedAtMs < entry.enqueuedAtMs && e.jpegSha256 === item.referenceSha256 &&
|
|
461
|
+
e.profileWork?.contextSha256 === work.contextSha256 && Array.isArray(e.profileWork.items) &&
|
|
462
|
+
e.profileWork.items.some(i => i.field === item.field && i.selected) &&
|
|
463
|
+
fs.existsSync(this.metadataPath(e.id)) && fs.existsSync(this.jpegPath(e.id)));
|
|
464
|
+
if (source)
|
|
465
|
+
item.referenceEvidenceId = source.id;
|
|
466
|
+
else {
|
|
467
|
+
item.selected = true;
|
|
468
|
+
item.reason = 'missing_durable_reference';
|
|
469
|
+
delete item.referenceSha256;
|
|
470
|
+
delete item.referenceCapturedAtMs;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
(0, ProfileReviewWork_1.promoteProfileDependencies)(work);
|
|
474
|
+
return Buffer.byteLength(JSON.stringify(work)) <= 32 * 1024 ? work : undefined;
|
|
475
|
+
}
|
|
476
|
+
/** Read-only test/audit surface; no reference is exposed before writePair commits. */
|
|
477
|
+
inspectProfileWork(name, capturedAtMs, sha256) {
|
|
478
|
+
const entry = [...(this.feeds.get(name)?.items ?? []), ...this.inFlight.values()].find(e => e.name === name && e.enqueuedAtMs === capturedAtMs && e.jpegSha256 === sha256 && e.profileWork);
|
|
479
|
+
return entry ? { evidenceId: entry.id, work: JSON.parse(JSON.stringify(entry.profileWork)) } : null;
|
|
480
|
+
}
|
|
481
|
+
/** A raw archive ID names this receipt, not a different visual question.
|
|
482
|
+
* Keep all selected IDs and original timestamps; share only the exact answer.
|
|
483
|
+
* Temporal/ordered observations still have no answer-reuse permission.
|
|
484
|
+
*/
|
|
485
|
+
exactAnswerContext(entry) {
|
|
486
|
+
const context = entry.reviewContext;
|
|
487
|
+
if (!context || context.temporalReviewContract || context.orderedSequence || context.orderedCaptureContract)
|
|
488
|
+
return undefined;
|
|
489
|
+
if (context.retainedCapture === undefined)
|
|
490
|
+
return context;
|
|
491
|
+
const ref = context.retainedCapture;
|
|
492
|
+
if (ref?.version !== 1 || ref.kind !== 'retained_single_capture' || ref.id !== entry.id ||
|
|
493
|
+
Object.keys(ref).sort().join(',') !== 'id,kind,version')
|
|
494
|
+
return undefined;
|
|
495
|
+
const normalized = { ...context };
|
|
496
|
+
delete normalized.retainedCapture;
|
|
497
|
+
return normalized;
|
|
498
|
+
}
|
|
499
|
+
rememberDirectAnswer(source, result) {
|
|
500
|
+
const sourceContext = this.exactAnswerContext(source);
|
|
501
|
+
if (!sourceContext)
|
|
502
|
+
return;
|
|
503
|
+
if (!this.reuseExactRecentImages || !source.reviewContext || !source.jpegSha256 ||
|
|
504
|
+
result.version !== 1 || result.evidenceSha256 !== source.jpegSha256 ||
|
|
505
|
+
result.reusedFromEvidenceId !== undefined || result.reuseMethod !== undefined ||
|
|
506
|
+
typeof result.reviewedAtMs !== 'number' || !Number.isFinite(result.reviewedAtMs) ||
|
|
507
|
+
result.reviewedAtMs < source.enqueuedAtMs || result.reviewedAtMs > this.now() ||
|
|
508
|
+
this.now() - source.enqueuedAtMs > this.reviewWindowMs)
|
|
509
|
+
return;
|
|
510
|
+
this.recentAnswers.delete(source.name);
|
|
511
|
+
this.recentAnswers.set(source.name, {
|
|
512
|
+
id: source.id, capturedAtMs: source.enqueuedAtMs, reviewedAtMs: result.reviewedAtMs,
|
|
513
|
+
jpegSha256: source.jpegSha256, imageWidth: source.imageWidth, imageHeight: source.imageHeight,
|
|
514
|
+
context: JSON.stringify(sourceContext), result,
|
|
515
|
+
});
|
|
516
|
+
if (this.recentAnswers.size > 64)
|
|
517
|
+
this.recentAnswers.delete(this.recentAnswers.keys().next().value);
|
|
518
|
+
}
|
|
519
|
+
exactRecentAnswer(entry, now) {
|
|
520
|
+
const entryContext = this.exactAnswerContext(entry);
|
|
521
|
+
if (!entryContext)
|
|
522
|
+
return undefined;
|
|
523
|
+
if (!this.reuseExactRecentImages || !entry.reviewContext)
|
|
524
|
+
return undefined;
|
|
525
|
+
for (const [name, answer] of this.recentAnswers) {
|
|
526
|
+
if (now - answer.capturedAtMs > this.reviewWindowMs)
|
|
527
|
+
this.recentAnswers.delete(name);
|
|
528
|
+
}
|
|
529
|
+
const source = this.recentAnswers.get(entry.name);
|
|
530
|
+
if (!source || entry.enqueuedAtMs < source.reviewedAtMs || entry.enqueuedAtMs > now ||
|
|
531
|
+
now < source.reviewedAtMs || entry.enqueuedAtMs - source.capturedAtMs > this.reviewWindowMs ||
|
|
532
|
+
entry.jpegSha256 !== source.jpegSha256 || entry.imageWidth !== source.imageWidth ||
|
|
533
|
+
entry.imageHeight !== source.imageHeight || JSON.stringify(entryContext) !== source.context)
|
|
534
|
+
return undefined;
|
|
535
|
+
const answer = { ...source.result, analyzeMs: 0, reviewedAtMs: now,
|
|
536
|
+
reusedFromEvidenceId: source.id, reuseMethod: 'exact_recent',
|
|
537
|
+
sourceCapturedAtMs: source.capturedAtMs, sourceReviewedAtMs: source.reviewedAtMs };
|
|
538
|
+
return Buffer.byteLength(JSON.stringify(answer)) <= 64 * 1024 ? answer : undefined;
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Share a completed answer, never discard a visually similar frame. Every
|
|
542
|
+
* recipient remains a separate durable candidate until its own receiver ack.
|
|
543
|
+
* This pending-only path excludes future captures, and profile/camera boundaries
|
|
544
|
+
* are never crossed. A changed JPEG, however small the change, is ineligible.
|
|
545
|
+
*/
|
|
546
|
+
prepareExactPendingAnswers(source, result) {
|
|
547
|
+
const sourceContext = this.exactAnswerContext(source);
|
|
548
|
+
if (!sourceContext)
|
|
549
|
+
return;
|
|
550
|
+
if (!this.reuseExactPendingImages || !source.reviewContext || !source.jpegSha256 ||
|
|
551
|
+
result.version !== 1 || result.evidenceSha256 !== source.jpegSha256 ||
|
|
552
|
+
result.reusedFromEvidenceId !== undefined || typeof result.reviewedAtMs !== 'number' ||
|
|
553
|
+
!Number.isFinite(result.reviewedAtMs) || result.reviewedAtMs < source.enqueuedAtMs)
|
|
554
|
+
return;
|
|
555
|
+
const context = JSON.stringify(sourceContext);
|
|
556
|
+
const answer = { ...result, reusedFromEvidenceId: source.id };
|
|
557
|
+
const bytes = Buffer.byteLength(JSON.stringify(answer));
|
|
558
|
+
if (bytes > 64 * 1024)
|
|
559
|
+
return; // The original result still remains usable.
|
|
560
|
+
for (const pending of this.feeds.get(source.name)?.items ?? []) {
|
|
561
|
+
if (pending.cachedAnalysis || pending.jpegSha256 !== source.jpegSha256 ||
|
|
562
|
+
pending.enqueuedAtMs > result.reviewedAtMs ||
|
|
563
|
+
Math.abs(pending.enqueuedAtMs - source.enqueuedAtMs) > this.reviewWindowMs ||
|
|
564
|
+
pending.imageWidth !== source.imageWidth || pending.imageHeight !== source.imageHeight ||
|
|
565
|
+
JSON.stringify(this.exactAnswerContext(pending)) !== context)
|
|
566
|
+
continue;
|
|
567
|
+
const delta = pending.reservedResultBytes ? 0 : bytes - (pending.resultBytes ?? 0);
|
|
568
|
+
if (this.persistedBytes + delta > this.maxBytes)
|
|
569
|
+
continue;
|
|
570
|
+
pending.cachedAnalysis = answer;
|
|
571
|
+
delete pending.scopedVlmProgress;
|
|
572
|
+
pending.resultBytes = bytes;
|
|
573
|
+
this.persistedBytes += delta;
|
|
574
|
+
// If disk fails, keep the answer in memory. Serving this candidate retries
|
|
575
|
+
// its own checkpoint before upload; recovery can safely recompute it.
|
|
576
|
+
if (this.writeMetadata(pending))
|
|
577
|
+
this.reusePrepared++;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/** Move a committed answer to the independent delivery lane. */
|
|
581
|
+
readyForDelivery(candidate) {
|
|
582
|
+
const entry = candidate.spoolId ? this.inFlight.get(candidate.spoolId) : undefined;
|
|
583
|
+
if (!entry?.cachedAnalysis)
|
|
584
|
+
throw new Error('Delivery requires a saved model result');
|
|
585
|
+
if (!this.reviewComplete(entry))
|
|
586
|
+
throw new Error('Temporal decision must be committed before delivery');
|
|
587
|
+
// Commit before changing ownership. A failed write leaves this in flight so
|
|
588
|
+
// the caller can retry the same result; it never becomes an acknowledged alert.
|
|
589
|
+
entry.availableAtMs = this.now();
|
|
590
|
+
entry.attempts = 0;
|
|
591
|
+
if (!this.writeMetadata(entry))
|
|
592
|
+
throw new Error('Could not persist delivery handoff');
|
|
593
|
+
this.inFlight.delete(entry.id);
|
|
594
|
+
const feed = this.feedQueue(entry.name);
|
|
595
|
+
feed.items.push(entry);
|
|
596
|
+
this.sortFeed(feed);
|
|
597
|
+
this.queued++;
|
|
598
|
+
}
|
|
256
599
|
/** Keep the same evidence and retry it after bounded exponential backoff. */
|
|
257
600
|
retry(candidate, nowMs) {
|
|
601
|
+
this.requeue(candidate, nowMs, false);
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Retry a saved answer after a short connectivity probe interval. The manager
|
|
605
|
+
* shares this pause across its single upload lane, so a large offline queue
|
|
606
|
+
* cannot turn one connection failure into one request per saved image.
|
|
607
|
+
*/
|
|
608
|
+
retryDelivery(candidate, nowMs) {
|
|
609
|
+
const entry = candidate.spoolId ? this.inFlight.get(candidate.spoolId) : undefined;
|
|
610
|
+
if (this.reviewWindowMs === null || !entry?.cachedAnalysis) {
|
|
611
|
+
throw new Error('Delivery retry requires a saved continuous-review answer');
|
|
612
|
+
}
|
|
613
|
+
const now = nowMs ?? this.now();
|
|
614
|
+
this.requeue(candidate, now, true);
|
|
615
|
+
return now + this.retryInitialMs;
|
|
616
|
+
}
|
|
617
|
+
requeue(candidate, nowMs, delivery) {
|
|
258
618
|
const id = candidate.spoolId;
|
|
259
619
|
if (!id)
|
|
260
620
|
return;
|
|
@@ -263,7 +623,7 @@ class DurableVlmCandidateSpool {
|
|
|
263
623
|
return;
|
|
264
624
|
this.inFlight.delete(id);
|
|
265
625
|
entry.attempts += 1;
|
|
266
|
-
const delay = Math.min(this.retryMaxMs, this.retryInitialMs * Math.pow(2, Math.min(16, entry.attempts - 1)));
|
|
626
|
+
const delay = delivery ? this.retryInitialMs : Math.min(this.retryMaxMs, this.retryInitialMs * Math.pow(2, Math.min(16, entry.attempts - 1)));
|
|
267
627
|
entry.availableAtMs = (nowMs ?? this.now()) + delay;
|
|
268
628
|
this.writeMetadata(entry);
|
|
269
629
|
const feed = this.feedQueue(entry.name);
|
|
@@ -278,6 +638,15 @@ class DurableVlmCandidateSpool {
|
|
|
278
638
|
for (const feed of this.feeds.values()) {
|
|
279
639
|
for (let i = feed.items.length - 1; i >= 0; i--) {
|
|
280
640
|
const entry = feed.items[i];
|
|
641
|
+
// A completed review is pending delivery, even after the evidence age
|
|
642
|
+
// target. Refuse new work at capacity rather than erase an unsent result.
|
|
643
|
+
if (this.reviewWindowMs !== null && entry.cachedAnalysis)
|
|
644
|
+
continue;
|
|
645
|
+
const archiveRef = entry.reviewContext?.retainedCapture;
|
|
646
|
+
if (this.preserveArchivedPending && archiveRef?.version === 1 &&
|
|
647
|
+
archiveRef.kind === 'retained_single_capture' && archiveRef.id === entry.id &&
|
|
648
|
+
Object.keys(archiveRef).sort().join(',') === 'id,kind,version')
|
|
649
|
+
continue;
|
|
281
650
|
if (now - entry.enqueuedAtMs <= this.retentionMs)
|
|
282
651
|
continue;
|
|
283
652
|
feed.items.splice(i, 1);
|
|
@@ -337,15 +706,93 @@ class DurableVlmCandidateSpool {
|
|
|
337
706
|
writeFailed: this.writeFailed,
|
|
338
707
|
readFailed: this.readFailed,
|
|
339
708
|
deleteFailed: this.deleteFailed,
|
|
709
|
+
reusePrepared: this.reusePrepared,
|
|
710
|
+
...(this.reviewWindowMs === null ? {} : {
|
|
711
|
+
reviewWindowMs: this.reviewWindowMs,
|
|
712
|
+
delayed: [...this.feeds.values()].reduce((n, f) => n + f.items.filter(e => now - e.enqueuedAtMs > this.reviewWindowMs).length, 0),
|
|
713
|
+
capacityRejected: this.capacityRejected,
|
|
714
|
+
}),
|
|
340
715
|
};
|
|
341
716
|
}
|
|
717
|
+
/** One read-only advisory look. It never takes/removes a review or reserves a GPU slot. */
|
|
718
|
+
nextTriage() {
|
|
719
|
+
let selected;
|
|
720
|
+
let order = Infinity;
|
|
721
|
+
for (const feed of this.feeds.values()) {
|
|
722
|
+
let item;
|
|
723
|
+
for (let i = feed.items.length - 1; i >= 0; i--) {
|
|
724
|
+
const entry = feed.items[i];
|
|
725
|
+
if (!entry.cachedAnalysis && this.triageEligible.has(entry.id) && !this.triageFinished.has(entry.id)) {
|
|
726
|
+
item = entry;
|
|
727
|
+
break;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const feedOrder = this.triageOrder.get(feed.name) ?? 0;
|
|
731
|
+
if (item && feedOrder < order) {
|
|
732
|
+
selected = item;
|
|
733
|
+
order = feedOrder;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
if (!selected)
|
|
737
|
+
return null;
|
|
738
|
+
this.triageOrder.set(selected.name, ++this.triageTick);
|
|
739
|
+
// Corrupt/unreadable bytes are a visible error, never an advisory-negative deletion.
|
|
740
|
+
const jpeg = fs.readFileSync(this.jpegPath(selected.id));
|
|
741
|
+
if (jpeg.length !== selected.byteLength || crypto.createHash('sha256').update(jpeg).digest('hex') !== selected.jpegSha256) {
|
|
742
|
+
throw Error('Urgent triage evidence hash mismatch');
|
|
743
|
+
}
|
|
744
|
+
return { candidate: this.asCandidate(selected, jpeg), policy: { ...selected.urgentTriagePolicy } };
|
|
745
|
+
}
|
|
746
|
+
checkpointTriage(result) {
|
|
747
|
+
const entry = this.inFlight.get(result.evidenceId) ?? [...this.feeds.values()]
|
|
748
|
+
.flatMap(feed => feed.items).find(item => item.id === result.evidenceId);
|
|
749
|
+
if (!entry || entry.cachedAnalysis || !this.triageEligible.has(entry.id)
|
|
750
|
+
|| this.triageFinished.has(entry.id) || !(0, UrgentReviewTriage_1.validTriageResult)(result, entry, entry.urgentTriagePolicy))
|
|
751
|
+
return false;
|
|
752
|
+
const previous = entry.urgentTriageResult;
|
|
753
|
+
const previousBytes = this.entryBytes(entry);
|
|
754
|
+
entry.urgentTriageResult = JSON.parse(JSON.stringify(result));
|
|
755
|
+
if (!this.writeMetadata(entry)) {
|
|
756
|
+
entry.urgentTriageResult = previous;
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
this.persistedBytes += this.entryBytes(entry) - previousBytes;
|
|
760
|
+
this.restoreTriage(entry);
|
|
761
|
+
return true;
|
|
762
|
+
}
|
|
763
|
+
restoreTriage(entry) {
|
|
764
|
+
const policy = this.urgentTriagePolicies[entry.name];
|
|
765
|
+
if (!policy || entry.reviewPriority !== 'urgent' || policy.contextSha256 !== (0, UrgentReviewTriage_1.triageHash)(entry.reviewContext)
|
|
766
|
+
|| (0, UrgentReviewTriage_1.triageHash)(policy) !== (0, UrgentReviewTriage_1.triageHash)(entry.urgentTriagePolicy))
|
|
767
|
+
return;
|
|
768
|
+
this.triageEligible.add(entry.id);
|
|
769
|
+
// Damaged optional hints do not invalidate the underlying evidence record.
|
|
770
|
+
if ((0, UrgentReviewTriage_1.validTriageResult)(entry.urgentTriageResult, entry, policy)) {
|
|
771
|
+
this.triageFinished.add(entry.id);
|
|
772
|
+
if (entry.urgentTriageResult.status === 'positive')
|
|
773
|
+
this.triagePositive.add(entry.id);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
342
776
|
clearQueuedState() {
|
|
777
|
+
this.triageEligible.clear();
|
|
778
|
+
this.triageFinished.clear();
|
|
779
|
+
this.triagePositive.clear();
|
|
780
|
+
this.triageOrder.clear();
|
|
781
|
+
this.triageTick = 0;
|
|
782
|
+
for (const entry of this.inFlight.values())
|
|
783
|
+
this.restoreTriage(entry);
|
|
784
|
+
this.recentAnswers.clear();
|
|
343
785
|
this.feeds.clear();
|
|
344
786
|
this.serveOrder.clear();
|
|
345
787
|
this.serveTick = 0;
|
|
788
|
+
this.deliveryServeOrder.clear();
|
|
789
|
+
this.deliveryServeTick = 0;
|
|
790
|
+
this.priorityTurns = 0;
|
|
791
|
+
this.deliveryPriorityTurns = 0;
|
|
346
792
|
this.queued = 0;
|
|
347
793
|
this.persisted = this.inFlight.size;
|
|
348
|
-
this.persistedBytes = [...this.inFlight.values()].reduce((n, e) => n + e
|
|
794
|
+
this.persistedBytes = [...this.inFlight.values()].reduce((n, e) => n + this.entryBytes(e), 0);
|
|
795
|
+
this.capacityRejected = 0;
|
|
349
796
|
this.enqueued = 0;
|
|
350
797
|
this.served = 0;
|
|
351
798
|
this.coalesced = 0;
|
|
@@ -357,6 +804,7 @@ class DurableVlmCandidateSpool {
|
|
|
357
804
|
this.writeFailed = 0;
|
|
358
805
|
this.readFailed = 0;
|
|
359
806
|
this.deleteFailed = 0;
|
|
807
|
+
this.reusePrepared = 0;
|
|
360
808
|
}
|
|
361
809
|
ensureDirectory() {
|
|
362
810
|
fs.mkdirSync(this.directory, { recursive: true, mode: 0o700 });
|
|
@@ -386,7 +834,7 @@ class DurableVlmCandidateSpool {
|
|
|
386
834
|
let entry = null;
|
|
387
835
|
try {
|
|
388
836
|
const raw = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
|
|
389
|
-
if (!this.validRecord(raw))
|
|
837
|
+
if (!this.validRecord(raw) || raw.id !== name.slice(0, -5))
|
|
390
838
|
throw new Error('invalid record');
|
|
391
839
|
const jpegStat = fs.statSync(this.jpegPath(raw.id));
|
|
392
840
|
if (!jpegStat.isFile() || jpegStat.size !== raw.byteLength)
|
|
@@ -394,29 +842,34 @@ class DurableVlmCandidateSpool {
|
|
|
394
842
|
entry = raw;
|
|
395
843
|
}
|
|
396
844
|
catch {
|
|
845
|
+
this.readFailed++;
|
|
397
846
|
const id = name.slice(0, -5);
|
|
398
847
|
this.deletePairById(id);
|
|
399
848
|
continue;
|
|
400
849
|
}
|
|
401
850
|
if (this.inFlight.has(entry.id))
|
|
402
851
|
continue;
|
|
852
|
+
this.restoreTriage(entry);
|
|
403
853
|
const feed = this.feedQueue(entry.name);
|
|
404
854
|
feed.items.push(entry);
|
|
405
855
|
feed.enqueued++;
|
|
406
856
|
this.queued++;
|
|
407
857
|
this.persisted++;
|
|
408
|
-
this.persistedBytes += entry
|
|
858
|
+
this.persistedBytes += this.entryBytes(entry);
|
|
409
859
|
this.enqueued++;
|
|
410
860
|
this.recovered++;
|
|
411
861
|
}
|
|
412
862
|
for (const feed of this.feeds.values()) {
|
|
413
863
|
this.sortFeed(feed);
|
|
414
|
-
this.
|
|
415
|
-
|
|
416
|
-
|
|
864
|
+
if (this.reviewWindowMs === null)
|
|
865
|
+
this.coalesceRecoveredEpisodes(feed);
|
|
866
|
+
if (this.reviewWindowMs === null) {
|
|
867
|
+
while (feed.items.length > this.maxPerFeed)
|
|
868
|
+
this.evictOldestOf(feed);
|
|
869
|
+
}
|
|
417
870
|
}
|
|
418
871
|
this.expire(this.now());
|
|
419
|
-
while (this.persisted > this.maxFrames || this.persistedBytes > this.maxBytes) {
|
|
872
|
+
while (this.reviewWindowMs === null && (this.persisted > this.maxFrames || this.persistedBytes > this.maxBytes)) {
|
|
420
873
|
if (!this.evictFromFullestFeed())
|
|
421
874
|
break;
|
|
422
875
|
}
|
|
@@ -443,15 +896,25 @@ class DurableVlmCandidateSpool {
|
|
|
443
896
|
typeof value.reason === 'string' &&
|
|
444
897
|
Number.isFinite(value.byteLength) && value.byteLength > 0 &&
|
|
445
898
|
(value.jpegSha256 === undefined || /^[a-f0-9]{64}$/.test(value.jpegSha256)) &&
|
|
899
|
+
(value.reviewKind === undefined || value.reviewKind === 'event' || value.reviewKind === 'sample') &&
|
|
900
|
+
(value.reservedResultBytes === undefined || value.reservedResultBytes === 64 * 1024) &&
|
|
901
|
+
(value.reviewContext === undefined ? value.contextBytes === undefined : (value.reviewContext !== null && typeof value.reviewContext === 'object' &&
|
|
902
|
+
!Array.isArray(value.reviewContext) &&
|
|
903
|
+
value.contextBytes === Buffer.byteLength(JSON.stringify(value.reviewContext)) &&
|
|
904
|
+
value.contextBytes <= 64 * 1024)) &&
|
|
905
|
+
(value.cachedAnalysis === undefined ? value.resultBytes === undefined : (value.cachedAnalysis !== null && typeof value.cachedAnalysis === 'object' &&
|
|
906
|
+
!Array.isArray(value.cachedAnalysis) &&
|
|
907
|
+
value.resultBytes === Buffer.byteLength(JSON.stringify(value.cachedAnalysis)) &&
|
|
908
|
+
value.resultBytes <= 64 * 1024)) &&
|
|
446
909
|
Number.isFinite(value.attempts) && value.attempts >= 0);
|
|
447
910
|
}
|
|
448
911
|
writePair(entry, jpeg) {
|
|
449
912
|
const jpegPath = this.jpegPath(entry.id);
|
|
450
913
|
const metadataPath = this.metadataPath(entry.id);
|
|
451
914
|
try {
|
|
452
|
-
|
|
915
|
+
this.writeFile(`${jpegPath}.tmp`, jpeg, 'wx');
|
|
453
916
|
fs.renameSync(`${jpegPath}.tmp`, jpegPath);
|
|
454
|
-
|
|
917
|
+
this.writeFile(`${metadataPath}.tmp`, JSON.stringify(entry), 'wx');
|
|
455
918
|
fs.renameSync(`${metadataPath}.tmp`, metadataPath);
|
|
456
919
|
return true;
|
|
457
920
|
}
|
|
@@ -461,6 +924,32 @@ class DurableVlmCandidateSpool {
|
|
|
461
924
|
return false;
|
|
462
925
|
}
|
|
463
926
|
}
|
|
927
|
+
entryBytes(entry) {
|
|
928
|
+
// Recompute diagnostic bytes on recovery; bad optional audit metadata must
|
|
929
|
+
// neither discard the JPEG nor evade the admission byte budget.
|
|
930
|
+
return entry.byteLength + (entry.contextBytes ?? 0) +
|
|
931
|
+
Math.max(entry.urgentTriagePolicy ? UrgentReviewTriage_1.TRIAGE_RESERVED_BYTES : 0, (entry.urgentTriagePolicy ? Buffer.byteLength(JSON.stringify(entry.urgentTriagePolicy)) : 0)
|
|
932
|
+
+ (entry.urgentTriageResult ? Buffer.byteLength(JSON.stringify(entry.urgentTriageResult)) : 0)) +
|
|
933
|
+
(entry.groupingShadow ? Buffer.byteLength(JSON.stringify(entry.groupingShadow)) : 0) +
|
|
934
|
+
(entry.profileWork ? Math.max(64 * 1024, Buffer.byteLength(JSON.stringify(entry.profileWork)) +
|
|
935
|
+
(entry.profileWorkResult ? Buffer.byteLength(JSON.stringify(entry.profileWorkResult)) : 0)) :
|
|
936
|
+
entry.profileWorkResult ? Buffer.byteLength(JSON.stringify(entry.profileWorkResult)) : 0) +
|
|
937
|
+
Math.max(entry.reservedResultBytes ?? entry.resultBytes ?? 0, entry.scopedVlmProgress ? Buffer.byteLength(JSON.stringify(entry.scopedVlmProgress)) : 0);
|
|
938
|
+
}
|
|
939
|
+
writeFile(file, data, flag = 'w') {
|
|
940
|
+
if (this.reviewWindowMs === null) {
|
|
941
|
+
fs.writeFileSync(file, data, { flag, mode: 0o600 });
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
const fd = fs.openSync(file, flag, 0o600);
|
|
945
|
+
try {
|
|
946
|
+
fs.writeFileSync(fd, data);
|
|
947
|
+
fs.fsyncSync(fd);
|
|
948
|
+
}
|
|
949
|
+
finally {
|
|
950
|
+
fs.closeSync(fd);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
464
953
|
writeMetadata(entry) {
|
|
465
954
|
const metadataPath = this.metadataPath(entry.id);
|
|
466
955
|
const tmp = `${metadataPath}.tmp`;
|
|
@@ -469,8 +958,9 @@ class DurableVlmCandidateSpool {
|
|
|
469
958
|
fs.unlinkSync(tmp);
|
|
470
959
|
}
|
|
471
960
|
catch { /* absent is normal */ }
|
|
472
|
-
|
|
961
|
+
this.writeFile(tmp, JSON.stringify(entry));
|
|
473
962
|
fs.renameSync(tmp, metadataPath);
|
|
963
|
+
return true;
|
|
474
964
|
}
|
|
475
965
|
catch {
|
|
476
966
|
this.writeFailed++;
|
|
@@ -478,6 +968,7 @@ class DurableVlmCandidateSpool {
|
|
|
478
968
|
fs.unlinkSync(tmp);
|
|
479
969
|
}
|
|
480
970
|
catch { /* best-effort */ }
|
|
971
|
+
return false;
|
|
481
972
|
}
|
|
482
973
|
}
|
|
483
974
|
asCandidate(entry, jpeg) {
|
|
@@ -492,6 +983,15 @@ class DurableVlmCandidateSpool {
|
|
|
492
983
|
imageHeight: entry.imageHeight,
|
|
493
984
|
spoolId: entry.id,
|
|
494
985
|
jpegSha256: entry.jpegSha256,
|
|
986
|
+
...(entry.reviewKind ? { reviewKind: entry.reviewKind } : {}),
|
|
987
|
+
...((0, ReviewPriority_1.isReviewPriority)(entry.reviewPriority) ? { reviewPriority: entry.reviewPriority } : {}),
|
|
988
|
+
...(entry.cachedAnalysis ? { cachedAnalysis: entry.cachedAnalysis } : {}),
|
|
989
|
+
...(entry.scopedVlmProgress ? { scopedVlmProgress: JSON.parse(JSON.stringify(entry.scopedVlmProgress)) } : {}),
|
|
990
|
+
...(entry.reviewContext ? { reviewContext: entry.reviewContext } : {}),
|
|
991
|
+
...(entry.profileWork ? { profileWork: JSON.parse(JSON.stringify(entry.profileWork)) } : {}),
|
|
992
|
+
...(entry.profileWorkResult ? { profileWorkResult: JSON.parse(JSON.stringify(entry.profileWorkResult)) } : {}),
|
|
993
|
+
...(entry.groupingShadow?.mode === 'shadow' && entry.groupingShadow?.version === 1 &&
|
|
994
|
+
Array.isArray(entry.groupingShadow.scopes) ? { groupingShadow: entry.groupingShadow } : {}),
|
|
495
995
|
...(entry.detectorShadow ? { detectorShadow: entry.detectorShadow } : {}),
|
|
496
996
|
};
|
|
497
997
|
}
|
|
@@ -516,20 +1016,83 @@ class DurableVlmCandidateSpool {
|
|
|
516
1016
|
sortFeed(feed) {
|
|
517
1017
|
feed.items.sort((a, b) => a.enqueuedAtMs - b.enqueuedAtMs || a.id.localeCompare(b.id));
|
|
518
1018
|
}
|
|
519
|
-
|
|
1019
|
+
reviewComplete(entry) {
|
|
1020
|
+
if (!entry.cachedAnalysis)
|
|
1021
|
+
return false;
|
|
1022
|
+
if (entry.reviewContext?.orderedSequence) {
|
|
1023
|
+
const decision = entry.cachedAnalysis.orderedReview;
|
|
1024
|
+
return decision?.version === 1 && decision.kind === 'ordered_frame_rehearsal'
|
|
1025
|
+
&& decision.sequenceId === entry.id && decision.anchorSha256 === entry.jpegSha256
|
|
1026
|
+
&& decision.capturedAtMs === entry.enqueuedAtMs;
|
|
1027
|
+
}
|
|
1028
|
+
if (!entry.reviewContext?.temporalReviewContract)
|
|
1029
|
+
return true;
|
|
1030
|
+
const decision = entry.cachedAnalysis.temporalReview;
|
|
1031
|
+
return decision?.version === 1 && decision.kind === 'temporal_rehearsal'
|
|
1032
|
+
&& decision.evidenceId === entry.id && decision.imageSha256 === entry.jpegSha256
|
|
1033
|
+
&& decision.capturedAtMs === entry.enqueuedAtMs;
|
|
1034
|
+
}
|
|
1035
|
+
nextEligible(now, stage, accept) {
|
|
1036
|
+
// Crossing the fresh/delayed boundary never changes order. The opt-in
|
|
1037
|
+
// capture-time priority gives urgent work preference, with every fifth
|
|
1038
|
+
// successful take using ordinary fair-camera/FIFO order. Thus a sustained
|
|
1039
|
+
// urgent stream cannot indefinitely bypass a ready ordinary camera/item.
|
|
1040
|
+
// A fairness turn is work-conserving: it never waits for an empty class.
|
|
1041
|
+
const orderByFeed = stage === 'delivery' ? this.deliveryServeOrder : this.serveOrder;
|
|
1042
|
+
const preference = this.priorityScheduling &&
|
|
1043
|
+
(stage === 'delivery' ? this.deliveryPriorityTurns : this.priorityTurns) < ReviewPriority_1.PRIORITY_BURST;
|
|
1044
|
+
const inStage = (entry) => stage === undefined ||
|
|
1045
|
+
(stage === 'delivery' ? this.reviewComplete(entry) : !this.reviewComplete(entry));
|
|
1046
|
+
const eligible = (entry) => inStage(entry) && entry.availableAtMs <= now;
|
|
1047
|
+
const priorityRank = (entry) => (0, ReviewPriority_1.reviewPriorityRank)(entry.reviewPriority) * 2
|
|
1048
|
+
+ (stage === 'review' && this.triagePositive.has(entry.id) ? 1 : 0);
|
|
1049
|
+
// One upload and one review may work on different images from the same
|
|
1050
|
+
// camera. A blocked network must not block that camera's next model call.
|
|
1051
|
+
const busy = new Set([...this.inFlight.values()].filter(inStage).map(e => e.name));
|
|
520
1052
|
let best = null;
|
|
521
1053
|
let bestOrder = Infinity;
|
|
1054
|
+
let bestRank = -1;
|
|
522
1055
|
for (const feed of this.feeds.values()) {
|
|
523
|
-
|
|
1056
|
+
if (this.reviewWindowMs !== null &&
|
|
1057
|
+
busy.has(feed.name))
|
|
1058
|
+
continue;
|
|
1059
|
+
// Keep FIFO within each class. A newly urgent item on the same feed can
|
|
1060
|
+
// pass older normal work; the fairness turn still serves that older work.
|
|
1061
|
+
let index = -1;
|
|
1062
|
+
// Temporal comparisons need capture order within a camera. An older retry
|
|
1063
|
+
// must finish before a later observation, even when the latter is urgent.
|
|
1064
|
+
// Other cameras and already committed upload work remain independent.
|
|
1065
|
+
const temporalFeed = stage === 'review' && feed.items.some(e => e.reviewContext?.temporalReviewContract);
|
|
1066
|
+
for (let i = 0; i < feed.items.length; i++) {
|
|
1067
|
+
if (temporalFeed && inStage(feed.items[i])) {
|
|
1068
|
+
if (eligible(feed.items[i]))
|
|
1069
|
+
index = i;
|
|
1070
|
+
break;
|
|
1071
|
+
}
|
|
1072
|
+
if (!eligible(feed.items[i]))
|
|
1073
|
+
continue;
|
|
1074
|
+
if (index < 0 || preference &&
|
|
1075
|
+
priorityRank(feed.items[i]) > priorityRank(feed.items[index]))
|
|
1076
|
+
index = i;
|
|
1077
|
+
if (!preference)
|
|
1078
|
+
break;
|
|
1079
|
+
}
|
|
524
1080
|
if (index < 0)
|
|
525
1081
|
continue;
|
|
526
1082
|
const entry = feed.items[index];
|
|
527
|
-
|
|
1083
|
+
// Check the feed's already-selected head, not every later item: a new
|
|
1084
|
+
// profile cannot jump older work merely because its model is free.
|
|
1085
|
+
if (accept && !accept(this.asCandidate(entry, Buffer.alloc(0))))
|
|
1086
|
+
continue;
|
|
1087
|
+
const order = orderByFeed.get(feed.name) ?? 0;
|
|
1088
|
+
const rank = preference ? priorityRank(entry) : 0;
|
|
528
1089
|
if (best === null ||
|
|
529
|
-
|
|
530
|
-
(
|
|
1090
|
+
rank > bestRank ||
|
|
1091
|
+
(rank === bestRank && (order < bestOrder ||
|
|
1092
|
+
(order === bestOrder && entry.enqueuedAtMs < best.entry.enqueuedAtMs)))) {
|
|
531
1093
|
best = { feed, index, entry };
|
|
532
1094
|
bestOrder = order;
|
|
1095
|
+
bestRank = rank;
|
|
533
1096
|
}
|
|
534
1097
|
}
|
|
535
1098
|
return best;
|
|
@@ -611,9 +1174,12 @@ class DurableVlmCandidateSpool {
|
|
|
611
1174
|
this.removePersisted(entry);
|
|
612
1175
|
}
|
|
613
1176
|
removePersisted(entry) {
|
|
1177
|
+
this.triageEligible.delete(entry.id);
|
|
1178
|
+
this.triageFinished.delete(entry.id);
|
|
1179
|
+
this.triagePositive.delete(entry.id);
|
|
614
1180
|
this.deletePair(entry);
|
|
615
1181
|
this.persisted = Math.max(0, this.persisted - 1);
|
|
616
|
-
this.persistedBytes = Math.max(0, this.persistedBytes - entry
|
|
1182
|
+
this.persistedBytes = Math.max(0, this.persistedBytes - this.entryBytes(entry));
|
|
617
1183
|
}
|
|
618
1184
|
deletePair(entry) {
|
|
619
1185
|
this.deletePairById(entry.id);
|