@trillboards/edge-sdk 0.2.140 → 0.2.151

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.
Files changed (188) hide show
  1. package/README.md +2 -2
  2. package/deploy/detector/pyproject.toml +20 -2
  3. package/deploy/detector/tests/test_execution.py +149 -0
  4. package/deploy/detector/tests/test_model_identity.py +80 -0
  5. package/deploy/detector/tests/test_registry.py +18 -0
  6. package/deploy/detector/tests/test_server.py +3 -1
  7. package/deploy/detector/tests/test_version_stamp.py +4 -1
  8. package/deploy/detector/trillboards_detector/_version.py +2 -2
  9. package/deploy/detector/trillboards_detector/cli.py +3 -0
  10. package/deploy/detector/trillboards_detector/detections.py +7 -0
  11. package/deploy/detector/trillboards_detector/errors.py +4 -0
  12. package/deploy/detector/trillboards_detector/execution.py +110 -0
  13. package/deploy/detector/trillboards_detector/loader.py +6 -3
  14. package/deploy/detector/trillboards_detector/registry.py +27 -5
  15. package/deploy/detector/trillboards_detector/server.py +29 -2
  16. package/deploy/detector/trillboards_detector/session.py +15 -3
  17. package/deploy/litertlm/test_trillboards_litertlm_shim.py +81 -0
  18. package/deploy/litertlm/test_verify_model_proof.py +30 -0
  19. package/deploy/litertlm/trillboards_litertlm_shim.py +150 -1
  20. package/deploy/litertlm/verify_model_proof.py +30 -0
  21. package/deploy/macos/build-macos-pkg.sh +10 -10
  22. package/deploy/macos/scripts/setup-detector.sh +2 -2
  23. package/deploy/macos/scripts/setup-litertlm.sh +66 -30
  24. package/deploy/macos/test-setup-litertlm-reinstall.sh +57 -4
  25. package/deploy/systemd/setup-detector.sh +2 -2
  26. package/deploy/systemd/setup-litertlm.sh +51 -27
  27. package/deploy/windows/build-windows.ps1 +10 -6
  28. package/deploy/windows/bundle-windows-installer.ps1 +3 -3
  29. package/deploy/windows/e2e/test-local-sense.ps1 +6 -5
  30. package/deploy/windows/install-update.ps1 +59 -14
  31. package/deploy/windows/set-executable-icon.ps1 +24 -5
  32. package/deploy/windows/setup-detector.ps1 +142 -4
  33. package/deploy/windows/setup-litertlm.ps1 +53 -10
  34. package/dist/AudienceAnalyzeClient.d.ts +10 -0
  35. package/dist/AudienceAnalyzeClient.d.ts.map +1 -1
  36. package/dist/AudienceAnalyzeClient.js +5 -4
  37. package/dist/AudienceAnalyzeClient.js.map +1 -1
  38. package/dist/CloudSensingManager.d.ts +38 -1
  39. package/dist/CloudSensingManager.d.ts.map +1 -1
  40. package/dist/CloudSensingManager.js +403 -25
  41. package/dist/CloudSensingManager.js.map +1 -1
  42. package/dist/DetectorFieldProducer.d.ts +5 -1
  43. package/dist/DetectorFieldProducer.d.ts.map +1 -1
  44. package/dist/DetectorFieldProducer.js +8 -1
  45. package/dist/DetectorFieldProducer.js.map +1 -1
  46. package/dist/DurableVlmCandidateSpool.d.ts +83 -2
  47. package/dist/DurableVlmCandidateSpool.d.ts.map +1 -1
  48. package/dist/DurableVlmCandidateSpool.js +596 -30
  49. package/dist/DurableVlmCandidateSpool.js.map +1 -1
  50. package/dist/EdgeAgent.js +1 -1
  51. package/dist/FrameGroupingShadow.d.ts +51 -0
  52. package/dist/FrameGroupingShadow.d.ts.map +1 -0
  53. package/dist/FrameGroupingShadow.js +158 -0
  54. package/dist/FrameGroupingShadow.js.map +1 -0
  55. package/dist/ImageClassifierFieldProducer.d.ts +36 -0
  56. package/dist/ImageClassifierFieldProducer.d.ts.map +1 -0
  57. package/dist/ImageClassifierFieldProducer.js +150 -0
  58. package/dist/ImageClassifierFieldProducer.js.map +1 -0
  59. package/dist/LiteRtVlmProvider.d.ts +5 -0
  60. package/dist/LiteRtVlmProvider.d.ts.map +1 -1
  61. package/dist/LiteRtVlmProvider.js +72 -7
  62. package/dist/LiteRtVlmProvider.js.map +1 -1
  63. package/dist/LocalDetectorClient.d.ts +6 -0
  64. package/dist/LocalDetectorClient.d.ts.map +1 -1
  65. package/dist/LocalDetectorClient.js +8 -0
  66. package/dist/LocalDetectorClient.js.map +1 -1
  67. package/dist/OrderedFrameReview.d.ts +57 -0
  68. package/dist/OrderedFrameReview.d.ts.map +1 -0
  69. package/dist/OrderedFrameReview.js +121 -0
  70. package/dist/OrderedFrameReview.js.map +1 -0
  71. package/dist/OrderedReviewClient.d.ts +38 -0
  72. package/dist/OrderedReviewClient.d.ts.map +1 -0
  73. package/dist/OrderedReviewClient.js +121 -0
  74. package/dist/OrderedReviewClient.js.map +1 -0
  75. package/dist/OrderedSequenceStore.d.ts +69 -0
  76. package/dist/OrderedSequenceStore.d.ts.map +1 -0
  77. package/dist/OrderedSequenceStore.js +257 -0
  78. package/dist/OrderedSequenceStore.js.map +1 -0
  79. package/dist/ProfileReviewWork.d.ts +68 -0
  80. package/dist/ProfileReviewWork.d.ts.map +1 -0
  81. package/dist/ProfileReviewWork.js +150 -0
  82. package/dist/ProfileReviewWork.js.map +1 -0
  83. package/dist/RetainedFieldContract.d.ts +21 -0
  84. package/dist/RetainedFieldContract.d.ts.map +1 -0
  85. package/dist/RetainedFieldContract.js +92 -0
  86. package/dist/RetainedFieldContract.js.map +1 -0
  87. package/dist/ReviewModelUse.d.ts +7 -0
  88. package/dist/ReviewModelUse.d.ts.map +1 -0
  89. package/dist/ReviewModelUse.js +30 -0
  90. package/dist/ReviewModelUse.js.map +1 -0
  91. package/dist/ReviewPriority.d.ts +12 -0
  92. package/dist/ReviewPriority.d.ts.map +1 -0
  93. package/dist/ReviewPriority.js +45 -0
  94. package/dist/ReviewPriority.js.map +1 -0
  95. package/dist/RuntimeModelSelection.d.ts +10 -0
  96. package/dist/RuntimeModelSelection.d.ts.map +1 -0
  97. package/dist/RuntimeModelSelection.js +58 -0
  98. package/dist/RuntimeModelSelection.js.map +1 -0
  99. package/dist/SceneChangeDetector.d.ts +1 -1
  100. package/dist/SceneChangeDetector.d.ts.map +1 -1
  101. package/dist/SceneChangeDetector.js +18 -6
  102. package/dist/SceneChangeDetector.js.map +1 -1
  103. package/dist/ScopedVlmReview.d.ts +76 -0
  104. package/dist/ScopedVlmReview.d.ts.map +1 -0
  105. package/dist/ScopedVlmReview.js +267 -0
  106. package/dist/ScopedVlmReview.js.map +1 -0
  107. package/dist/SensingProfileClient.d.ts +50 -0
  108. package/dist/SensingProfileClient.d.ts.map +1 -1
  109. package/dist/SensingProfileClient.js +58 -2
  110. package/dist/SensingProfileClient.js.map +1 -1
  111. package/dist/TemporalReviewClient.d.ts +43 -0
  112. package/dist/TemporalReviewClient.d.ts.map +1 -0
  113. package/dist/TemporalReviewClient.js +117 -0
  114. package/dist/TemporalReviewClient.js.map +1 -0
  115. package/dist/TemporalReviewJournal.d.ts +91 -0
  116. package/dist/TemporalReviewJournal.d.ts.map +1 -0
  117. package/dist/TemporalReviewJournal.js +337 -0
  118. package/dist/TemporalReviewJournal.js.map +1 -0
  119. package/dist/UrgentReviewTriage.d.ts +35 -0
  120. package/dist/UrgentReviewTriage.d.ts.map +1 -0
  121. package/dist/UrgentReviewTriage.js +52 -0
  122. package/dist/UrgentReviewTriage.js.map +1 -0
  123. package/dist/VideoSegmentUploader.d.ts +293 -0
  124. package/dist/VideoSegmentUploader.d.ts.map +1 -0
  125. package/dist/VideoSegmentUploader.js +1002 -0
  126. package/dist/VideoSegmentUploader.js.map +1 -0
  127. package/dist/VlmCandidateBuffer.d.ts +19 -0
  128. package/dist/VlmCandidateBuffer.d.ts.map +1 -1
  129. package/dist/VlmCandidateBuffer.js.map +1 -1
  130. package/dist/autoSense.d.ts +2 -2
  131. package/dist/autoSense.d.ts.map +1 -1
  132. package/dist/autoSense.js +16 -11
  133. package/dist/autoSense.js.map +1 -1
  134. package/dist/cli.js +1 -1
  135. package/dist/cli.js.map +1 -1
  136. package/dist/config.d.ts +2 -0
  137. package/dist/config.d.ts.map +1 -1
  138. package/dist/config.js.map +1 -1
  139. package/dist/continuousReview.d.ts +71 -0
  140. package/dist/continuousReview.d.ts.map +1 -0
  141. package/dist/continuousReview.js +53 -0
  142. package/dist/continuousReview.js.map +1 -0
  143. package/dist/detectorCountReview.d.ts +9 -0
  144. package/dist/detectorCountReview.d.ts.map +1 -0
  145. package/dist/detectorCountReview.js +79 -0
  146. package/dist/detectorCountReview.js.map +1 -0
  147. package/dist/diagnose.d.ts +1 -1
  148. package/dist/diagnose.js +1 -1
  149. package/dist/installerOnboardingMode.d.ts.map +1 -1
  150. package/dist/installerOnboardingMode.js +5 -3
  151. package/dist/installerOnboardingMode.js.map +1 -1
  152. package/dist/localInferenceSchema.d.ts +8 -0
  153. package/dist/localInferenceSchema.d.ts.map +1 -1
  154. package/dist/localInferenceSchema.js +3 -3
  155. package/dist/localInferenceSchema.js.map +1 -1
  156. package/dist/localSenseE2eHarness.d.ts +12 -0
  157. package/dist/localSenseE2eHarness.d.ts.map +1 -1
  158. package/dist/localSenseE2eHarness.js +77 -4
  159. package/dist/localSenseE2eHarness.js.map +1 -1
  160. package/dist/localSensing.d.ts +91 -18
  161. package/dist/localSensing.d.ts.map +1 -1
  162. package/dist/localSensing.js +678 -153
  163. package/dist/localSensing.js.map +1 -1
  164. package/dist/mixedScopedCounts.d.ts +17 -0
  165. package/dist/mixedScopedCounts.d.ts.map +1 -0
  166. package/dist/mixedScopedCounts.js +90 -0
  167. package/dist/mixedScopedCounts.js.map +1 -0
  168. package/dist/sensingCatchRate.d.ts +9 -5
  169. package/dist/sensingCatchRate.d.ts.map +1 -1
  170. package/dist/sensingCatchRate.js +10 -6
  171. package/dist/sensingCatchRate.js.map +1 -1
  172. package/dist/sensingRunMode.d.ts +5 -0
  173. package/dist/sensingRunMode.d.ts.map +1 -1
  174. package/dist/sensingRunMode.js +56 -0
  175. package/dist/sensingRunMode.js.map +1 -1
  176. package/package.json +7 -4
  177. package/retained/ArchiveLifecycle.cjs +79 -0
  178. package/retained/ModelStageScheduler.cjs +66 -0
  179. package/retained/ProfileAuthorityClient.cjs +88 -0
  180. package/retained/RetainedCaptureClient.cjs +164 -0
  181. package/retained/WaitRecheckCaptureClient.cjs +109 -0
  182. package/retained/__tests__/archive-lifecycle.test.cjs +117 -0
  183. package/retained/__tests__/runtime.test.cjs +384 -0
  184. package/retained/alert-rule-policy.cjs +37 -0
  185. package/retained/manager.cjs +133 -0
  186. package/retained/pilot-profile-client.cjs +82 -0
  187. package/retained/temporal-grouping.cjs +78 -0
  188. package/retained/website-api-capability.cjs +122 -0
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+ const fs=require('fs'),crypto=require('crypto'),assert=require('assert/strict');
3
+ const {RetainedCaptureClient}=require('./RetainedCaptureClient.cjs');
4
+ const {validateTemporalContract}=require('../dist/TemporalReviewJournal');
5
+ const hash=x=>crypto.createHash('sha256').update(JSON.stringify(x)).digest('hex');
6
+ function save(file,value){const tmp=file+'.rule-tmp',fd=fs.openSync(tmp,'w');try{fs.writeFileSync(fd,JSON.stringify(value));fs.fsyncSync(fd);}finally{fs.closeSync(fd);}fs.renameSync(tmp,file);}
7
+
8
+ /** Select at the review boundary, not ahead of the first model answer.
9
+ * One outstanding selected observation per profile bounds admission. Capture is independent.
10
+ * Every deferred/omitted original remains on disk without a fabricated model result.
11
+ */
12
+ class WaitRecheckCaptureClient extends RetainedCaptureClient {
13
+ constructor(client,directory,options){
14
+ super(client,directory,options);
15
+ this.deferred=new Map();this.waitOmitted=0;this.lastReview=null;this.unclearRetryMs=options.unclearRetryMs??0;assert(Number.isSafeInteger(this.unclearRetryMs)&&this.unclearRetryMs>=0&&this.unclearRetryMs<=5000,'Invalid unclear retry spacing');
16
+ const policy={version:1,unclearRetryMs:this.unclearRetryMs,dueRecheckPriority:'urgent'},policyFile=require('path').join(directory,'WAIT-POLICY.json');
17
+ if(fs.existsSync(policyFile))assert.deepEqual(JSON.parse(fs.readFileSync(policyFile)),policy,'Wait selection policy changed');else save(policyFile,policy);
18
+ const rows=fs.readdirSync(directory).filter(f=>/^[a-f0-9]{32}\.json$/.test(f)).map(f=>JSON.parse(fs.readFileSync(this.file(f.slice(0,32))))).sort((a,b)=>a.capturedAt-b.capturedAt);
19
+ for(const r of rows){
20
+ this.contract(r.candidate.reviewContext);
21
+ if(r.disposition==='awaiting_rule_decision')this.deferred.set(r.id,r);
22
+ if(r.disposition==='archived_wait_window')this.waitOmitted++;
23
+ if(r.result?.temporalReview)this.remember(r);
24
+ }
25
+ assert(this.entries.size<=1,'Wait/recheck archive has more than one outstanding selected observation');
26
+ }
27
+ contract(context){
28
+ const c=context?.temporalReviewContract;validateTemporalContract(c);
29
+ assert(c.mode==='wait_recheck'&&c.cameraId===this.cameraId,'Wrong wait/recheck contract');
30
+ assert(!(context.temporalComparisonContracts?.length),'Choose one alert rule per monitored area; comparisons use separate test arms');
31
+ assert(!context.orderedSequence&&!context.orderedCaptureContract,'Wait/recheck uses separately captured endpoint images');return c;
32
+ }
33
+ archivePins(){
34
+ const pins=super.archivePins();
35
+ for(const id of this.deferred?.keys()??[])pins.add(id);
36
+ if(this.lastReview?.decision.evidenceId)pins.add(this.lastReview.decision.evidenceId);
37
+ if(this.lastReview?.decision.waitAnchor)pins.add(this.lastReview.decision.waitAnchor.evidenceId);
38
+ return pins;
39
+ }
40
+ chooseReview(c,_digest,_due,stage){
41
+ this.contract(c.reviewContext);
42
+ return stage===null?{selected:false,disposition:'archived_profile_inactive'}:{selected:false,disposition:'awaiting_rule_decision'};
43
+ }
44
+ retainOrderedCapture(c){
45
+ const before=this.counts.captures;super.retainOrderedCapture(c);
46
+ if(this.counts.captures!==before){const r=this.previousCaptureRecord;if(r.disposition==='awaiting_rule_decision')this.deferred.set(r.id,r);}
47
+ }
48
+ remember(r){
49
+ const d=r.result.temporalReview;
50
+ assert(d.confirmationBasis==='first_and_follow_up'&&d.evidenceId===r.id&&d.imageSha256===r.imageSha256&&d.capturedAtMs===r.capturedAt,'Invalid saved wait/recheck decision');
51
+ if(!this.lastReview||r.capturedAt>=this.lastReview.capturedAt)this.lastReview={capturedAt:r.capturedAt,contextSha256:hash(r.candidate.reviewContext),decision:d};
52
+ }
53
+ async prepare(request){
54
+ const row=JSON.parse(fs.readFileSync(this.file(request.evidenceId))),anchor=row.ruleSelection?.anchor;
55
+ if(anchor){
56
+ assert(/^[a-f0-9]{32}$/.test(anchor.evidenceId),'Invalid wait anchor');
57
+ const first=JSON.parse(fs.readFileSync(this.file(anchor.evidenceId)));
58
+ assert(first.id===anchor.evidenceId&&first.imageSha256===anchor.imageSha256&&first.capturedAt===anchor.capturedAtMs
59
+ &&hash(first.candidate.reviewContext)===hash(row.candidate.reviewContext)&&first.result?.temporalReview?.value===true,'Wait anchor identity changed');
60
+ assert(crypto.createHash('sha256').update(fs.readFileSync(this.image(first))).digest('hex')===anchor.imageSha256,'Wait anchor image missing or changed');
61
+ }
62
+ const req=await super.prepare(request);this.remember(JSON.parse(fs.readFileSync(this.file(request.evidenceId))));return req;
63
+ }
64
+ pendingOrderedCandidates(){
65
+ // Includes delivery retry: never queue a second frame while the first is unresolved.
66
+ if(this.entries.size)return this.prioritize(super.pendingOrderedCandidates());
67
+ let processed=0;
68
+ for(const r of this.deferred.values()){
69
+ // Bounded work per pump keeps capture responsive even after a large backlog.
70
+ if(++processed>32)break;
71
+ const c=this.contract(r.candidate.reviewContext),contextSha256=hash(r.candidate.reviewContext);
72
+ const same=this.lastReview?.contextSha256===contextSha256;
73
+ const prior=same?this.lastReview.decision:null;
74
+ let select=false,reason='',grouped=null,dueAtMs=null;
75
+ if(!prior){select=true;reason='wait_initial_or_context_change';}
76
+ else if(prior.value==='unknown'){dueAtMs=prior.capturedAtMs+this.unclearRetryMs;select=r.capturedAt>=dueAtMs;reason=select?'wait_unclear_retry':'wait_unclear_retry_interval';}
77
+ else if(prior.alertLatched){dueAtMs=prior.capturedAtMs+c.followUpIntervalMs;select=r.capturedAt>=dueAtMs;reason=select?'wait_clear_check':'wait_alert_latched';}
78
+ else if(prior.waitAnchor){dueAtMs=prior.waitAnchor.capturedAtMs+c.durationMs;select=r.capturedAt>=dueAtMs;reason=select?'wait_deadline_check':'wait_timer_active';}
79
+ else {
80
+ grouped=this.selectionMode==='state-change-v1'?this.groupingChoice(r.candidate,r.imageSha256):{selected:true,selectionReason:'review_all_control'};
81
+ select=grouped.selected;reason=grouped.selectionReason;
82
+ }
83
+ // The mandatory endpoint check does not consult grouping or elapsed processing time.
84
+ r.ruleSelection={version:1,mode:'wait_recheck',reason,dueAtMs,fromEvidenceId:prior?.evidenceId??null,...(prior?.waitAnchor?{anchor:prior.waitAnchor}:{})};
85
+ r.selectionReason=reason;r.temporalContinuityBroken=!same;
86
+ if(select){
87
+ r.selected=true;r.disposition='pending_review';r.scheduledReviewPriority=prior?.waitAnchor&&r.capturedAt>=prior.waitAnchor.capturedAtMs+c.durationMs?'urgent':r.candidate.reviewPriority;save(this.file(r.id),r);
88
+ this.deferred.delete(r.id);this.entries.set(r.id,r);this.counts.selected++;
89
+ this.groupReferences.set(this.referenceKey(r.capturedAt,r.imageSha256,r.candidate.reviewContext),r.id);
90
+ if(this.groupReferences.size>256)this.groupReferences.delete(this.groupReferences.keys().next().value);
91
+ return this.prioritize(super.pendingOrderedCandidates());
92
+ }
93
+ if(grouped){r.disposition='archived_grouped_unchanged';r.groupReferenceIds=grouped.groupReferenceIds;save(this.file(r.id),r);this.counts.grouped++;}
94
+ else {r.disposition='archived_wait_window';save(this.file(r.id),r);this.waitOmitted++;}
95
+ this.records.set(r.id,r);
96
+ this.deferred.delete(r.id);
97
+ }
98
+ return [];
99
+ }
100
+ prioritize(items){return items.map(c=>{const r=this.entries.get(c.retainedId);return r?.scheduledReviewPriority?{...c,reviewPriority:r.scheduledReviewPriority}:c;});}
101
+ stats(){
102
+ const s=super.stats(),pendingRuleDecisions=this.deferred?.size??0,archivedWaitWindow=this.waitOmitted??0;
103
+ const first=this.deferred?.values().next().value?.capturedAt;
104
+ return {...s,archivedBetweenReviews:s.archivedBetweenReviews-pendingRuleDecisions-archivedWaitWindow,
105
+ pendingRuleDecisions,archivedWaitWindow,oldestPendingCapturedAt:s.oldestPendingCapturedAt??first??null,
106
+ waitingForDeadline:!!this.lastReview?.decision.waitAnchor&&!this.lastReview.decision.alertLatched};
107
+ }
108
+ }
109
+ module.exports={WaitRecheckCaptureClient};
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+ const { test } = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs'), path = require('node:path'), os = require('node:os');
5
+ const { RetainedCaptureClient } = require('../RetainedCaptureClient.cjs');
6
+
7
+ const endpoint = 'http://127.0.0.1:18791/v2/earner/audience-analyze';
8
+ function fixture(extra = {}) {
9
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'retained-lifecycle-fixture-'));
10
+ const client = {
11
+ getEndpoint: () => endpoint,
12
+ async reviewCandidate(req) { req.checkpointAnalysis({ fixture: true, evidenceSha256: require('node:crypto').createHash('sha256').update(req.jpeg).digest('hex') }); },
13
+ async analyze(req) { return { ok: true, body: { accepted: true, evidenceId: req.evidenceId, evidenceReview: { capturedAtMs: req.capturedAt } } }; },
14
+ };
15
+ const options = { cameraId: 'camera6', maxCaptures: 8, maxBytes: 100000, archiveRetentionMs: 500, ...extra };
16
+ const archive = new RetainedCaptureClient(client, directory, options);
17
+ function capture(at, label = String(at)) {
18
+ archive.retainOrderedCapture({ name: 'camera6', enqueuedAtMs: at,
19
+ jpeg: Buffer.from('synthetic-image-' + label), reviewContext: { version: 1 } });
20
+ return archive.previousCaptureRecord;
21
+ }
22
+ async function deliver(row) {
23
+ const req = { evidenceId: row.id, capturedAt: row.capturedAt, jpeg: fs.readFileSync(archive.image(row)),
24
+ reviewContext: row.candidate.reviewContext, checkpointAnalysis() {} };
25
+ await archive.analyze(req);
26
+ }
27
+ return { archive, client, directory, options, capture, deliver };
28
+ }
29
+
30
+ test('acknowledged originals are reclaimed without making lifetime counts the storage quota', async () => {
31
+ const f = fixture();
32
+ for (let i = 0; i < 100; i++) await f.deliver(f.capture(100000 + i * 500));
33
+ assert.equal(f.archive.stats().captures, 100);
34
+ assert.equal(f.archive.stats().received, 100);
35
+ assert(f.archive.stats().retainedCaptures <= 8);
36
+ assert(f.archive.stats().prunedCaptures > 90);
37
+ const recovered = new RetainedCaptureClient(f.client, f.directory, f.options);
38
+ assert.equal(recovered.stats().captures, 100);
39
+ assert.equal(recovered.stats().received, 100);
40
+ assert.equal(recovered.stats().pending, 0);
41
+ assert.equal(recovered.lastAt, 149500);
42
+ assert.equal(recovered.stats().logicalImageBytes, f.archive.stats().logicalImageBytes);
43
+ });
44
+
45
+ test('completed temporal heads do not pin all previous completed history', async () => {
46
+ const f = fixture();
47
+ for (let i = 0; i < 100; i++) {
48
+ const at = 100000 + i * 500;
49
+ f.archive.retainOrderedCapture({ name: 'camera6', enqueuedAtMs: at,
50
+ jpeg: Buffer.from('synthetic-temporal-' + i), reviewContext: {
51
+ temporalReviewContract: { groupedContinuity: { version: 1 } },
52
+ } });
53
+ await f.deliver(f.archive.previousCaptureRecord);
54
+ }
55
+ assert.equal(f.archive.stats().captures, 100);
56
+ assert(f.archive.stats().retainedCaptures <= 8);
57
+ assert(f.archive.stats().prunedCaptures > 90);
58
+ });
59
+
60
+ test('capacity pressure cannot discard an unresolved review or its exact image', async () => {
61
+ const f = fixture({ maxCaptures: 3 });
62
+ const first = f.capture(100000);
63
+ f.capture(100500); f.capture(101000);
64
+ assert.throws(() => f.capture(101500), /CAPTURE_STORAGE_EXHAUSTED/);
65
+ assert.equal(fs.existsSync(f.archive.file(first.id)), true);
66
+ assert.equal(fs.readFileSync(f.archive.image(first)).toString(), 'synthetic-image-100000');
67
+ assert.equal(f.archive.stats().pending, 3);
68
+ assert.equal(f.archive.stats().prunedCaptures, 0);
69
+ });
70
+
71
+ test('grouped and temporal references stay available until their pending consumer is acknowledged', async () => {
72
+ const f = fixture({ maxCaptures: 20 });
73
+ const first = f.capture(100000); await f.deliver(first);
74
+ const pending = f.capture(100500);
75
+ pending.groupReferenceIds = [first.id];
76
+ pending.candidate.reviewContext.temporalReviewContract = { groupedContinuity: { version: 1 } };
77
+ pending.previousSelectedId = first.id;
78
+ fs.writeFileSync(f.archive.file(pending.id), JSON.stringify(pending));
79
+ for (let i = 2; i < 12; i++) await f.deliver(f.capture(100000 + i * 500));
80
+ assert.equal(fs.existsSync(f.archive.file(first.id)), true);
81
+ assert.equal(fs.existsSync(f.archive.image(first)), true);
82
+ assert.equal(fs.existsSync(f.archive.file(pending.id)), true);
83
+ assert.equal(f.archive.stats().pending, 1);
84
+ });
85
+
86
+ test('reclamation deletes a shared image only after the last referencing record is retired', async () => {
87
+ const f = fixture();
88
+ const pending = f.capture(100000, 'shared');
89
+ await f.deliver(f.capture(100500, 'shared'));
90
+ for (let i = 2; i < 10; i++) await f.deliver(f.capture(100000 + i * 500));
91
+ assert.equal(fs.existsSync(f.archive.image(pending)), true);
92
+ await f.deliver(pending);
93
+ for (let i = 10; i < 15; i++) await f.deliver(f.capture(100000 + i * 500));
94
+ assert.equal(fs.existsSync(f.archive.image(pending)), false);
95
+ });
96
+
97
+ test('a committed reclamation resumes safely after interruption between record and image removal', async () => {
98
+ const f = fixture();
99
+ for (let i = 0; i < 4; i++) await f.deliver(f.capture(100000 + i * 500));
100
+ const unlink = fs.unlinkSync;
101
+ let interrupted = false;
102
+ fs.unlinkSync = file => {
103
+ if (!interrupted && file.endsWith('.jpg')) { interrupted = true; throw Error('synthetic interrupted cleanup'); }
104
+ return unlink(file);
105
+ };
106
+ try { assert.throws(() => f.capture(102000), /synthetic interrupted cleanup/); }
107
+ finally { fs.unlinkSync = unlink; }
108
+ const recovered = new RetainedCaptureClient(f.client, f.directory, f.options);
109
+ assert.equal(recovered.stats().captures, 4);
110
+ assert.equal(recovered.stats().received, 4);
111
+ assert.equal(recovered.stats().pending, 0);
112
+ assert.equal(JSON.parse(fs.readFileSync(path.join(f.directory, 'ARCHIVE-LIFECYCLE.json'))).pending, null);
113
+ for (const file of fs.readdirSync(f.directory).filter(name => /^[a-f0-9]{32}\.json$/.test(name))) {
114
+ const row = JSON.parse(fs.readFileSync(path.join(f.directory, file)));
115
+ assert.equal(fs.existsSync(recovered.image(row)), true);
116
+ }
117
+ });
@@ -0,0 +1,384 @@
1
+ 'use strict';
2
+ const { test } = require('node:test'), assert = require('node:assert/strict');
3
+ const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto');
4
+ const http = require('node:http');
5
+ const release = path.join(__dirname, '..');
6
+ const take = name => require(path.join(release, '../dist', name));
7
+ test('alert timing requires the authored structured website choice, not English heuristics', () => {
8
+ const { validateRule } = require('../alert-rule-policy.cjs');
9
+ assert.throws(() => validateRule('notify when the door has been left open'), /explicit timing mode/);
10
+ assert.throws(() => validateRule('alert immediately'), /explicit timing mode/);
11
+ assert.equal(validateRule({ mode: 'immediate' }).durationMs, 0);
12
+ assert.equal(validateRule({ mode: 'wait_recheck', durationMs: 10000 }).durationMs, 10000);
13
+ });
14
+ const { ProfileAuthorityClient, reconcilePresenceAlias } = require(path.join(release, 'ProfileAuthorityClient.cjs'));
15
+ const { SerialModelLane, ModelStageScheduler } = require(path.join(release, 'ModelStageScheduler.cjs'));
16
+ const sourceContract = {
17
+ "prompt": "Synthetic person-presence contract fixture",
18
+ "schema": {
19
+ "type": "object",
20
+ "properties": {
21
+ "people_present": {
22
+ "type": "boolean"
23
+ },
24
+ "person_count": {
25
+ "type": "integer"
26
+ },
27
+ "confidence": {
28
+ "type": "number"
29
+ },
30
+ "face_count": {
31
+ "type": "integer"
32
+ }
33
+ },
34
+ "required": [
35
+ "people_present",
36
+ "person_count",
37
+ "confidence",
38
+ "face_count"
39
+ ]
40
+ },
41
+ "required": [
42
+ {
43
+ "field": "face_count",
44
+ "type": "counter"
45
+ },
46
+ {
47
+ "field": "person_count",
48
+ "type": "counter",
49
+ "modelId": "rfdetr_medium"
50
+ },
51
+ {
52
+ "field": "person_present",
53
+ "type": "boolean",
54
+ "modelId": "rfdetr_medium"
55
+ }
56
+ ],
57
+ "operatingPoints": [
58
+ [
59
+ "person",
60
+ 0.5
61
+ ]
62
+ ],
63
+ "modelSelection": {
64
+ "version": 1,
65
+ "selectionId": "synthetic-contract",
66
+ "models": [
67
+ {
68
+ "modelId": "gemma_4_e2b",
69
+ "format": "litert-lm",
70
+ "sha256": "ab7838cdfc8f77e54d8ca45eadceb20452d9f01e4bfade03e5dce27911b27e42"
71
+ },
72
+ {
73
+ "modelId": "rfdetr_medium",
74
+ "format": "onnx",
75
+ "sha256": "8e8197112112b79d7cc5d0a94ecef659c05317a658aadc7c7643374c539d7615"
76
+ }
77
+ ],
78
+ "fieldOwners": [
79
+ {
80
+ "field": "face_count",
81
+ "modelId": "gemma_4_e2b"
82
+ },
83
+ {
84
+ "field": "person_count",
85
+ "modelId": "rfdetr_medium"
86
+ },
87
+ {
88
+ "field": "person_present",
89
+ "modelId": "rfdetr_medium"
90
+ }
91
+ ]
92
+ }
93
+ };
94
+ const digest = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
95
+ const clone = value => JSON.parse(JSON.stringify(value));
96
+ function context() {
97
+ return { version: 1, prompt: sourceContract.prompt, schema: clone(sourceContract.schema),
98
+ retainedFieldContract: { version: 1, required: clone(sourceContract.required) },
99
+ producerFields: sourceContract.required.filter(f => f.modelId).map(f => ({ ...f })),
100
+ absenceWatchedFields: [], regions: [], operatingPoints: clone(sourceContract.operatingPoints),
101
+ modelSelection: clone(sourceContract.modelSelection) };
102
+ }
103
+ function request(overrides = {}) {
104
+ return { jpeg: Buffer.from('explicitly-synthetic-test-jpeg'), evidenceId: 'synthetic-proof', capturedAt: 100000,
105
+ reviewWindowMs: 150000, reviewContext: context(), checkpointAnalysis: () => {}, ...overrides };
106
+ }
107
+ function saved(req, structured = { face_count: 0, person_count: 0, person_present: false, people_present: true, confidence: .99 }) {
108
+ return { version: 1, structured, detectorProduced: { person_count: 'rfdetr_medium', person_present: 'rfdetr_medium' },
109
+ analyzeMs: 1, promptText: req.reviewContext.prompt, reviewedAtMs: 100001, evidenceSha256: digest(req.jpeg), hasSignal: true,
110
+ runtimeSelectionSha256: take('ProfileReviewWork').reviewContextHash(req.reviewContext) };
111
+ }
112
+
113
+ test('retained wrappers preserve the real native camera heartbeat and measured inference cost', async () => {
114
+ const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'retained-health-fixture-'));
115
+ const health = { state: 'open', sensingState: 'unavailable', lastFrameAtMs: 100000 };
116
+ let reported;
117
+ const native = { getEndpoint: () => 'http://127.0.0.1:18791/v2/earner/audience-analyze',
118
+ getObservedAnalyzeMs: () => 1234, reportCameraHealth: async value => { reported = value; return { ok: true }; } };
119
+ const authority = new ProfileAuthorityClient(native);
120
+ const contract = { version: 1, cameraId: 'camera6', field: 'person_present', mode: 'any',
121
+ minObservations: 1, durationMs: 0, maxGapMs: 2000, unknownPolicy: 'reset_confirmation_keep_alert_latch' };
122
+ const temporal = new (take('TemporalReviewClient').TemporalReviewClient)(authority,
123
+ new (take('TemporalReviewJournal').TemporalReviewJournal)(path.join(directory, 'journal')), contract);
124
+ const capture = new (require('../RetainedCaptureClient.cjs').RetainedCaptureClient)(temporal,
125
+ path.join(directory, 'archive'), { cameraId: 'camera6' });
126
+ assert.equal((await capture.reportCameraHealth(health)).ok, true);
127
+ assert.equal(reported, health); assert.equal(capture.getObservedAnalyzeMs(), 1234);
128
+ });
129
+ test('positive RF verdict is not vetoed by a negative Gemma alias', () => {
130
+ const req = request(), result = reconcilePresenceAlias(saved(req,
131
+ { person_count: 1, person_present: true, people_present: false, face_count: 0, confidence: .99 }), req);
132
+ assert.equal(result.structured.people_present, true); assert.equal(result.hasSignal, true);
133
+ });
134
+ test('another configured signal is retained; person absence does not silence generic alerts', () => {
135
+ const req = request(), input = saved(req); input.structured.spill_present = true;
136
+ assert.equal(reconcilePresenceAlias(input, req).hasSignal, true);
137
+ });
138
+ test('RF-empty alias no longer creates a false signal by itself', () => {
139
+ const req = request(), result = reconcilePresenceAlias(saved(req), req);
140
+ assert.equal(result.structured.people_present, false); assert.equal(result.hasSignal, false);
141
+ });
142
+ test('absent specialist is unavailable, not a fabricated empty room', () => {
143
+ const req = request(), input = saved(req); delete input.detectorProduced.person_present;
144
+ assert.throws(() => reconcilePresenceAlias(input, req), /Incomplete retained review/);
145
+ });
146
+ test('model-selection and JPEG mismatches cannot pass a complete checkpoint', () => {
147
+ const req = request(); const image = saved(req); image.evidenceSha256 = '0'.repeat(64);
148
+ assert.throws(() => reconcilePresenceAlias(image, req), /different image/);
149
+ const identity = saved(req); identity.runtimeSelectionSha256 = '0'.repeat(64);
150
+ assert.throws(() => reconcilePresenceAlias(identity, req), /different captured/);
151
+ });
152
+ test('a count/presence inconsistency or forged cached correction stays an error', () => {
153
+ const req = request(), count = saved(req); count.structured.person_count = 1;
154
+ assert.throws(() => reconcilePresenceAlias(count, req), /RF presence\/count disagreement/);
155
+ const result = reconcilePresenceAlias(saved(req), req); result.authorityConsistency.evidenceSha256 = '0'.repeat(64);
156
+ assert.throws(() => reconcilePresenceAlias(result, req), /Cached authority/);
157
+ });
158
+ test('no declared RF ownership, independently owned alias, or lasso means no global override', () => {
159
+ for (const change of ['legacy', 'owned_alias', 'region', 'derived']) {
160
+ const req = request();
161
+ if (change === 'legacy') delete req.reviewContext.modelSelection;
162
+ if (change === 'owned_alias') {
163
+ req.reviewContext.retainedFieldContract.required.push({ field: 'people_present', type: 'boolean' });
164
+ req.reviewContext.modelSelection.fieldOwners.push({ field: 'people_present', modelId: 'gemma_4_e2b' });
165
+ }
166
+ if (change === 'region') req.reviewContext.regions = [['person_present', { points: [] }]];
167
+ if (change === 'derived') req.reviewContext.producerFields[0].derivation = { fixture: true };
168
+ const input = saved(req); assert.equal(reconcilePresenceAlias(input, req), input);
169
+ }
170
+ });
171
+ test('review and cached delivery use the same corrected answer and original raw value without another inference', async () => {
172
+ const req = request(); let calls = 0, posted;
173
+ const inner = { reviewCandidate: async r => { if (!r.cachedAnalysis) calls++; r.checkpointAnalysis(r.cachedAnalysis ?? saved(req)); },
174
+ analyze: async r => { posted = r; return { ok: true }; } };
175
+ const wrapped = new ProfileAuthorityClient(inner); let complete;
176
+ await wrapped.reviewCandidate({ ...req, checkpointAnalysis: value => { complete = value; } });
177
+ await wrapped.analyze({ ...req, cachedAnalysis: complete });
178
+ assert.equal(calls, 1); assert.equal(posted.cachedAnalysis.structured.people_present, false);
179
+ assert.equal(posted.extra.pilotAuthorityConsistency.rawValue, true); assert.equal(posted.jpeg, req.jpeg);
180
+ });
181
+ test('native LocalSensingClient completes all fields; compatibility repair is AFTER its RF merge', async () => {
182
+ const req = request(); let vlm = 0, detector = 0, uploaded;
183
+ const client = new (take('localSensing').LocalSensingClient)({ analyze: async () => {
184
+ vlm++; return { people_present: true, person_count: 4, confidence: .99, face_count: 0 };
185
+ } }, { getEndpoint: () => 'https://example.invalid/v2/earner/audience-analyze', analyzePreAnalyzed: async (result, extra) => {
186
+ uploaded = { result, extra }; return { ok: true }; } }, { retainedApiBaseUrl: 'https://example.invalid',
187
+ detectorFieldProducer: { produce: async () => { detector++; return { produced: [
188
+ { field: 'person_count', value: 0, modelId: 'rfdetr_medium' }, { field: 'person_present', value: false, modelId: 'rfdetr_medium' } ], withheld: [] }; } } });
189
+ const wrapped = new ProfileAuthorityClient(client); let complete;
190
+ await wrapped.reviewCandidate({ ...req, checkpointAnalysis: value => { complete = value; } });
191
+ assert.equal(complete.structured.person_count, 0); assert.equal(complete.structured.face_count, 0);
192
+ assert.equal(complete.structured.people_present, false); assert.equal(complete.hasSignal, false);
193
+ await wrapped.analyze({ ...req, cachedAnalysis: complete });
194
+ assert.equal(vlm, 1); assert.equal(detector, 1); assert.equal(uploaded.result.people_present, false);
195
+ assert.equal(uploaded.extra.capturedAt, req.capturedAt);
196
+ });
197
+ test('model lane never frees a still-running call on rejection of another model', async () => {
198
+ const lane = new SerialModelLane('test'); let release;
199
+ const first = lane.run(() => new Promise(resolve => { release = resolve; }));
200
+ await new Promise(resolve => setImmediate(resolve)); let secondStarted = false;
201
+ const second = lane.run(async () => { secondStarted = true; throw Error('expected failure'); });
202
+ const checked = assert.rejects(second, /expected failure/);
203
+ assert.equal(secondStarted, false); assert.equal(lane.stats().active, 1);
204
+ await assert.rejects(lane.run(() => {}), /overflow/);
205
+ release(); await first; await checked;
206
+ assert.equal(lane.stats().active, 0); assert.equal(lane.stats().pending, 0); assert.equal(lane.stats().maxActive, 1);
207
+ });
208
+ test('stage scheduler allows different model stages but binds each detector call to its own captured profile', async () => {
209
+ const modulePath = require.resolve(path.join(release, '../dist/DetectorFieldProducer'));
210
+ const original = require.cache[modulePath].exports, calls = [];
211
+ require.cache[modulePath].exports = { DetectorFieldProducer: class {
212
+ constructor(options) { this.options = options; }
213
+ async produce(jpeg, fields, pins) { calls.push({ sha: digest(jpeg), fields, pins, thresholds: [...this.options.classThresholds] });
214
+ await new Promise(resolve => setTimeout(resolve, 10)); return { produced: [], withheld: [] }; }
215
+ } };
216
+ try {
217
+ const scheduler = new ModelStageScheduler();
218
+ function client() { return { localProvider: { analyze: async () => { await new Promise(resolve => setTimeout(resolve, 10)); return {}; } },
219
+ producerOptions: { baseUrl: 'http://127.0.0.1:9384' }, producerInjected: false,
220
+ async reviewCandidate(req) { await this.localProvider.analyze({ jpeg: req.jpeg });
221
+ await this.producer.produce(req.jpeg, req.reviewContext.producerFields, new Map([['rfdetr_medium', 'fixture']])); } }; }
222
+ const first = request(), second = request({ jpeg: Buffer.from('other-synthetic-jpeg'), evidenceId: 'other', capturedAt: 100050 });
223
+ second.reviewContext.operatingPoints = [['person', .75]];
224
+ await Promise.all([scheduler.wrap(client()).reviewCandidate(first), scheduler.wrap(client()).reviewCandidate(second)]);
225
+ assert.deepEqual(calls.map(c => c.sha), [digest(first.jpeg), digest(second.jpeg)]);
226
+ assert.deepEqual(calls.map(c => c.thresholds), [[['person', .5]], [['person', .75]]]);
227
+ for (const lane of Object.values(scheduler.stats())) assert(lane.maxActive === 1 && lane.pending === 0 && lane.active === 0);
228
+ } finally { require.cache[modulePath].exports = original; }
229
+ });
230
+ test('stage scheduling rejects incompatible experiments and missing captured evidence', async () => {
231
+ const client = { localProvider: { analyze: async () => ({}) }, producerOptions: {}, producerInjected: false,
232
+ reviewCandidate: async () => {} };
233
+ const wrapped = new ModelStageScheduler().wrap(client);
234
+ assert.throws(() => wrapped.reviewCandidate({}), /original retained/);
235
+ const req = request(); req.reviewContext.mixedModelExecution = 'overlap_v1';
236
+ assert.throws(() => wrapped.reviewCandidate(req), /mix execution/);
237
+ });
238
+ test('real website factory retains the captured contract and builds one/two slots only with an explicit choice', async () => {
239
+ const names = [path.join(release, '../dist/localSensing'),
240
+ path.join(release, '../dist/CloudSensingManager'),
241
+ path.join(release, 'website-api-capability.cjs')];
242
+ const modules = names.map(name => { const key = require.resolve(name); require(key); return { key, prior: require.cache[key].exports }; });
243
+ const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'retained-factory-fixture-'));
244
+ let starts = 0, apiChecks = 0;
245
+ const fieldContext = context();
246
+ try {
247
+ require.cache[modules[0].key].exports = { buildLocalSensingLoops: config => config.cameras.map(feed => ({ name: feed.name,
248
+ client: { prepareReview: async () => {}, captureReviewContext: () => clone(fieldContext),
249
+ getEndpoint: () => 'https://example.invalid/v2/earner/audience-analyze',
250
+ localProvider: { analyze: () => { throw Error('No model call allowed'); } }, producerOptions: {}, producerInjected: false } })) };
251
+ require.cache[modules[1].key].exports = { resolveFeeds: config => config.cameras,
252
+ CloudSensingManager: class {
253
+ constructor(options) { this.options = options; this.candidates = { offer: () => 'buffered' };
254
+ this.frameGroupers = new Map(options.loops.map(loop => [loop.name, {}])); }
255
+ async start() { starts++; throw Error('No live manager start allowed'); }
256
+ } };
257
+ require.cache[modules[2].key].exports = { checkRetainedApi: async (_base, feed) => { apiChecks++; return { cameraId: feed.screenId }; } };
258
+ delete require.cache[require.resolve(path.join(release, 'manager.cjs'))];
259
+ const { createWebsiteManager } = require(path.join(release, 'manager.cjs'));
260
+ const config = { apiBaseUrl: 'https://example.invalid', localSensing: { enabled: true, model: 'gemma-update' },
261
+ cameras: [{ name: 'camera6', screenId: 'screen6' }, { name: 'camera8', screenId: 'screen8' }],
262
+ pilot: { requiresDetector: true, maxCapturesPerCamera: 100, maxBytesPerCamera: 100000,
263
+ alertRules: { camera6: { field: 'person_present', mode: 'immediate', durationMs: 0 }, camera8: { field: 'person_present', mode: 'immediate', durationMs: 0 } } },
264
+ cloudSensing: { candidateBuffer: { continuousReview: { enabled: true, deliveryTarget: 'configured-api', independentUploads: true, reviewWindowMs: 150000 } } } };
265
+ const before = clone(config);
266
+ const automatic = clone(config);
267
+ automatic.pilot = { websiteManaged: true, requiresDetector: true,
268
+ maxCapturesPerCamera: 100, maxBytesPerCamera: 100000 };
269
+ const website = await createWebsiteManager(automatic, path.join(directory, 'website-managed'));
270
+ assert.equal(website.manager.options.maxConcurrentAnalyses, 1);
271
+ for (const client of website.clients) {
272
+ const actual = client.captureReviewContext();
273
+ assert.deepEqual(actual.retainedFieldContract, fieldContext.retainedFieldContract);
274
+ assert.deepEqual(actual.modelSelection, fieldContext.modelSelection);
275
+ assert.equal(actual.temporalReviewContract, undefined);
276
+ assert.equal(client.intervalMs, 500);
277
+ }
278
+ for (const pipeline of [false, true]) {
279
+ const selected = clone(config); if (pipeline) selected.pilot.crossCameraPipelining = true;
280
+ const runtime = await createWebsiteManager(selected, path.join(directory, pipeline ? 'pipelined' : 'serial'));
281
+ assert.equal(runtime.manager.options.maxConcurrentAnalyses, pipeline ? 2 : 1);
282
+ assert.equal(!!runtime.stageScheduler, pipeline);
283
+ for (const client of runtime.clients) {
284
+ const actual = client.captureReviewContext();
285
+ for (const field of ['schema', 'prompt', 'retainedFieldContract', 'producerFields', 'modelSelection', 'operatingPoints', 'regions'])
286
+ assert.deepEqual(actual[field], fieldContext[field]);
287
+ assert.equal(client.intervalMs, 500); assert.equal(actual.temporalReviewContract.groupedContinuity.checkIntervalMs, 5000);
288
+ }
289
+ }
290
+ assert.deepEqual(config, before); assert.equal(starts, 0); assert.equal(apiChecks, 6);
291
+ const incompatible = clone(config); incompatible.pilot.crossCameraPipelining = true;
292
+ incompatible.cloudSensing.candidateBuffer.continuousReview.overlapMixedReview = true;
293
+ await assert.rejects(createWebsiteManager(incompatible, path.join(directory, 'rejected')), /isolated two-feed/);
294
+ } finally {
295
+ for (const module of modules) require.cache[module.key].exports = module.prior;
296
+ delete require.cache[require.resolve(path.join(release, 'manager.cjs'))];
297
+ }
298
+ });
299
+ test('normal website startup resolves thirteen paired cameras with two active profiles and no manual pilot rules', async () => {
300
+ const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'normal-website-fixture-'));
301
+ let opens = 0, models = 0, profiles = 0, capabilities = 0, denied = false;
302
+ const nonceSet = new Set();
303
+ const cameras = Array.from({ length: 13 }, (_, i) => ({ name: 'camera-' + i, screenId: 'screen_' + i,
304
+ deviceToken: 'synthetic-token-' + i, rtspUrl: 'rtsp://fixture.invalid/channel-' + i }));
305
+ const server = http.createServer((req, res) => {
306
+ const url = new URL(req.url, 'http://127.0.0.1'), cameraId = url.searchParams.get('screen_id');
307
+ const feed = cameras.find(camera => camera.screenId === cameraId);
308
+ const nonce = req.headers['x-device-nonce'];
309
+ if (!feed || req.headers['x-device-token'] !== feed.deviceToken || !nonce
310
+ || nonceSet.has(nonce) || !Number.isFinite(Number(req.headers['x-device-timestamp']))) {
311
+ res.writeHead(403).end('{}'); return;
312
+ }
313
+ nonceSet.add(nonce);
314
+ res.setHeader('Content-Type', 'application/json');
315
+ if (url.pathname.endsWith('/capabilities')) {
316
+ capabilities++;
317
+ if (denied) { res.writeHead(403).end('{}'); return; }
318
+ res.end(JSON.stringify({ retainedReview: true, protocolVersion: 1, lateEvidenceAccepted: true,
319
+ timedModes: ['any', 'wait_recheck'], cameraId, maxBodyBytes: 2 * 1024 * 1024 })); return;
320
+ }
321
+ if (!url.pathname.endsWith('/sensing-profile')) { res.writeHead(405).end('{}'); return; }
322
+ profiles++;
323
+ const active = feed === cameras[0] || feed === cameras[1];
324
+ const field = { field: 'door_open', type: 'boolean', description: 'Check this camera door.' };
325
+ res.end(JSON.stringify({ success: true, screenId: cameraId, profileState: active ? 'active' : 'inactive',
326
+ profileName: active ? 'Door profile' : null, profileId: active ? 'door-' + cameraId : null,
327
+ profileType: active ? 'custom_intent' : null, fields: active ? [field] : [], absenceWatchedFields: [],
328
+ retainedPilot: { version: 1, reviewFieldSet: 'declared' },
329
+ fieldRouting: { vlm: active ? [field] : [], producer: [], withheld: [], operatingPoints: {}, alertFocusRegions: [] },
330
+ modelSelection: active ? { version: 1, selectionId: 'door-' + cameraId,
331
+ models: [clone(sourceContract.modelSelection.models[0])],
332
+ fieldOwners: [{ field: 'door_open', modelId: 'gemma_4_e2b' }] } : null }));
333
+ });
334
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
335
+ try {
336
+ const config = take('sensingRunMode').configuredWebsiteReview({ apiBaseUrl: 'http://127.0.0.1:' + server.address().port,
337
+ cameras, localSensing: { enabled: true }, dataDir: directory });
338
+ const input = { ...config, pilot: { websiteManaged: true, requiresDetector: true } };
339
+ const { createWebsiteManager } = require('../manager.cjs');
340
+ const deps = { rtspProviderFactory: () => ({ isOpen: () => false,
341
+ open: async () => { opens++; throw Error('No capture is authorized by this startup fixture'); } }),
342
+ localProviderFactory: () => ({ analyze: () => { models++; throw Error('No inference in startup fixture'); } }) };
343
+ const runtime = await createWebsiteManager(input, path.join(directory, 'state'), deps);
344
+ assert.equal(runtime.loops.length, 13); assert.equal(capabilities, 13); assert.equal(profiles, 13);
345
+ assert.equal(runtime.clients.filter(client => client.isReviewActive()).length, 2);
346
+ assert.equal(runtime.clients.filter(client => !client.isReviewActive()).length, 11);
347
+ for (const client of runtime.clients.slice(0, 2)) {
348
+ const captured = client.captureReviewContext();
349
+ assert.equal(captured.retainedFieldContract.required[0].field, 'door_open');
350
+ assert.equal(captured.modelSelection.fieldOwners[0].modelId, 'gemma_4_e2b');
351
+ assert.equal(captured.temporalReviewContract, undefined);
352
+ }
353
+ assert.equal(opens, 0); assert.equal(models, 0);
354
+ denied = true;
355
+ await assert.rejects(createWebsiteManager(input, path.join(directory, 'denied'), deps), /HTTP 403/);
356
+ assert.equal(profiles, 13); assert.equal(opens, 0); assert.equal(models, 0);
357
+ } finally {
358
+ await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
359
+ fs.rmSync(directory, { recursive: true, force: true });
360
+ }
361
+ });
362
+ test('native durable spool enforces capture order and one in-flight review per feed, not one for the whole site', () => {
363
+ const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'retained-spool-fixture-'));
364
+ const { DurableVlmCandidateSpool } = take('DurableVlmCandidateSpool');
365
+ let now = 101000;
366
+ const spool = new DurableVlmCandidateSpool({ directory, maxFrames: 64, maxBytes: 1000000, maxPerFeed: 32,
367
+ preserveArchivedPending: true, reviewWindowMs: 150000, retentionMs: 86400000, now: () => now });
368
+ spool.startRun();
369
+ const contract = { version: 1, cameraId: 'camera6', field: 'person_present', mode: 'any', minObservations: 1,
370
+ durationMs: 0, maxGapMs: 2000, unknownPolicy: 'reset_confirmation_keep_alert_latch' };
371
+ for (const [name, at] of [['camera6', 100000], ['camera6', 100500], ['camera8', 100050], ['camera8', 100550]]) {
372
+ const id = digest(Buffer.from(name + ':' + at)).slice(0, 32);
373
+ assert.equal(spool.offer({ name, jpeg: Buffer.from(name + ':' + at), enqueuedAtMs: at,
374
+ reviewContext: { ...context(), temporalReviewContract: { ...contract, cameraId: name },
375
+ retainedCapture: { version: 1, id, kind: 'retained_single_capture' } }, retainedId: id }), 'buffered');
376
+ }
377
+ const first = spool.take(undefined, 'review'), second = spool.take(undefined, 'review');
378
+ assert.notEqual(first.name, second.name); assert.equal(spool.peek(undefined, 'review'), null);
379
+ assert.equal(digest(first.jpeg), digest(Buffer.from(first.name + ':' + first.enqueuedAtMs)));
380
+ assert.equal(digest(second.jpeg), digest(Buffer.from(second.name + ':' + second.enqueuedAtMs)));
381
+ spool.retry(first); spool.retry(second);
382
+ now += 6000; // The native retry backoff is five seconds, not two.
383
+ const again = spool.take(undefined, 'review'); assert.equal(again.enqueuedAtMs, first.enqueuedAtMs);
384
+ });
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+ const assert=require('assert/strict');
3
+ const {validateTemporalContract}=require('../dist/TemporalReviewJournal');
4
+ const MODES=['immediate','wait_recheck','continuous_duration'];
5
+ /** Website fields own timing. Free text cannot silently choose an alert timer. */
6
+ function parseInstruction(text){
7
+ assert(typeof text==='string'&&text.trim().length&&text.length<=2000,'An alert instruction is required');
8
+ throw new Error('Choose an explicit timing mode and duration in the website alert settings; free-text timing is not inferred');
9
+ }
10
+ function validateRule(value){
11
+ if(typeof value==='string')return parseInstruction(value);
12
+ assert(value&&typeof value==='object'&&!Array.isArray(value)&&MODES.includes(value.mode),'Invalid alert rule');
13
+ const durationMs=value.durationMs??0;
14
+ assert(Number.isSafeInteger(durationMs)&&(value.mode==='immediate'?durationMs===0:durationMs>=500&&durationMs<=600000),'Duration must be 0 for immediate, or 0.5 to 600 seconds for timed rules');
15
+ const followUpIntervalMs=value.followUpIntervalMs??5000;
16
+ if(value.mode==='wait_recheck')assert(Number.isSafeInteger(followUpIntervalMs)&&followUpIntervalMs>=500&&followUpIntervalMs<=60000,'Invalid post-alert check interval');
17
+ else assert(value.followUpIntervalMs===undefined,'Follow-up interval belongs to wait/recheck only');
18
+ const label=value.mode==='immediate'?'Alert immediately':value.mode==='wait_recheck'?`Wait ${durationMs/1000} seconds, then check again`:`Check duration with intermediate evidence: ${durationMs/1000} seconds`;
19
+ return {mode:value.mode,durationMs,...(value.mode==='wait_recheck'?{followUpIntervalMs}:{}),source:value.source==='instruction'?'instruction':'explicit',label};
20
+ }
21
+ function contractFor(base,rule){
22
+ rule=validateRule(rule);const c={...base};delete c.groupedContinuity;delete c.followUpIntervalMs;
23
+ c.mode=rule.mode==='immediate'?'any':rule.mode==='continuous_duration'?'duration':'wait_recheck';
24
+ c.minObservations=rule.mode==='immediate'?1:2;c.durationMs=rule.durationMs;
25
+ c.unknownPolicy=rule.mode==='wait_recheck'?'keep_wait_anchor_retry_unknown':'reset_confirmation_keep_alert_latch';
26
+ if(rule.mode==='wait_recheck'){c.clearObservations=1;c.followUpIntervalMs=rule.followUpIntervalMs;}
27
+ else c.groupedContinuity=base.groupedContinuity??{version:1,checkIntervalMs:5000,maxReviewGapMs:Math.max(6000,5000+c.maxGapMs)};
28
+ validateTemporalContract(c);return c;
29
+ }
30
+ function applyAlertRules(plan,choices){
31
+ if(choices===null||choices===undefined)return plan; // Explicitly retain the earlier test configuration.
32
+ assert(choices&&typeof choices==='object'&&!Array.isArray(choices),'Choose alert rules per profile');
33
+ assert.deepEqual(Object.keys(choices).sort(),plan.profiles.map(p=>p.id).sort(),'Choose exactly the configured profiles');
34
+ const alertRules=Object.fromEntries(plan.profiles.map(p=>[p.id,validateRule(choices[p.id])]));
35
+ return {...plan,alertRules,profiles:plan.profiles.map(p=>({...p,alertRule:alertRules[p.id],temporal:contractFor(p.temporal,alertRules[p.id]),temporalComparisons:[]}))};
36
+ }
37
+ module.exports={parseInstruction,validateRule,contractFor,applyAlertRules};