dsh-vision-router 2.0.0 → 2.1.0

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 (92) hide show
  1. package/README.md +33 -16
  2. package/README.zh.md +34 -17
  3. package/cordis.patch.yml +19 -9
  4. package/docs/architecture/2x-contract-ledger.md +165 -0
  5. package/docs/architecture/compat-inventory.md +121 -0
  6. package/docs/architecture/dsh-compatibility-matrix.md +57 -0
  7. package/docs/architecture/dsh-support-window.md +50 -0
  8. package/docs/architecture/final-closure-audit.md +148 -0
  9. package/docs/architecture/p2-jobs-feasibility.md +94 -0
  10. package/docs/architecture/p3-compat-retirement.md +46 -0
  11. package/docs/architecture/p3-host-native-seams.md +43 -0
  12. package/docs/architecture/p3-native-recovery-evaluation.md +65 -0
  13. package/docs/architecture/runtime-boundaries.md +124 -0
  14. package/docs/releases/v2.0.1.md +17 -0
  15. package/docs/releases/v2.1.0-support-window.md +29 -0
  16. package/docs/releases/v2.1.0.md +83 -0
  17. package/entry.js +13 -307
  18. package/index.js +85 -352
  19. package/lib/abort-signal-compat.js +191 -0
  20. package/lib/adapter-update-coalescer.js +113 -29
  21. package/lib/adversarial-hardening.js +24 -9
  22. package/lib/artifact-boundary.js +16 -175
  23. package/lib/artifact-io.js +220 -0
  24. package/lib/artifact-retention.js +305 -53
  25. package/lib/catalog-corrections.js +6 -1
  26. package/lib/client-host-compat-prelude.js +249 -0
  27. package/lib/client-presentation-boundary.js +61 -3
  28. package/lib/client.js +71 -72
  29. package/lib/core-vision-surface.js +124 -0
  30. package/lib/depth-guidance.js +114 -90
  31. package/lib/doctor-cli-p0.js +168 -0
  32. package/lib/doctor-cli.js +7 -0
  33. package/lib/doctor-vision-limits.js +72 -0
  34. package/lib/dsh-contract-compat.js +207 -39
  35. package/lib/dsh-host-capabilities.js +107 -0
  36. package/lib/dsh-support-window.js +48 -0
  37. package/lib/http-compat.js +22 -6
  38. package/lib/image-resource-governor.js +35 -10
  39. package/lib/legacy-core-vision-policy-bridge.js +25 -226
  40. package/lib/legacy-global-proxy-boundary.js +127 -0
  41. package/lib/live-model-client-prelude.js +0 -12
  42. package/lib/mixed-router.js +81 -81
  43. package/lib/public-entry.js +42 -4
  44. package/lib/runtime-composition.js +348 -0
  45. package/lib/runtime-i18n-boundary.js +683 -0
  46. package/lib/runtime-i18n-core-scope.js +56 -0
  47. package/lib/runtime-i18n-core.js +34 -0
  48. package/lib/runtime-i18n.js +121 -0
  49. package/lib/session-surface-policy.js +97 -0
  50. package/lib/session-vision-index.js +282 -0
  51. package/lib/session-vision-runtime.js +48 -0
  52. package/lib/settings-factory-lifecycle.js +144 -0
  53. package/lib/settings-ia-client-prelude.js +74 -2
  54. package/lib/settings-limit-client-prelude.js +110 -0
  55. package/lib/settings-native-card-layout.js +297 -0
  56. package/lib/settings-number-contract.js +29 -0
  57. package/lib/structured-flow-hardening.js +207 -76
  58. package/lib/tesseract-exec-compat.js +58 -32
  59. package/lib/update-check.js +17 -5
  60. package/lib/v2-settings-ia-integration.js +9 -4
  61. package/lib/vision-artifact-store.js +77 -0
  62. package/lib/vision-backend-runtime-policy.js +6 -2
  63. package/lib/vision-background-benchmark.js +130 -41
  64. package/lib/vision-background-failure-policy.js +70 -0
  65. package/lib/vision-background-stop-store.js +54 -9
  66. package/lib/vision-capability-benchmark-client.js +57 -66
  67. package/lib/vision-capability-benchmark-presentation.js +97 -0
  68. package/lib/vision-capability-benchmark-service.js +3 -3
  69. package/lib/vision-execution-order-apply.js +61 -0
  70. package/lib/vision-execution-order-plan.js +64 -0
  71. package/lib/vision-execution-order.js +38 -0
  72. package/lib/vision-limit-diagnostics.js +202 -0
  73. package/lib/vision-product-presentation.js +284 -0
  74. package/lib/vision-provider-transport.js +197 -0
  75. package/lib/vision-resilience.js +87 -45
  76. package/lib/vision-routing-evidence.js +366 -0
  77. package/lib/vision-routing-runtime.js +278 -0
  78. package/lib/vision-tool-runtime-boundary.js +27 -21
  79. package/lib/vision-turn-budget-client-prelude.js +9 -9
  80. package/lib/web/benchmark-panel.js +6 -0
  81. package/lib/web/diagnostics-panel.js +44 -0
  82. package/lib/web/index.js +60 -0
  83. package/lib/web/model-picker.js +22 -0
  84. package/lib/web/onboarding.js +6 -0
  85. package/lib/web/product-state.js +59 -0
  86. package/lib/web/remote-settings-client.js +8 -0
  87. package/lib/web/routing-section.js +6 -0
  88. package/lib/web/settings-controller.js +6 -0
  89. package/lib/web-capability-boundary.js +65 -9
  90. package/lib/windows-screenshot-dpi-compat.js +148 -0
  91. package/package.json +14 -6
  92. package/lib/vision-capability-shadow.js +0 -576
@@ -0,0 +1,191 @@
1
+ function timeoutReason(root) {
2
+ try {
3
+ const DomException = root?.DOMException
4
+ if (typeof DomException === 'function') {
5
+ return new DomException('The operation was aborted due to timeout', 'TimeoutError')
6
+ }
7
+ } catch {
8
+ // Fall through to an ordinary Error in runtimes without DOMException.
9
+ }
10
+ const error = new Error('The operation was aborted due to timeout')
11
+ error.name = 'TimeoutError'
12
+ return error
13
+ }
14
+
15
+ function defineStatic(target, name, value) {
16
+ try {
17
+ Object.defineProperty(target, name, {
18
+ value,
19
+ writable: true,
20
+ configurable: true,
21
+ })
22
+ } catch {
23
+ try { target[name] = value } catch {}
24
+ }
25
+ return typeof target?.[name] === 'function'
26
+ }
27
+
28
+ function isAbortSignalLike(value) {
29
+ return Boolean(
30
+ value &&
31
+ typeof value === 'object' &&
32
+ typeof value.aborted === 'boolean' &&
33
+ typeof value.addEventListener === 'function',
34
+ )
35
+ }
36
+
37
+ function signalList(signals) {
38
+ if (signals === undefined || signals === null || typeof signals[Symbol.iterator] !== 'function') {
39
+ throw new TypeError('AbortSignal.any: signals must be an iterable of AbortSignal values')
40
+ }
41
+ const list = Array.from(signals)
42
+ for (const signal of list) {
43
+ if (!isAbortSignalLike(signal)) {
44
+ throw new TypeError('AbortSignal.any: every input must be an AbortSignal')
45
+ }
46
+ }
47
+ return list
48
+ }
49
+
50
+ function timeoutDelay(delay) {
51
+ if (typeof delay !== 'number') {
52
+ throw new TypeError('AbortSignal.timeout: delay must be a number')
53
+ }
54
+ if (!Number.isInteger(delay) || delay < 0 || delay > 0xffff_ffff) {
55
+ throw new RangeError('AbortSignal.timeout: delay must be an integer between 0 and 4294967295')
56
+ }
57
+ return delay
58
+ }
59
+
60
+ function removeAbortListeners(listeners) {
61
+ for (const [signal, listener] of listeners) {
62
+ try { signal.removeEventListener?.('abort', listener) } catch {}
63
+ }
64
+ listeners.length = 0
65
+ }
66
+
67
+ function strongAbortRelay(controller, source, cleanup) {
68
+ return () => {
69
+ cleanup()
70
+ if (!controller.signal.aborted) controller.abort(source.reason)
71
+ }
72
+ }
73
+
74
+ function weakAbortRelay(outputRef, controllerBySignal, source, cleanup, finalizer, token) {
75
+ return () => {
76
+ const output = outputRef.deref()
77
+ cleanup()
78
+ try { finalizer.unregister(token) } catch {}
79
+ if (!output || output.aborted) return
80
+ const controller = controllerBySignal.get(output)
81
+ if (controller && !output.aborted) controller.abort(source.reason)
82
+ }
83
+ }
84
+
85
+ function weakAnySupport(root) {
86
+ const WeakRefCtor = root?.WeakRef
87
+ const FinalizationRegistryCtor = root?.FinalizationRegistry
88
+ const WeakMapCtor = root?.WeakMap
89
+ if (
90
+ typeof WeakRefCtor !== 'function' ||
91
+ typeof FinalizationRegistryCtor !== 'function' ||
92
+ typeof WeakMapCtor !== 'function'
93
+ ) return undefined
94
+ const controllerBySignal = new WeakMapCtor()
95
+ const finalizer = new FinalizationRegistryCtor((cleanup) => {
96
+ try { cleanup?.() } catch {}
97
+ })
98
+ return { WeakRefCtor, controllerBySignal, finalizer }
99
+ }
100
+
101
+ /**
102
+ * DSH normally runs on Node versions that already provide AbortSignal.any()
103
+ * and AbortSignal.timeout(). A few supported host/browser bridges, however,
104
+ * can surface an AbortSignal constructor without those static helpers. The
105
+ * plugin has several cancellation boundaries that legitimately combine more
106
+ * than one signal, so install small standards-shaped fallbacks once at the
107
+ * public package boundary rather than sprinkling version checks everywhere.
108
+ *
109
+ * This is intentionally a process-global, missing-only compatibility seam:
110
+ * native functions are never replaced, and ESM module evaluation installs the
111
+ * fallback once for all Vision Router contexts in the process. That avoids a
112
+ * teardown race where unloading one profile could remove helpers still used by
113
+ * another live profile. When WeakRef/FinalizationRegistry are available, the
114
+ * `any()` fallback also removes source listeners after an unused composite is
115
+ * collected instead of pinning long-lived Host lifecycle signals forever.
116
+ */
117
+ export function installAbortSignalCompat(root = globalThis) {
118
+ const Signal = root?.AbortSignal
119
+ const Controller = root?.AbortController
120
+ const state = {
121
+ any: typeof Signal?.any === 'function',
122
+ timeout: typeof Signal?.timeout === 'function',
123
+ }
124
+ if (typeof Signal !== 'function' || typeof Controller !== 'function') return state
125
+
126
+ if (!state.any) {
127
+ const weakSupport = weakAnySupport(root)
128
+ const any = function any(signals) {
129
+ const list = signalList(signals)
130
+ const controller = new Controller()
131
+ const output = controller.signal
132
+ for (const signal of list) {
133
+ if (!signal.aborted) continue
134
+ controller.abort(signal.reason)
135
+ return output
136
+ }
137
+
138
+ const listeners = []
139
+ const cleanup = () => removeAbortListeners(listeners)
140
+ let outputRef
141
+ let finalizerToken
142
+ if (weakSupport) {
143
+ weakSupport.controllerBySignal.set(output, controller)
144
+ outputRef = new weakSupport.WeakRefCtor(output)
145
+ finalizerToken = {}
146
+ }
147
+
148
+ try {
149
+ for (const signal of list) {
150
+ const listener = outputRef
151
+ ? weakAbortRelay(
152
+ outputRef,
153
+ weakSupport.controllerBySignal,
154
+ signal,
155
+ cleanup,
156
+ weakSupport.finalizer,
157
+ finalizerToken,
158
+ )
159
+ : strongAbortRelay(controller, signal, cleanup)
160
+ listeners.push([signal, listener])
161
+ signal.addEventListener('abort', listener, { once: true })
162
+ }
163
+ if (outputRef) weakSupport.finalizer.register(output, cleanup, finalizerToken)
164
+ } catch (error) {
165
+ cleanup()
166
+ if (finalizerToken) {
167
+ try { weakSupport.finalizer.unregister(finalizerToken) } catch {}
168
+ }
169
+ throw error
170
+ }
171
+ return output
172
+ }
173
+ state.any = defineStatic(Signal, 'any', any)
174
+ }
175
+
176
+ if (!state.timeout) {
177
+ const timeout = function timeout(delay) {
178
+ const parsed = timeoutDelay(delay)
179
+ const controller = new Controller()
180
+ const schedule = typeof root?.setTimeout === 'function' ? root.setTimeout.bind(root) : setTimeout
181
+ const timer = schedule(() => controller.abort(timeoutReason(root)), parsed)
182
+ try { timer?.unref?.() } catch {}
183
+ return controller.signal
184
+ }
185
+ state.timeout = defineStatic(Signal, 'timeout', timeout)
186
+ }
187
+
188
+ return state
189
+ }
190
+
191
+ export const abortSignalCompat = installAbortSignalCompat()
@@ -1,24 +1,24 @@
1
1
  const wrappedContexts = new WeakMap()
2
2
  const wrappedLlmServices = new WeakMap()
3
3
  const preparedAdapterCompat = new WeakMap()
4
+ const fullAdapterContractCompat = new WeakMap()
4
5
 
5
6
  export const DEFAULT_ADAPTER_RECONCILE_MAX_PASSES = 32
6
7
 
7
- /**
8
- * DSH 0.1.1 dispatches every model call through adapter.prepareCall(). Older
9
- * Vision Router adapters are intentionally duck-typed objects and therefore do
10
- * not inherit LlmAdapter's default implementation. Normalize only adapters
11
- * registered through Vision Router's wrapped context so host/foreign adapters
12
- * keep their native identity and behavior.
13
- */
14
- export function ensureAdapterPrepareCall(adapter) {
15
- if (!adapter || (typeof adapter !== 'object' && typeof adapter !== 'function')) return adapter
16
- if (typeof adapter.prepareCall === 'function') return adapter
8
+ function isAdapter(value) {
9
+ return value !== null && (typeof value === 'object' || typeof value === 'function')
10
+ }
17
11
 
18
- const cached = preparedAdapterCompat.get(adapter)
19
- if (cached) return cached
12
+ function hasFunction(adapter, property) {
13
+ try {
14
+ return typeof Reflect.get(adapter, property, adapter) === 'function'
15
+ } catch {
16
+ return false
17
+ }
18
+ }
20
19
 
21
- const prepareCall = async (provider, model, signal) => {
20
+ function createPrepareCallFallback(adapter) {
21
+ return async (provider, model, signal) => {
22
22
  const fallbackModel = { provider, id: model, name: model }
23
23
  const resolved =
24
24
  typeof adapter.resolveModel === 'function'
@@ -31,44 +31,127 @@ export function ensureAdapterPrepareCall(adapter) {
31
31
  },
32
32
  }
33
33
  }
34
+ }
34
35
 
35
- // Preserve the exact adapter object whenever possible. Some runtime paths
36
- // retain registration.adapter and compare/reuse it later, so mutation is less
37
- // surprising than substituting a wrapper. Frozen/sealed adapters fall back to
38
- // a proxy that exposes only the missing contract method.
39
- try {
40
- Object.defineProperty(adapter, 'prepareCall', {
36
+ function noImageRequestPricing() {
37
+ return undefined
38
+ }
39
+
40
+ /**
41
+ * Normalize Vision Router-owned duck-typed adapters to the Host adapter
42
+ * contracts that have base-class defaults in newer DSH releases.
43
+ *
44
+ * DSH 0.1.1 started dispatching through adapter.prepareCall(). DSH 0.1.2 adds
45
+ * adapter.imageRequestPricing() and the token meter resolves it synchronously
46
+ * on every measurement. Vision Router intentionally registers plain adapter
47
+ * objects, so neither inherited default exists unless we materialize it here.
48
+ *
49
+ * Request-image pricing is deliberately NOT delegated to some other adapter.
50
+ * Wrapper/twin/chain routes can rewrite, replace, or aggregate image input, so
51
+ * only that exact route can truthfully declare its projection. `undefined` is
52
+ * LlmAdapter's default and keeps the Host's provider-neutral heuristic. An
53
+ * adapter that explicitly implements imageRequestPricing remains authoritative.
54
+ *
55
+ * The compatibility method is installed non-enumerably on extensible adapters
56
+ * to preserve registration identity. Frozen/sealed adapters fall back to one
57
+ * proxy that supplies all missing methods while binding existing methods back
58
+ * to the original object (important for class/private-field adapters).
59
+ */
60
+ export function ensureAdapterContracts(adapter, options = {}) {
61
+ if (!isAdapter(adapter)) return adapter
62
+
63
+ const includeImageRequestPricing = options.imageRequestPricing === true
64
+ const needsPrepareCall = !hasFunction(adapter, 'prepareCall')
65
+ const needsImageRequestPricing =
66
+ includeImageRequestPricing && !hasFunction(adapter, 'imageRequestPricing')
67
+ if (!needsPrepareCall && !needsImageRequestPricing) return adapter
68
+
69
+ const cache = includeImageRequestPricing ? fullAdapterContractCompat : preparedAdapterCompat
70
+ const cached = cache.get(adapter)
71
+ if (cached) return cached
72
+
73
+ const prepareCall = needsPrepareCall ? createPrepareCallFallback(adapter) : undefined
74
+ const imageRequestPricing = needsImageRequestPricing ? noImageRequestPricing : undefined
75
+ const descriptors = {}
76
+ if (prepareCall !== undefined) {
77
+ descriptors.prepareCall = {
41
78
  configurable: true,
42
79
  writable: true,
43
80
  value: prepareCall,
44
- })
45
- preparedAdapterCompat.set(adapter, adapter)
81
+ }
82
+ }
83
+ if (imageRequestPricing !== undefined) {
84
+ descriptors.imageRequestPricing = {
85
+ configurable: true,
86
+ writable: true,
87
+ value: imageRequestPricing,
88
+ }
89
+ }
90
+
91
+ // Preserve the exact adapter object whenever possible. Some runtime paths
92
+ // retain registration.adapter and compare/reuse it later, so mutation is less
93
+ // surprising than substituting a wrapper.
94
+ try {
95
+ Object.defineProperties(adapter, descriptors)
96
+ cache.set(adapter, adapter)
46
97
  return adapter
47
98
  } catch {
99
+ const boundFunctions = new Map()
48
100
  const wrapped = new Proxy(adapter, {
49
101
  get(target, property) {
50
- if (property === 'prepareCall') return prepareCall
102
+ if (property === 'prepareCall' && prepareCall !== undefined) return prepareCall
103
+ if (property === 'imageRequestPricing' && imageRequestPricing !== undefined) {
104
+ return imageRequestPricing
105
+ }
51
106
  const value = Reflect.get(target, property, target)
52
- return typeof value === 'function' ? value.bind(target) : value
107
+ if (typeof value !== 'function') return value
108
+ let bound = boundFunctions.get(value)
109
+ if (bound === undefined) {
110
+ bound = value.bind(target)
111
+ boundFunctions.set(value, bound)
112
+ }
113
+ return bound
53
114
  },
54
115
  })
55
- preparedAdapterCompat.set(adapter, wrapped)
116
+ cache.set(adapter, wrapped)
56
117
  return wrapped
57
118
  }
58
119
  }
59
120
 
121
+ /** Preserve the public helper used by the DSH 0.1.1 compatibility tests. */
122
+ export function ensureAdapterPrepareCall(adapter) {
123
+ return ensureAdapterContracts(adapter)
124
+ }
125
+
126
+ function llmSupportsImageRequestPricing(llm) {
127
+ try {
128
+ return typeof Reflect.get(llm, 'imageRequestPricing', llm) === 'function'
129
+ } catch {
130
+ return false
131
+ }
132
+ }
133
+
60
134
  function wrapLlmService(llm) {
61
- if (!llm || (typeof llm !== 'object' && typeof llm !== 'function')) return llm
135
+ if (!isAdapter(llm)) return llm
62
136
  const cached = wrappedLlmServices.get(llm)
63
137
  if (cached) return cached
64
138
 
139
+ // Capability detection, not a version check: rc.6/rc.7/rc.8 keep the exact
140
+ // historical adapter shape, while Hosts exposing the new route-pricing seam
141
+ // receive the base-class-compatible default on our duck-typed adapters.
142
+ const includeImageRequestPricing = llmSupportsImageRequestPricing(llm)
65
143
  const wrapped = new Proxy(llm, {
66
144
  get(target, property) {
67
145
  if (property === 'registerAdapter') {
68
146
  const registerAdapter = Reflect.get(target, property, target)
69
147
  if (typeof registerAdapter !== 'function') return registerAdapter
70
148
  return (providers, adapter, ...rest) =>
71
- registerAdapter.call(target, providers, ensureAdapterPrepareCall(adapter), ...rest)
149
+ registerAdapter.call(
150
+ target,
151
+ providers,
152
+ ensureAdapterContracts(adapter, { imageRequestPricing: includeImageRequestPricing }),
153
+ ...rest,
154
+ )
72
155
  }
73
156
  const value = Reflect.get(target, property, target)
74
157
  return typeof value === 'function' ? value.bind(target) : value
@@ -154,9 +237,10 @@ export function createCoalescingRunner(run, options = {}) {
154
237
  * this wrapped context are coalesced; host and other plugin listeners keep
155
238
  * their native event semantics.
156
239
  *
157
- * The same boundary also normalizes Vision Router-owned adapter registrations
158
- * to the DSH 0.1.1 prepareCall contract. This stays scoped to the wrapped
159
- * context instead of monkey-patching the Host LLM service process-wide.
240
+ * The same deepest registration boundary also normalizes Vision Router-owned
241
+ * adapter contracts: prepareCall for DSH 0.1.1+, and imageRequestPricing when
242
+ * the Host exposes the DSH 0.1.2 route-pricing seam. This stays scoped to the
243
+ * wrapped context instead of monkey-patching the Host LLM service process-wide.
160
244
  */
161
245
  export function contextWithCoalescedAdapterUpdates(ctx) {
162
246
  if (!ctx || (typeof ctx !== 'object' && typeof ctx !== 'function')) return ctx
@@ -478,24 +478,39 @@ export function installAdversarialHardening(ctx, config = {}, core) {
478
478
  return effect.call(target, () => {
479
479
  const originalFetch = globalThis.fetch
480
480
  const disposer = factory()
481
- const installedFetch = globalThis.fetch
481
+ let installedFetch = globalThis.fetch
482
482
  let active = true
483
483
  // Later plugins may capture this function. Making it an inert
484
484
  // delegator on unload removes Vision Router from the chain even if
485
485
  // another plugin still sits above it, and prevents it resurfacing
486
486
  // when that later plugin restores the fetch value it captured.
487
- const guardedFetch = (...args) => active
488
- ? installedFetch(...args)
489
- : originalFetch(...args)
487
+ const guardedFetch = (...args) => {
488
+ const fetchImpl = active && typeof installedFetch === 'function'
489
+ ? installedFetch
490
+ : originalFetch
491
+ return fetchImpl(...args)
492
+ }
490
493
  globalThis.fetch = guardedFetch
491
494
  return () => {
495
+ if (!active) return
492
496
  active = false
493
- if (globalThis.fetch === guardedFetch) {
494
- globalThis.fetch = originalFetch
495
- // The core disposer is safe only while our guard is still the
496
- // active process-level fetch. Never let its unconditional
497
- // assignment clobber a later plugin's patch.
497
+ // Drop the raw Vision Router fetch closure as soon as ownership
498
+ // ends. This releases its cached ProxyAgent/undici closure even
499
+ // when a later plugin still retains guardedFetch.
500
+ installedFetch = undefined
501
+ const currentFetch = globalThis.fetch
502
+ try {
503
+ // Always run the underlying effect disposer. It may own more
504
+ // than the process-level pointer; skipping it merely because a
505
+ // later plugin wrapped fetch leaks that lifecycle forever.
498
506
  if (typeof disposer === 'function') disposer()
507
+ } finally {
508
+ // The core disposer currently restores originalFetch
509
+ // unconditionally. Preserve any later plugin that was above us,
510
+ // while an outermost Vision Router cleanup returns to the host.
511
+ globalThis.fetch = currentFetch === guardedFetch
512
+ ? originalFetch
513
+ : currentFetch
499
514
  }
500
515
  }
501
516
  }, label)
@@ -1,184 +1,25 @@
1
- import { randomUUID } from 'node:crypto'
2
- import { lstat, mkdir, realpath, rename, unlink, writeFile } from 'node:fs/promises'
3
- import path from 'node:path'
4
- import {
5
- currentVisionTurnBudget,
6
- currentVisionTurnBudgetSignal,
7
- } from './turn-budget-context.js'
8
- import {
9
- isManagedArtifactRunName,
10
- scheduleArtifactRetention,
11
- } from './artifact-retention.js'
1
+ import { createVisionArtifactStore } from './vision-artifact-store.js'
12
2
 
13
- export const DEFAULT_ARTIFACTS_DIR = '.dsh-vision-router/artifacts'
14
-
15
- function isPathInside(root, candidate) {
16
- const relative = path.relative(root, candidate)
17
- return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
18
- }
19
-
20
- function isMissing(error) {
21
- return error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')
22
- }
23
-
24
- function artifactAbortError() {
25
- const error = new Error('vision-router: artifact publication aborted')
26
- error.name = 'AbortError'
27
- error.code = 'ABORT_ERR'
28
- return error
29
- }
30
-
31
- function throwIfVisionAborted() {
32
- if (currentVisionTurnBudgetSignal()?.aborted) throw artifactAbortError()
33
- }
34
-
35
- function currentArtifactRunId() {
36
- const value = currentVisionTurnBudget()?.artifactRunId
37
- return isManagedArtifactRunName(value) ? value : undefined
38
- }
39
-
40
- export function normalizeArtifactsDir(value) {
41
- if (typeof value !== 'string' || value.trim() === '') return DEFAULT_ARTIFACTS_DIR
42
- const raw = value.trim()
43
- if (path.isAbsolute(raw) || path.win32.isAbsolute(raw)) return DEFAULT_ARTIFACTS_DIR
44
- const parts = raw.split(/[\\/]+/)
45
- if (parts.some((part) => part === '..')) return DEFAULT_ARTIFACTS_DIR
46
- const normalized = path.normalize(raw)
47
- if (normalized === '' || normalized === '.' || normalized === path.sep) return DEFAULT_ARTIFACTS_DIR
48
- return normalized
49
- }
50
-
51
- function normalizeRelativeArtifactPath(value) {
52
- if (typeof value !== 'string' || value.trim() === '') {
53
- throw new Error('vision-router: artifact path must be a non-empty relative path')
54
- }
55
- const raw = value.trim()
56
- if (path.isAbsolute(raw) || path.win32.isAbsolute(raw)) {
57
- throw new Error('vision-router: artifact path must stay inside the artifacts directory')
58
- }
59
- const parts = raw.split(/[\\/]+/)
60
- if (parts.some((part) => part === '..')) {
61
- throw new Error('vision-router: artifact path must stay inside the artifacts directory')
62
- }
63
- const normalized = path.normalize(raw)
64
- if (normalized === '' || normalized === '.' || normalized === path.sep) {
65
- throw new Error('vision-router: artifact path must name a file')
66
- }
67
- return normalized
68
- }
69
-
70
- async function realpathNearestExisting(candidate, realpathImpl) {
71
- let current = candidate
72
- while (true) {
73
- try {
74
- return await realpathImpl(current)
75
- } catch (error) {
76
- if (!isMissing(error)) throw error
77
- const parent = path.dirname(current)
78
- if (parent === current) throw error
79
- current = parent
80
- }
81
- }
82
- }
83
-
84
- async function safeLstat(target, lstatImpl) {
85
- try {
86
- return await lstatImpl(target)
87
- } catch (error) {
88
- if (isMissing(error)) return undefined
89
- throw error
90
- }
91
- }
3
+ export {
4
+ DEFAULT_ARTIFACTS_DIR,
5
+ normalizeArtifactsDir,
6
+ } from './artifact-io.js'
92
7
 
93
8
  /**
94
- * Resolve an artifact target without treating lexical workspace containment as
95
- * authority. Existing ancestors are canonicalized before mkdir runs, then the
96
- * completed parent and artifact root are canonicalized again.
9
+ * Compatibility boundary retained for existing production callers during P2.
10
+ * Resolution authority now flows through VisionArtifactStore while the public
11
+ * function signature and return value remain unchanged.
97
12
  */
98
- export async function resolveArtifactTarget(workspace, artifactsDir, relativePath, deps = {}) {
99
- const realpathImpl = deps.realpath ?? realpath
100
- const mkdirImpl = deps.mkdir ?? mkdir
101
- const lstatImpl = deps.lstat ?? lstat
102
-
103
- throwIfVisionAborted()
104
- const workspaceReal = await realpathImpl(path.resolve(String(workspace ?? '')))
105
- throwIfVisionAborted()
106
- const relativeBase = normalizeArtifactsDir(artifactsDir)
107
- const runId = currentArtifactRunId()
108
- const requestedTarget = normalizeRelativeArtifactPath(relativePath)
109
- const relativeTarget = runId
110
- ? normalizeRelativeArtifactPath(path.join(runId, requestedTarget))
111
- : requestedTarget
112
- const lexicalBase = path.resolve(workspaceReal, relativeBase)
113
- if (!isPathInside(workspaceReal, lexicalBase)) {
114
- throw new Error('vision-router: artifactsDir must stay inside the session workspace')
115
- }
116
- const lexicalTarget = path.resolve(lexicalBase, relativeTarget)
117
- if (!isPathInside(lexicalBase, lexicalTarget)) {
118
- throw new Error('vision-router: artifact target must stay inside the artifacts directory')
119
- }
120
-
121
- const lexicalParent = path.dirname(lexicalTarget)
122
- const existingAncestorReal = await realpathNearestExisting(lexicalParent, realpathImpl)
123
- throwIfVisionAborted()
124
- if (!isPathInside(workspaceReal, existingAncestorReal)) {
125
- throw new Error('vision-router: artifact parent escapes the session workspace through a symlink')
126
- }
127
-
128
- await mkdirImpl(lexicalParent, { recursive: true })
129
- throwIfVisionAborted()
130
- const parentReal = await realpathImpl(lexicalParent)
131
- const artifactsBaseReal = await realpathImpl(lexicalBase)
132
- throwIfVisionAborted()
133
- if (!isPathInside(workspaceReal, parentReal) || !isPathInside(workspaceReal, artifactsBaseReal)) {
134
- throw new Error('vision-router: artifact parent escapes the session workspace through a symlink')
135
- }
136
-
137
- const target = path.join(parentReal, path.basename(lexicalTarget))
138
- const existing = await safeLstat(target, lstatImpl)
139
- throwIfVisionAborted()
140
- if (existing?.isDirectory?.()) {
141
- throw new Error('vision-router: artifact target is a directory')
142
- }
143
- return { target, parentReal, artifactsBaseReal, existing, runId }
13
+ export function resolveArtifactTarget(workspace, artifactsDir, relativePath, deps = {}) {
14
+ return createVisionArtifactStore({ workspace, artifactsDir, deps }).resolve(relativePath)
144
15
  }
145
16
 
146
17
  /**
147
- * Publish artifact bytes without ever opening the final target for writing.
148
- * A temp file is written in the canonical parent and renamed over the target;
149
- * an existing symlink is unlinked as a directory entry rather than followed.
18
+ * Compatibility writer retained while callers migrate to VisionArtifactStore.
19
+ * The store delegates to the exact hardened IO primitive that previously lived
20
+ * here, so path layout, atomic publication, cancellation and retention semantics
21
+ * remain unchanged.
150
22
  */
151
- export async function writeArtifactFile(workspace, artifactsDir, relativePath, data, deps = {}) {
152
- const writeFileImpl = deps.writeFile ?? writeFile
153
- const renameImpl = deps.rename ?? rename
154
- const unlinkImpl = deps.unlink ?? unlink
155
- const resolved = await resolveArtifactTarget(workspace, artifactsDir, relativePath, deps)
156
- const temp = path.join(
157
- resolved.parentReal,
158
- `.dsh-vision-router-${process.pid}-${randomUUID()}.tmp`,
159
- )
160
- let published = false
161
- try {
162
- throwIfVisionAborted()
163
- await writeFileImpl(temp, data, { mode: 0o600 })
164
- throwIfVisionAborted()
165
- if (resolved.existing !== undefined) {
166
- try {
167
- await unlinkImpl(resolved.target)
168
- } catch (error) {
169
- if (!isMissing(error)) throw error
170
- }
171
- throwIfVisionAborted()
172
- }
173
- await renameImpl(temp, resolved.target)
174
- published = true
175
- if (resolved.runId) {
176
- scheduleArtifactRetention(resolved.artifactsBaseReal, { protectRunId: resolved.runId })
177
- }
178
- return resolved.target
179
- } finally {
180
- if (!published) {
181
- try { await unlinkImpl(temp) } catch { /* best effort */ }
182
- }
183
- }
23
+ export function writeArtifactFile(workspace, artifactsDir, relativePath, data, deps = {}) {
24
+ return createVisionArtifactStore({ workspace, artifactsDir, deps }).publish(relativePath, data)
184
25
  }