dsh-vision-router 2.3.0 → 3.0.1
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 +18 -3
- package/README.zh.md +16 -3
- package/docs/architecture/3.0-architecture-plan.md +252 -0
- package/docs/architecture/3.0-r0-contract-ledger.md +137 -0
- package/docs/architecture/3.0-r1-source-artifact-boundary.md +98 -0
- package/docs/architecture/3.0-r10-final-correctness-closure.md +207 -0
- package/docs/architecture/3.0-r11-adversarial-review.md +447 -0
- package/docs/architecture/3.0-r5-browser-go-no-go.md +165 -0
- package/docs/architecture/3.0-r5-browser-typescript-gate.md +105 -0
- package/docs/architecture/3.0-r6-compat-retirement.md +203 -0
- package/docs/architecture/3.0-r7-public-api.md +151 -0
- package/docs/architecture/3.0-r8-ci-release.md +248 -0
- package/docs/architecture/3.0-r9-final-audit.md +370 -0
- package/docs/architecture/dsh-support-window.md +13 -3
- package/docs/releases/v3.0.0.md +85 -0
- package/docs/releases/v3.0.1.md +77 -0
- package/entry.js +1 -1
- package/index.js +110 -71
- package/lib/agent-request-route-authority.js +1 -20
- package/lib/core-apply-lifecycle.js +37 -43
- package/lib/core-cache.js +87 -0
- package/lib/core-primitives.js +11 -144
- package/lib/core-vision-surface-domain.js +63 -0
- package/lib/core-vision-surface.js +11 -77
- package/lib/delegated-call-config.js +15 -24
- package/lib/doctor-cli.js +0 -0
- package/lib/doctor-runtime.js +4 -1
- package/lib/dsh-contract-compat.js +26 -230
- package/lib/dsh-host-capabilities.js +3 -9
- package/lib/dsh-settings-017-compat.js +17 -10
- package/lib/file-logger.js +8 -3
- package/lib/grounding-coordinate-runtime.js +1 -2
- package/lib/http-body-limit.js +97 -79
- package/lib/image-offload-compat.js +30 -21
- package/lib/image-resource-governor.js +210 -228
- package/lib/legacy-global-proxy-boundary.js +46 -3
- package/lib/live-model-client-prelude.js +12 -4
- package/lib/live-model-discovery.js +134 -43
- package/lib/local-vision-stabilizer.js +33 -18
- package/lib/maintenance-routes.js +1 -3
- package/lib/native-image-coexistence.js +15 -3
- package/lib/ollama-cold-start.js +87 -23
- package/lib/pi-ai-bridge-wire-compat.js +3 -2
- package/lib/proxy-dispatcher-pool.js +151 -92
- package/lib/proxy-routing.js +75 -51
- package/lib/proxy-url-compat.js +3 -10
- package/lib/public-config.js +23 -44
- package/lib/public-entry.d.ts +148 -0
- package/lib/public-entry.js +17 -4
- package/lib/replay-delegation.js +12 -7
- package/lib/replay-envelope-v2-compat.js +12 -7
- package/lib/runtime-composition.js +30 -10
- package/lib/screenshot-source-boundary.js +2 -2
- package/lib/session-affinity-runtime.js +113 -97
- package/lib/session-affinity.js +89 -76
- package/lib/session-event-history-compat.js +30 -0
- package/lib/session-query-readers.js +263 -0
- package/lib/session-surface-compat.js +30 -31
- package/lib/session-surface-policy-domain.js +85 -0
- package/lib/session-surface-policy.js +2 -111
- package/lib/session-turn-resolver.js +2 -6
- package/lib/session-vision-attachment-index.js +143 -0
- package/lib/session-vision-delegation-boundary.js +34 -0
- package/lib/session-vision-delegation.js +22 -0
- package/lib/session-vision-index.js +25 -107
- package/lib/session-vision-mode-authority.js +229 -192
- package/lib/session-vision-mode-boundary.js +65 -22
- package/lib/session-vision-policy-domain.js +304 -0
- package/lib/session-vision-policy.js +104 -0
- package/lib/session-vision-state.js +368 -378
- package/lib/settings-client-017-compat.js +16 -10
- package/lib/structured-flow-hardening.js +22 -4
- package/lib/tesseract-exec-compat.js +9 -1
- package/lib/turn-budget-context.js +47 -41
- package/lib/update-check.js +11 -2
- package/lib/vision-attachment-handle-runtime.js +176 -156
- package/lib/vision-backend-runtime-policy.js +16 -0
- package/lib/vision-background-benchmark.js +55 -6
- package/lib/vision-background-failure-policy.js +41 -41
- package/lib/vision-background-stop-store-core.js +217 -0
- package/lib/vision-background-stop-store.js +8 -179
- package/lib/vision-capability-benchmark.js +11 -61
- package/lib/vision-capability-evidence.js +35 -0
- package/lib/vision-capability-fingerprint.js +72 -0
- package/lib/vision-capability-identity.js +116 -61
- package/lib/vision-capability-router.js +13 -33
- package/lib/vision-chunk-assembler.js +95 -0
- package/lib/vision-diagnostics-routes.js +42 -4
- package/lib/vision-execution-order-apply.js +45 -55
- package/lib/vision-execution-order.js +19 -19
- package/lib/vision-execution-policy.js +12 -7
- package/lib/vision-model-registry.js +30 -17
- package/lib/vision-model-visibility-boundary-main.js +15 -16
- package/lib/vision-provider-transport.js +137 -147
- package/lib/vision-routing-authority.js +47 -48
- package/lib/vision-routing-product.js +26 -29
- package/lib/vision-routing-settings-prelude.js +8 -3
- package/lib/vision-runtime-performance-store.js +166 -0
- package/lib/vision-runtime-performance.js +13 -142
- package/lib/vision-tool-runtime-boundary.js +99 -30
- package/lib/web/dsh-settings-017-root-transport.js +27 -12
- package/lib/web/remote-settings-client.js +4 -7
- package/lib/web-capability-boundary.js +17 -7
- package/lib/wrapper-directory.js +14 -23
- package/package.json +37 -11
|
@@ -415,5 +415,21 @@ export function contextWithVisionBackendRuntimePolicy(ctx, options = {}) {
|
|
|
415
415
|
? finalContextDecorator(wrapped)
|
|
416
416
|
: wrapped
|
|
417
417
|
wrappedContexts.set(ctx, result)
|
|
418
|
+
let lifecycleOwned = false
|
|
419
|
+
try {
|
|
420
|
+
if (typeof ctx.effect === 'function') {
|
|
421
|
+
ctx.effect(
|
|
422
|
+
() => () => {
|
|
423
|
+
if (wrappedContexts.get(ctx) === result) wrappedContexts.delete(ctx)
|
|
424
|
+
},
|
|
425
|
+
'vision-router: backend runtime policy context lifecycle',
|
|
426
|
+
)
|
|
427
|
+
lifecycleOwned = true
|
|
428
|
+
}
|
|
429
|
+
} catch {
|
|
430
|
+
// The current caller can still use this one-shot policy view.
|
|
431
|
+
}
|
|
432
|
+
// options contains generation-owned config/transport/evidence closures.
|
|
433
|
+
if (!lifecycleOwned && wrappedContexts.get(ctx) === result) wrappedContexts.delete(ctx)
|
|
418
434
|
return result
|
|
419
435
|
}
|
|
@@ -552,6 +552,7 @@ export function createBackgroundCapabilityProfiler({
|
|
|
552
552
|
let lastForegroundAt = Number(now())
|
|
553
553
|
let runningController
|
|
554
554
|
let runningWork
|
|
555
|
+
let tickTask
|
|
555
556
|
let topologyDirty = false
|
|
556
557
|
let lastAuthority = resolveVisionRoutingAuthority(activeSettings(ctx, config))
|
|
557
558
|
const backoff = new Map()
|
|
@@ -689,7 +690,7 @@ export function createBackgroundCapabilityProfiler({
|
|
|
689
690
|
}
|
|
690
691
|
}
|
|
691
692
|
|
|
692
|
-
const
|
|
693
|
+
const runTick = async () => {
|
|
693
694
|
if (stopped || runningController) return
|
|
694
695
|
const currentNow = Number(now())
|
|
695
696
|
const current = activeSettings(ctx, config)
|
|
@@ -757,7 +758,7 @@ export function createBackgroundCapabilityProfiler({
|
|
|
757
758
|
}, interval)
|
|
758
759
|
try { policyTimer?.unref?.() } catch {}
|
|
759
760
|
|
|
760
|
-
const runner = runAxisBenchmark ??
|
|
761
|
+
const runner = runAxisBenchmark ?? runAxis
|
|
761
762
|
await runner({
|
|
762
763
|
ctx,
|
|
763
764
|
config: current,
|
|
@@ -767,6 +768,7 @@ export function createBackgroundCapabilityProfiler({
|
|
|
767
768
|
axis: work.axis,
|
|
768
769
|
signal: controller.signal,
|
|
769
770
|
logger,
|
|
771
|
+
options: invokerOptions,
|
|
770
772
|
onProgress(progress = {}) {
|
|
771
773
|
if (!runningWork || runningWork.candidate.key !== work.candidate.key || runningWork.axis !== work.axis) return
|
|
772
774
|
const completed = Math.max(0, Number(progress.completed) || 0)
|
|
@@ -864,6 +866,32 @@ export function createBackgroundCapabilityProfiler({
|
|
|
864
866
|
}
|
|
865
867
|
}
|
|
866
868
|
|
|
869
|
+
const tick = () => {
|
|
870
|
+
if (tickTask) return tickTask
|
|
871
|
+
let tracked
|
|
872
|
+
// `tracked` is assigned before any rejection can be observed: the timer
|
|
873
|
+
// callback and the lifecycle disposer both call tick(), and a background
|
|
874
|
+
// scan that throws must degrade to a logged failure instead of an
|
|
875
|
+
// unhandled process-level rejection.
|
|
876
|
+
tracked = Promise.resolve()
|
|
877
|
+
.then(runTick)
|
|
878
|
+
.catch((error) => {
|
|
879
|
+
try {
|
|
880
|
+
logger?.warn?.(
|
|
881
|
+
'vision-router: background benchmark tick failed: %s',
|
|
882
|
+
bounded(error?.message ?? error),
|
|
883
|
+
)
|
|
884
|
+
} catch {
|
|
885
|
+
/* diagnostics only */
|
|
886
|
+
}
|
|
887
|
+
})
|
|
888
|
+
.finally(() => {
|
|
889
|
+
if (tickTask === tracked) tickTask = undefined
|
|
890
|
+
})
|
|
891
|
+
tickTask = tracked
|
|
892
|
+
return tracked
|
|
893
|
+
}
|
|
894
|
+
|
|
867
895
|
const foregroundStart = () => {
|
|
868
896
|
activeForeground += 1
|
|
869
897
|
lastForegroundAt = Number(now())
|
|
@@ -893,6 +921,7 @@ export function createBackgroundCapabilityProfiler({
|
|
|
893
921
|
if (timer !== undefined) clearTimer(timer)
|
|
894
922
|
timer = undefined
|
|
895
923
|
yieldBackground('background profiler stopped')
|
|
924
|
+
return tickTask ?? Promise.resolve()
|
|
896
925
|
}
|
|
897
926
|
|
|
898
927
|
schedule(idleMs)
|
|
@@ -971,6 +1000,16 @@ export function installBackgroundCapabilityProfiling(ctx, config, core, store, o
|
|
|
971
1000
|
const logger = options.logger ?? ctx?.logger
|
|
972
1001
|
const imageVerdictStore = options.imageVerdictStore ?? createImageInputVerdictStore({ logger })
|
|
973
1002
|
const backgroundStopStore = options.backgroundStopStore ?? createBackgroundBenchmarkStopStore({ logger })
|
|
1003
|
+
// Provider transport is one explicit runtime capability shared by automatic
|
|
1004
|
+
// profiling and the user-triggered exact check. Top-level ownership wins so
|
|
1005
|
+
// a stale nested test/customization bag cannot silently bypass live proxy
|
|
1006
|
+
// policy after composition or HMR changes.
|
|
1007
|
+
const invokerOptions = {
|
|
1008
|
+
...(options.invokerOptions ?? {}),
|
|
1009
|
+
...(options.providerTransport === undefined
|
|
1010
|
+
? {}
|
|
1011
|
+
: { providerTransport: options.providerTransport }),
|
|
1012
|
+
}
|
|
974
1013
|
const profiler = createBackgroundCapabilityProfiler({
|
|
975
1014
|
ctx,
|
|
976
1015
|
config,
|
|
@@ -978,6 +1017,7 @@ export function installBackgroundCapabilityProfiling(ctx, config, core, store, o
|
|
|
978
1017
|
store,
|
|
979
1018
|
logger,
|
|
980
1019
|
...options,
|
|
1020
|
+
invokerOptions,
|
|
981
1021
|
imageVerdictStore,
|
|
982
1022
|
backgroundStopStore,
|
|
983
1023
|
})
|
|
@@ -1022,17 +1062,26 @@ export function installBackgroundCapabilityProfiling(ctx, config, core, store, o
|
|
|
1022
1062
|
// Event-driven wakeups are an optimization; the short authority scan remains.
|
|
1023
1063
|
}
|
|
1024
1064
|
|
|
1065
|
+
let lifecycleOwned = true
|
|
1025
1066
|
try {
|
|
1026
|
-
ctx?.effect?.(() => () => {
|
|
1067
|
+
ctx?.effect?.(() => async () => {
|
|
1027
1068
|
for (const dispose of eventDisposers.splice(0)) {
|
|
1028
1069
|
try { dispose() } catch {}
|
|
1029
1070
|
}
|
|
1030
|
-
profiler.stop()
|
|
1071
|
+
await profiler.stop()
|
|
1031
1072
|
}, 'vision-router: background capability profiler')
|
|
1032
1073
|
} catch {
|
|
1033
|
-
|
|
1074
|
+
lifecycleOwned = false
|
|
1075
|
+
for (const dispose of eventDisposers.splice(0)) {
|
|
1076
|
+
try { dispose() } catch {}
|
|
1077
|
+
}
|
|
1078
|
+
void profiler.stop().catch(() => {})
|
|
1034
1079
|
}
|
|
1035
1080
|
|
|
1081
|
+
// An inactive/unowned generation must not continue admitting client scripts,
|
|
1082
|
+
// routes, or tool wrappers after its profiler ownership was rejected.
|
|
1083
|
+
if (!lifecycleOwned) return { ctx, profiler }
|
|
1084
|
+
|
|
1036
1085
|
installVisionExactCheckClient(ctx)
|
|
1037
1086
|
|
|
1038
1087
|
try {
|
|
@@ -1083,7 +1132,7 @@ export function installBackgroundCapabilityProfiling(ctx, config, core, store, o
|
|
|
1083
1132
|
provider: body.provider,
|
|
1084
1133
|
model: body.model,
|
|
1085
1134
|
signal: AbortSignal.timeout(VISION_CHECK_TIMEOUT_MS),
|
|
1086
|
-
invokerOptions
|
|
1135
|
+
invokerOptions,
|
|
1087
1136
|
})
|
|
1088
1137
|
await profiler.recordImageSupported(body.provider, body.model)
|
|
1089
1138
|
sendJson(res, 200, result)
|
|
@@ -1,47 +1,47 @@
|
|
|
1
|
-
export const BACKGROUND_AUTH_STOP_TTL_MS = 10 * 60 * 1000
|
|
2
|
-
export const BACKGROUND_ENDPOINT_STOP_TTL_MS = 6 * 60 * 60 * 1000
|
|
3
|
-
export const BACKGROUND_TRANSIENT_RETRY_MS = 30 * 60 * 1000
|
|
4
|
-
|
|
1
|
+
export const BACKGROUND_AUTH_STOP_TTL_MS = 10 * 60 * 1000;
|
|
2
|
+
export const BACKGROUND_ENDPOINT_STOP_TTL_MS = 6 * 60 * 60 * 1000;
|
|
3
|
+
export const BACKGROUND_TRANSIENT_RETRY_MS = 30 * 60 * 1000;
|
|
5
4
|
const DEFAULT_POLICY = Object.freeze({
|
|
6
|
-
retryable: true,
|
|
7
|
-
persist: false,
|
|
8
|
-
retryAfterMs: undefined,
|
|
9
|
-
ttlMs: undefined,
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
const POLICIES = Object.freeze({
|
|
13
|
-
auth: Object.freeze({
|
|
14
|
-
retryable: false,
|
|
15
|
-
persist: false,
|
|
16
|
-
ttlMs: BACKGROUND_AUTH_STOP_TTL_MS,
|
|
17
|
-
}),
|
|
18
|
-
protocol: Object.freeze({
|
|
19
|
-
retryable: false,
|
|
20
|
-
persist: true,
|
|
21
|
-
ttlMs: BACKGROUND_ENDPOINT_STOP_TTL_MS,
|
|
22
|
-
}),
|
|
23
|
-
unavailable: Object.freeze({
|
|
24
|
-
retryable: false,
|
|
25
|
-
persist: true,
|
|
26
|
-
ttlMs: BACKGROUND_ENDPOINT_STOP_TTL_MS,
|
|
27
|
-
}),
|
|
28
|
-
'unsupported-image': Object.freeze({
|
|
29
|
-
retryable: false,
|
|
30
|
-
persist: false,
|
|
31
|
-
}),
|
|
32
|
-
'visual-proof': Object.freeze({
|
|
33
5
|
retryable: true,
|
|
34
6
|
persist: false,
|
|
35
|
-
retryAfterMs:
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
7
|
+
retryAfterMs: undefined,
|
|
8
|
+
ttlMs: undefined,
|
|
9
|
+
});
|
|
10
|
+
const POLICIES = Object.freeze({
|
|
11
|
+
auth: Object.freeze({
|
|
12
|
+
retryable: false,
|
|
13
|
+
persist: false,
|
|
14
|
+
ttlMs: BACKGROUND_AUTH_STOP_TTL_MS,
|
|
15
|
+
}),
|
|
16
|
+
protocol: Object.freeze({
|
|
17
|
+
retryable: false,
|
|
18
|
+
persist: true,
|
|
19
|
+
ttlMs: BACKGROUND_ENDPOINT_STOP_TTL_MS,
|
|
20
|
+
}),
|
|
21
|
+
unavailable: Object.freeze({
|
|
22
|
+
retryable: false,
|
|
23
|
+
persist: true,
|
|
24
|
+
ttlMs: BACKGROUND_ENDPOINT_STOP_TTL_MS,
|
|
25
|
+
}),
|
|
26
|
+
'unsupported-image': Object.freeze({
|
|
27
|
+
retryable: false,
|
|
28
|
+
persist: false,
|
|
29
|
+
}),
|
|
30
|
+
'visual-proof': Object.freeze({
|
|
31
|
+
retryable: true,
|
|
32
|
+
persist: false,
|
|
33
|
+
retryAfterMs: BACKGROUND_TRANSIENT_RETRY_MS,
|
|
34
|
+
}),
|
|
35
|
+
infrastructure: Object.freeze({
|
|
36
|
+
retryable: true,
|
|
37
|
+
persist: false,
|
|
38
|
+
retryAfterMs: BACKGROUND_TRANSIENT_RETRY_MS,
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
44
41
|
/** Failure lifetime is separate from failure classification. */
|
|
45
42
|
export function backgroundFailurePolicy(errorClass) {
|
|
46
|
-
|
|
43
|
+
const key = String(errorClass ?? '');
|
|
44
|
+
return Object.prototype.hasOwnProperty.call(POLICIES, key)
|
|
45
|
+
? POLICIES[key]
|
|
46
|
+
: DEFAULT_POLICY;
|
|
47
47
|
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { BENCHMARK_AXES, } from './vision-capability-evidence.js';
|
|
6
|
+
import { CAPABILITY_BENCHMARK_SUITE_REVISION } from './vision-capability-fingerprint.js';
|
|
7
|
+
const CACHE_VERSION = 3;
|
|
8
|
+
const MAX_ENTRIES = 256;
|
|
9
|
+
function propertyBag(value) {
|
|
10
|
+
return value !== null && typeof value === 'object'
|
|
11
|
+
? value
|
|
12
|
+
: undefined;
|
|
13
|
+
}
|
|
14
|
+
function cleanText(value, max = 256) {
|
|
15
|
+
return typeof value === 'string' && value.trim() !== ''
|
|
16
|
+
? value.trim().slice(0, max)
|
|
17
|
+
: '';
|
|
18
|
+
}
|
|
19
|
+
function benchmarkAxis(value) {
|
|
20
|
+
return BENCHMARK_AXES.includes(value)
|
|
21
|
+
? value
|
|
22
|
+
: undefined;
|
|
23
|
+
}
|
|
24
|
+
function persistentFailureClass(value) {
|
|
25
|
+
return value === 'unavailable' || value === 'protocol'
|
|
26
|
+
? value
|
|
27
|
+
: undefined;
|
|
28
|
+
}
|
|
29
|
+
function cleanStop(value) {
|
|
30
|
+
const record = propertyBag(value);
|
|
31
|
+
if (record === undefined)
|
|
32
|
+
return undefined;
|
|
33
|
+
if (Number(record.suiteRevision) !== CAPABILITY_BENCHMARK_SUITE_REVISION) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
const fingerprint = cleanText(record.fingerprint, 64);
|
|
37
|
+
if (!/^ep2_[0-9a-f]{32}$/.test(fingerprint))
|
|
38
|
+
return undefined;
|
|
39
|
+
const key = cleanText(record.key);
|
|
40
|
+
const provider = cleanText(record.provider);
|
|
41
|
+
const model = cleanText(record.model);
|
|
42
|
+
const axis = benchmarkAxis(cleanText(record.axis, 32));
|
|
43
|
+
const errorClass = persistentFailureClass(cleanText(record.errorClass, 48));
|
|
44
|
+
const errorCode = cleanText(record.errorCode, 96);
|
|
45
|
+
const recordedAt = Number(record.recordedAt);
|
|
46
|
+
const expiresAt = Number(record.expiresAt);
|
|
47
|
+
if (!key || !provider || !model || axis === undefined || errorClass === undefined) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
if (!Number.isFinite(recordedAt) || recordedAt <= 0)
|
|
51
|
+
return undefined;
|
|
52
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= recordedAt)
|
|
53
|
+
return undefined;
|
|
54
|
+
return {
|
|
55
|
+
fingerprint,
|
|
56
|
+
key,
|
|
57
|
+
provider,
|
|
58
|
+
model,
|
|
59
|
+
axis,
|
|
60
|
+
errorClass,
|
|
61
|
+
...(errorCode ? { errorCode } : {}),
|
|
62
|
+
recordedAt,
|
|
63
|
+
expiresAt,
|
|
64
|
+
suiteRevision: CAPABILITY_BENCHMARK_SUITE_REVISION,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function recordKey(value) {
|
|
68
|
+
return `${value.fingerprint}\u0000${value.axis}`;
|
|
69
|
+
}
|
|
70
|
+
function selectRetainedStops(values, at, maxEntries = MAX_ENTRIES) {
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
return [...values]
|
|
73
|
+
.map(cleanStop)
|
|
74
|
+
.filter((item) => item !== undefined && item.expiresAt > at)
|
|
75
|
+
.sort((a, b) => b.recordedAt - a.recordedAt
|
|
76
|
+
|| recordKey(a).localeCompare(recordKey(b)))
|
|
77
|
+
.filter((item) => {
|
|
78
|
+
const key = recordKey(item);
|
|
79
|
+
if (seen.has(key))
|
|
80
|
+
return false;
|
|
81
|
+
seen.add(key);
|
|
82
|
+
return true;
|
|
83
|
+
})
|
|
84
|
+
.slice(0, maxEntries);
|
|
85
|
+
}
|
|
86
|
+
function retainedStopMap(values, at, maxEntries = MAX_ENTRIES) {
|
|
87
|
+
return new Map(selectRetainedStops(values, at, maxEntries)
|
|
88
|
+
.map((item) => [recordKey(item), item]));
|
|
89
|
+
}
|
|
90
|
+
async function save(file, records, fsOps, at) {
|
|
91
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
92
|
+
const stops = selectRetainedStops(records.values(), at);
|
|
93
|
+
await fsOps.mkdir(path.dirname(file), { recursive: true });
|
|
94
|
+
await fsOps.writeFile(temporary, JSON.stringify({ version: CACHE_VERSION, stops }), { encoding: 'utf8', mode: 0o600 });
|
|
95
|
+
await fsOps.rename(temporary, file);
|
|
96
|
+
}
|
|
97
|
+
async function load(file, fsOps, at) {
|
|
98
|
+
try {
|
|
99
|
+
const raw = await fsOps.readFile(file, 'utf8');
|
|
100
|
+
const body = propertyBag(JSON.parse(raw));
|
|
101
|
+
const stops = body?.stops;
|
|
102
|
+
const version = Number(body?.version);
|
|
103
|
+
if (!Array.isArray(stops)
|
|
104
|
+
|| (version !== 2 && version !== CACHE_VERSION)) {
|
|
105
|
+
return new Map();
|
|
106
|
+
}
|
|
107
|
+
const records = retainedStopMap(stops, at);
|
|
108
|
+
if (version === 2)
|
|
109
|
+
await save(file, records, fsOps, at);
|
|
110
|
+
return records;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (propertyBag(error)?.code === 'ENOENT')
|
|
114
|
+
return new Map();
|
|
115
|
+
return new Map();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Durable stop owner for unattended capability measurement.
|
|
120
|
+
*
|
|
121
|
+
* Only endpoint/model evidence that is benchmark-axis-scoped and safe to
|
|
122
|
+
* persist (protocol or unavailable) can enter this store. AUTH remains a
|
|
123
|
+
* process-local access condition and is rejected by runtime validation even
|
|
124
|
+
* when legacy v2 cache data contains it.
|
|
125
|
+
*/
|
|
126
|
+
export function createBackgroundBenchmarkStopStoreCore(options) {
|
|
127
|
+
const file = options.file;
|
|
128
|
+
const fsOps = {
|
|
129
|
+
readFile: options.fsOps?.readFile ?? readFile,
|
|
130
|
+
mkdir: options.fsOps?.mkdir ?? mkdir,
|
|
131
|
+
writeFile: options.fsOps?.writeFile ?? writeFile,
|
|
132
|
+
rename: options.fsOps?.rename ?? rename,
|
|
133
|
+
};
|
|
134
|
+
const logger = options.logger;
|
|
135
|
+
const now = typeof options.now === 'function' ? options.now : Date.now;
|
|
136
|
+
let records = new Map();
|
|
137
|
+
let saveTail = Promise.resolve();
|
|
138
|
+
const ready = load(file, fsOps, Number(now()))
|
|
139
|
+
.then((loaded) => {
|
|
140
|
+
records = loaded;
|
|
141
|
+
});
|
|
142
|
+
const persist = () => {
|
|
143
|
+
const at = Number(now());
|
|
144
|
+
saveTail = saveTail
|
|
145
|
+
.then(() => save(file, records, fsOps, at))
|
|
146
|
+
.catch((error) => {
|
|
147
|
+
logger?.warn?.('vision-router: background stop cache write failed: %s', cleanText(propertyBag(error)?.message ?? error, 240));
|
|
148
|
+
});
|
|
149
|
+
return saveTail;
|
|
150
|
+
};
|
|
151
|
+
const reconcile = () => {
|
|
152
|
+
const at = Number(now());
|
|
153
|
+
const retained = retainedStopMap(records.values(), at);
|
|
154
|
+
const changed = retained.size !== records.size
|
|
155
|
+
|| [...retained.keys()].some((key) => !records.has(key));
|
|
156
|
+
records = retained;
|
|
157
|
+
return changed;
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
file,
|
|
161
|
+
async list() {
|
|
162
|
+
await ready;
|
|
163
|
+
if (reconcile())
|
|
164
|
+
await persist();
|
|
165
|
+
return [...records.values()]
|
|
166
|
+
.sort((a, b) => b.recordedAt - a.recordedAt);
|
|
167
|
+
},
|
|
168
|
+
async mark({ fingerprint, key, provider, model, axis, errorClass, errorCode, recordedAt = Number(now()), expiresAt, } = {}) {
|
|
169
|
+
await ready;
|
|
170
|
+
reconcile();
|
|
171
|
+
const clean = cleanStop({
|
|
172
|
+
fingerprint,
|
|
173
|
+
key,
|
|
174
|
+
provider,
|
|
175
|
+
model,
|
|
176
|
+
axis,
|
|
177
|
+
errorClass,
|
|
178
|
+
errorCode,
|
|
179
|
+
recordedAt,
|
|
180
|
+
expiresAt,
|
|
181
|
+
suiteRevision: CAPABILITY_BENCHMARK_SUITE_REVISION,
|
|
182
|
+
});
|
|
183
|
+
if (clean === undefined)
|
|
184
|
+
return undefined;
|
|
185
|
+
const keyOfStop = recordKey(clean);
|
|
186
|
+
records.set(keyOfStop, clean);
|
|
187
|
+
reconcile();
|
|
188
|
+
await persist();
|
|
189
|
+
return records.get(keyOfStop);
|
|
190
|
+
},
|
|
191
|
+
async clearStop(fingerprint, axis) {
|
|
192
|
+
await ready;
|
|
193
|
+
const removed = records.delete(`${String(fingerprint ?? '')}\u0000${String(axis ?? '')}`);
|
|
194
|
+
if (removed)
|
|
195
|
+
await persist();
|
|
196
|
+
return removed;
|
|
197
|
+
},
|
|
198
|
+
async clearFingerprint(fingerprint) {
|
|
199
|
+
await ready;
|
|
200
|
+
const wanted = String(fingerprint ?? '');
|
|
201
|
+
let removed = false;
|
|
202
|
+
for (const [key, record] of records.entries()) {
|
|
203
|
+
if (record.fingerprint !== wanted)
|
|
204
|
+
continue;
|
|
205
|
+
records.delete(key);
|
|
206
|
+
removed = true;
|
|
207
|
+
}
|
|
208
|
+
if (removed)
|
|
209
|
+
await persist();
|
|
210
|
+
return removed;
|
|
211
|
+
},
|
|
212
|
+
async flush() {
|
|
213
|
+
await ready;
|
|
214
|
+
await saveTail;
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -1,190 +1,19 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
3
1
|
import path from 'node:path'
|
|
4
|
-
import process from 'node:process'
|
|
5
2
|
import { resolveDshHome } from './doctor.js'
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
// v1 stops had no expiry; v2 AUTH stops also persisted a secret-derived
|
|
9
|
-
// credential fingerprint. Do not migrate either format: v3 persists only
|
|
10
|
-
// non-secret endpoint/model stop evidence.
|
|
11
|
-
const CACHE_VERSION = 3
|
|
12
|
-
const MAX_ENTRIES = 256
|
|
13
|
-
const AXES = new Set(['ocr', 'general', 'document', 'structured', 'grounding'])
|
|
14
|
-
const CLASSES = new Set(['unavailable', 'protocol'])
|
|
15
|
-
|
|
16
|
-
function cleanText(value, max = 256) {
|
|
17
|
-
return typeof value === 'string' && value.trim() !== '' ? value.trim().slice(0, max) : ''
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function cleanStop(value) {
|
|
21
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
22
|
-
if (Number(value.suiteRevision) !== CAPABILITY_BENCHMARK_SUITE_REVISION) return undefined
|
|
23
|
-
const fingerprint = cleanText(value.fingerprint, 64)
|
|
24
|
-
if (!/^ep2_[0-9a-f]{32}$/.test(fingerprint)) return undefined
|
|
25
|
-
const key = cleanText(value.key)
|
|
26
|
-
const provider = cleanText(value.provider)
|
|
27
|
-
const model = cleanText(value.model)
|
|
28
|
-
const axis = cleanText(value.axis, 32)
|
|
29
|
-
const errorClass = cleanText(value.errorClass, 48)
|
|
30
|
-
const errorCode = cleanText(value.errorCode, 96)
|
|
31
|
-
const recordedAt = Number(value.recordedAt)
|
|
32
|
-
const expiresAt = Number(value.expiresAt)
|
|
33
|
-
if (!key || !provider || !model || !AXES.has(axis) || !CLASSES.has(errorClass)) return undefined
|
|
34
|
-
if (!Number.isFinite(recordedAt) || recordedAt <= 0) return undefined
|
|
35
|
-
if (!Number.isFinite(expiresAt) || expiresAt <= recordedAt) return undefined
|
|
36
|
-
return {
|
|
37
|
-
fingerprint,
|
|
38
|
-
key,
|
|
39
|
-
provider,
|
|
40
|
-
model,
|
|
41
|
-
axis,
|
|
42
|
-
errorClass,
|
|
43
|
-
...(errorCode ? { errorCode } : {}),
|
|
44
|
-
recordedAt,
|
|
45
|
-
expiresAt,
|
|
46
|
-
suiteRevision: CAPABILITY_BENCHMARK_SUITE_REVISION,
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function recordKey(value) {
|
|
51
|
-
return `${value.fingerprint}\u0000${value.axis}`
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function selectRetainedStops(values, at, maxEntries = MAX_ENTRIES) {
|
|
55
|
-
const seen = new Set()
|
|
56
|
-
return [...values]
|
|
57
|
-
.map(cleanStop)
|
|
58
|
-
.filter((item) => item && item.expiresAt > at)
|
|
59
|
-
.sort((a, b) => b.recordedAt - a.recordedAt || recordKey(a).localeCompare(recordKey(b)))
|
|
60
|
-
.filter((item) => {
|
|
61
|
-
const key = recordKey(item)
|
|
62
|
-
if (seen.has(key)) return false
|
|
63
|
-
seen.add(key)
|
|
64
|
-
return true
|
|
65
|
-
})
|
|
66
|
-
.slice(0, maxEntries)
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function retainedStopMap(values, at, maxEntries = MAX_ENTRIES) {
|
|
70
|
-
return new Map(selectRetainedStops(values, at, maxEntries).map((item) => [recordKey(item), item]))
|
|
71
|
-
}
|
|
3
|
+
import { createBackgroundBenchmarkStopStoreCore } from './vision-background-stop-store-core.js'
|
|
72
4
|
|
|
73
5
|
export function backgroundBenchmarkStopCachePath(dshHome = resolveDshHome()) {
|
|
74
6
|
return path.join(dshHome, 'cache', 'vision-router', 'background-benchmark-stops.json')
|
|
75
7
|
}
|
|
76
8
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (!body || !Array.isArray(body.stops) || ![2, CACHE_VERSION].includes(body.version)) return new Map()
|
|
82
|
-
const records = retainedStopMap(body.stops, at)
|
|
83
|
-
if (body.version === 2) await save(file, records, fsOps, at)
|
|
84
|
-
return records
|
|
85
|
-
} catch (error) {
|
|
86
|
-
if (error?.code === 'ENOENT') return new Map()
|
|
87
|
-
return new Map()
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async function save(file, records, fsOps, at) {
|
|
92
|
-
const temporary = `${file}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`
|
|
93
|
-
const stops = selectRetainedStops(records.values(), at)
|
|
94
|
-
await fsOps.mkdir(path.dirname(file), { recursive: true })
|
|
95
|
-
await fsOps.writeFile(temporary, JSON.stringify({ version: CACHE_VERSION, stops }), { encoding: 'utf8', mode: 0o600 })
|
|
96
|
-
await fsOps.rename(temporary, file)
|
|
97
|
-
}
|
|
98
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Keep DSH-home discovery at the dynamic Host/path boundary. The typed core
|
|
11
|
+
* owns durable stop validation, migration, retention and persistence.
|
|
12
|
+
*/
|
|
99
13
|
export function createBackgroundBenchmarkStopStore(options = {}) {
|
|
100
14
|
const file = options.cacheFile ?? backgroundBenchmarkStopCachePath(options.dshHome)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
mkdir: options.fsOps?.mkdir ?? mkdir,
|
|
104
|
-
writeFile: options.fsOps?.writeFile ?? writeFile,
|
|
105
|
-
rename: options.fsOps?.rename ?? rename,
|
|
106
|
-
}
|
|
107
|
-
const logger = options.logger
|
|
108
|
-
const now = typeof options.now === 'function' ? options.now : Date.now
|
|
109
|
-
let records = new Map()
|
|
110
|
-
let saveTail = Promise.resolve()
|
|
111
|
-
const ready = load(file, fsOps, Number(now())).then((loaded) => { records = loaded })
|
|
112
|
-
|
|
113
|
-
const persist = () => {
|
|
114
|
-
const at = Number(now())
|
|
115
|
-
saveTail = saveTail
|
|
116
|
-
.then(() => save(file, records, fsOps, at))
|
|
117
|
-
.catch((error) => logger?.warn?.('vision-router: background stop cache write failed: %s', cleanText(error?.message ?? error, 240)))
|
|
118
|
-
return saveTail
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const reconcile = () => {
|
|
122
|
-
const at = Number(now())
|
|
123
|
-
const retained = retainedStopMap(records.values(), at)
|
|
124
|
-
const changed = retained.size !== records.size || [...retained.keys()].some((key) => !records.has(key))
|
|
125
|
-
records = retained
|
|
126
|
-
return changed
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return {
|
|
15
|
+
return createBackgroundBenchmarkStopStoreCore({
|
|
16
|
+
...options,
|
|
130
17
|
file,
|
|
131
|
-
|
|
132
|
-
await ready
|
|
133
|
-
if (reconcile()) await persist()
|
|
134
|
-
return [...records.values()].sort((a, b) => b.recordedAt - a.recordedAt)
|
|
135
|
-
},
|
|
136
|
-
async mark({
|
|
137
|
-
fingerprint,
|
|
138
|
-
key,
|
|
139
|
-
provider,
|
|
140
|
-
model,
|
|
141
|
-
axis,
|
|
142
|
-
errorClass,
|
|
143
|
-
errorCode,
|
|
144
|
-
recordedAt = Number(now()),
|
|
145
|
-
expiresAt,
|
|
146
|
-
} = {}) {
|
|
147
|
-
await ready
|
|
148
|
-
reconcile()
|
|
149
|
-
const clean = cleanStop({
|
|
150
|
-
fingerprint,
|
|
151
|
-
key,
|
|
152
|
-
provider,
|
|
153
|
-
model,
|
|
154
|
-
axis,
|
|
155
|
-
errorClass,
|
|
156
|
-
errorCode,
|
|
157
|
-
recordedAt,
|
|
158
|
-
expiresAt,
|
|
159
|
-
suiteRevision: CAPABILITY_BENCHMARK_SUITE_REVISION,
|
|
160
|
-
})
|
|
161
|
-
if (!clean) return undefined
|
|
162
|
-
records.set(recordKey(clean), clean)
|
|
163
|
-
reconcile()
|
|
164
|
-
await persist()
|
|
165
|
-
return records.get(recordKey(clean))
|
|
166
|
-
},
|
|
167
|
-
async clearStop(fingerprint, axis) {
|
|
168
|
-
await ready
|
|
169
|
-
const removed = records.delete(`${String(fingerprint ?? '')}\u0000${String(axis ?? '')}`)
|
|
170
|
-
if (removed) await persist()
|
|
171
|
-
return removed
|
|
172
|
-
},
|
|
173
|
-
async clearFingerprint(fingerprint) {
|
|
174
|
-
await ready
|
|
175
|
-
const wanted = String(fingerprint ?? '')
|
|
176
|
-
let removed = false
|
|
177
|
-
for (const [key, record] of records.entries()) {
|
|
178
|
-
if (record.fingerprint !== wanted) continue
|
|
179
|
-
records.delete(key)
|
|
180
|
-
removed = true
|
|
181
|
-
}
|
|
182
|
-
if (removed) await persist()
|
|
183
|
-
return removed
|
|
184
|
-
},
|
|
185
|
-
async flush() {
|
|
186
|
-
await ready
|
|
187
|
-
await saveTail
|
|
188
|
-
},
|
|
189
|
-
}
|
|
18
|
+
})
|
|
190
19
|
}
|