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.
Files changed (105) hide show
  1. package/README.md +18 -3
  2. package/README.zh.md +16 -3
  3. package/docs/architecture/3.0-architecture-plan.md +252 -0
  4. package/docs/architecture/3.0-r0-contract-ledger.md +137 -0
  5. package/docs/architecture/3.0-r1-source-artifact-boundary.md +98 -0
  6. package/docs/architecture/3.0-r10-final-correctness-closure.md +207 -0
  7. package/docs/architecture/3.0-r11-adversarial-review.md +447 -0
  8. package/docs/architecture/3.0-r5-browser-go-no-go.md +165 -0
  9. package/docs/architecture/3.0-r5-browser-typescript-gate.md +105 -0
  10. package/docs/architecture/3.0-r6-compat-retirement.md +203 -0
  11. package/docs/architecture/3.0-r7-public-api.md +151 -0
  12. package/docs/architecture/3.0-r8-ci-release.md +248 -0
  13. package/docs/architecture/3.0-r9-final-audit.md +370 -0
  14. package/docs/architecture/dsh-support-window.md +13 -3
  15. package/docs/releases/v3.0.0.md +85 -0
  16. package/docs/releases/v3.0.1.md +77 -0
  17. package/entry.js +1 -1
  18. package/index.js +110 -71
  19. package/lib/agent-request-route-authority.js +1 -20
  20. package/lib/core-apply-lifecycle.js +37 -43
  21. package/lib/core-cache.js +87 -0
  22. package/lib/core-primitives.js +11 -144
  23. package/lib/core-vision-surface-domain.js +63 -0
  24. package/lib/core-vision-surface.js +11 -77
  25. package/lib/delegated-call-config.js +15 -24
  26. package/lib/doctor-cli.js +0 -0
  27. package/lib/doctor-runtime.js +4 -1
  28. package/lib/dsh-contract-compat.js +26 -230
  29. package/lib/dsh-host-capabilities.js +3 -9
  30. package/lib/dsh-settings-017-compat.js +17 -10
  31. package/lib/file-logger.js +8 -3
  32. package/lib/grounding-coordinate-runtime.js +1 -2
  33. package/lib/http-body-limit.js +97 -79
  34. package/lib/image-offload-compat.js +30 -21
  35. package/lib/image-resource-governor.js +210 -228
  36. package/lib/legacy-global-proxy-boundary.js +46 -3
  37. package/lib/live-model-client-prelude.js +12 -4
  38. package/lib/live-model-discovery.js +134 -43
  39. package/lib/local-vision-stabilizer.js +33 -18
  40. package/lib/maintenance-routes.js +1 -3
  41. package/lib/native-image-coexistence.js +15 -3
  42. package/lib/ollama-cold-start.js +87 -23
  43. package/lib/pi-ai-bridge-wire-compat.js +3 -2
  44. package/lib/proxy-dispatcher-pool.js +151 -92
  45. package/lib/proxy-routing.js +75 -51
  46. package/lib/proxy-url-compat.js +3 -10
  47. package/lib/public-config.js +23 -44
  48. package/lib/public-entry.d.ts +148 -0
  49. package/lib/public-entry.js +17 -4
  50. package/lib/replay-delegation.js +12 -7
  51. package/lib/replay-envelope-v2-compat.js +12 -7
  52. package/lib/runtime-composition.js +30 -10
  53. package/lib/screenshot-source-boundary.js +2 -2
  54. package/lib/session-affinity-runtime.js +113 -97
  55. package/lib/session-affinity.js +89 -76
  56. package/lib/session-event-history-compat.js +30 -0
  57. package/lib/session-query-readers.js +263 -0
  58. package/lib/session-surface-compat.js +30 -31
  59. package/lib/session-surface-policy-domain.js +85 -0
  60. package/lib/session-surface-policy.js +2 -111
  61. package/lib/session-turn-resolver.js +2 -6
  62. package/lib/session-vision-attachment-index.js +143 -0
  63. package/lib/session-vision-delegation-boundary.js +34 -0
  64. package/lib/session-vision-delegation.js +22 -0
  65. package/lib/session-vision-index.js +25 -107
  66. package/lib/session-vision-mode-authority.js +229 -192
  67. package/lib/session-vision-mode-boundary.js +65 -22
  68. package/lib/session-vision-policy-domain.js +304 -0
  69. package/lib/session-vision-policy.js +104 -0
  70. package/lib/session-vision-state.js +368 -378
  71. package/lib/settings-client-017-compat.js +16 -10
  72. package/lib/structured-flow-hardening.js +22 -4
  73. package/lib/tesseract-exec-compat.js +9 -1
  74. package/lib/turn-budget-context.js +47 -41
  75. package/lib/update-check.js +11 -2
  76. package/lib/vision-attachment-handle-runtime.js +176 -156
  77. package/lib/vision-backend-runtime-policy.js +16 -0
  78. package/lib/vision-background-benchmark.js +55 -6
  79. package/lib/vision-background-failure-policy.js +41 -41
  80. package/lib/vision-background-stop-store-core.js +217 -0
  81. package/lib/vision-background-stop-store.js +8 -179
  82. package/lib/vision-capability-benchmark.js +11 -61
  83. package/lib/vision-capability-evidence.js +35 -0
  84. package/lib/vision-capability-fingerprint.js +72 -0
  85. package/lib/vision-capability-identity.js +116 -61
  86. package/lib/vision-capability-router.js +13 -33
  87. package/lib/vision-chunk-assembler.js +95 -0
  88. package/lib/vision-diagnostics-routes.js +42 -4
  89. package/lib/vision-execution-order-apply.js +45 -55
  90. package/lib/vision-execution-order.js +19 -19
  91. package/lib/vision-execution-policy.js +12 -7
  92. package/lib/vision-model-registry.js +30 -17
  93. package/lib/vision-model-visibility-boundary-main.js +15 -16
  94. package/lib/vision-provider-transport.js +137 -147
  95. package/lib/vision-routing-authority.js +47 -48
  96. package/lib/vision-routing-product.js +26 -29
  97. package/lib/vision-routing-settings-prelude.js +8 -3
  98. package/lib/vision-runtime-performance-store.js +166 -0
  99. package/lib/vision-runtime-performance.js +13 -142
  100. package/lib/vision-tool-runtime-boundary.js +99 -30
  101. package/lib/web/dsh-settings-017-root-transport.js +27 -12
  102. package/lib/web/remote-settings-client.js +4 -7
  103. package/lib/web-capability-boundary.js +17 -7
  104. package/lib/wrapper-directory.js +14 -23
  105. 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 tick = async () => {
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 ?? ((args) => runAxis({ ...args, options: invokerOptions }))
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
- // Cleanup registration is best-effort; the profiler remains process-scoped.
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: options.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: BACKGROUND_TRANSIENT_RETRY_MS,
36
- }),
37
- infrastructure: Object.freeze({
38
- retryable: true,
39
- persist: false,
40
- retryAfterMs: BACKGROUND_TRANSIENT_RETRY_MS,
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
- return POLICIES[String(errorClass ?? '')] ?? DEFAULT_POLICY
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 { CAPABILITY_BENCHMARK_SUITE_REVISION } from './vision-capability-benchmark.js'
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
- async function load(file, fsOps, at) {
78
- try {
79
- const raw = await fsOps.readFile(file, 'utf8')
80
- const body = JSON.parse(raw)
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
- const fsOps = {
102
- readFile: options.fsOps?.readFile ?? readFile,
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
- async list() {
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
  }