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.
- package/README.md +33 -16
- package/README.zh.md +34 -17
- package/cordis.patch.yml +19 -9
- package/docs/architecture/2x-contract-ledger.md +165 -0
- package/docs/architecture/compat-inventory.md +121 -0
- package/docs/architecture/dsh-compatibility-matrix.md +57 -0
- package/docs/architecture/dsh-support-window.md +50 -0
- package/docs/architecture/final-closure-audit.md +148 -0
- package/docs/architecture/p2-jobs-feasibility.md +94 -0
- package/docs/architecture/p3-compat-retirement.md +46 -0
- package/docs/architecture/p3-host-native-seams.md +43 -0
- package/docs/architecture/p3-native-recovery-evaluation.md +65 -0
- package/docs/architecture/runtime-boundaries.md +124 -0
- package/docs/releases/v2.0.1.md +17 -0
- package/docs/releases/v2.1.0-support-window.md +29 -0
- package/docs/releases/v2.1.0.md +83 -0
- package/entry.js +13 -307
- package/index.js +85 -352
- package/lib/abort-signal-compat.js +191 -0
- package/lib/adapter-update-coalescer.js +113 -29
- package/lib/adversarial-hardening.js +24 -9
- package/lib/artifact-boundary.js +16 -175
- package/lib/artifact-io.js +220 -0
- package/lib/artifact-retention.js +305 -53
- package/lib/catalog-corrections.js +6 -1
- package/lib/client-host-compat-prelude.js +249 -0
- package/lib/client-presentation-boundary.js +61 -3
- package/lib/client.js +71 -72
- package/lib/core-vision-surface.js +124 -0
- package/lib/depth-guidance.js +114 -90
- package/lib/doctor-cli-p0.js +168 -0
- package/lib/doctor-cli.js +7 -0
- package/lib/doctor-vision-limits.js +72 -0
- package/lib/dsh-contract-compat.js +207 -39
- package/lib/dsh-host-capabilities.js +107 -0
- package/lib/dsh-support-window.js +48 -0
- package/lib/http-compat.js +22 -6
- package/lib/image-resource-governor.js +35 -10
- package/lib/legacy-core-vision-policy-bridge.js +25 -226
- package/lib/legacy-global-proxy-boundary.js +127 -0
- package/lib/live-model-client-prelude.js +0 -12
- package/lib/mixed-router.js +81 -81
- package/lib/public-entry.js +42 -4
- package/lib/runtime-composition.js +348 -0
- package/lib/runtime-i18n-boundary.js +683 -0
- package/lib/runtime-i18n-core-scope.js +56 -0
- package/lib/runtime-i18n-core.js +34 -0
- package/lib/runtime-i18n.js +121 -0
- package/lib/session-surface-policy.js +97 -0
- package/lib/session-vision-index.js +282 -0
- package/lib/session-vision-runtime.js +48 -0
- package/lib/settings-factory-lifecycle.js +144 -0
- package/lib/settings-ia-client-prelude.js +74 -2
- package/lib/settings-limit-client-prelude.js +110 -0
- package/lib/settings-native-card-layout.js +297 -0
- package/lib/settings-number-contract.js +29 -0
- package/lib/structured-flow-hardening.js +207 -76
- package/lib/tesseract-exec-compat.js +58 -32
- package/lib/update-check.js +17 -5
- package/lib/v2-settings-ia-integration.js +9 -4
- package/lib/vision-artifact-store.js +77 -0
- package/lib/vision-backend-runtime-policy.js +6 -2
- package/lib/vision-background-benchmark.js +130 -41
- package/lib/vision-background-failure-policy.js +70 -0
- package/lib/vision-background-stop-store.js +54 -9
- package/lib/vision-capability-benchmark-client.js +57 -66
- package/lib/vision-capability-benchmark-presentation.js +97 -0
- package/lib/vision-capability-benchmark-service.js +3 -3
- package/lib/vision-execution-order-apply.js +61 -0
- package/lib/vision-execution-order-plan.js +64 -0
- package/lib/vision-execution-order.js +38 -0
- package/lib/vision-limit-diagnostics.js +202 -0
- package/lib/vision-product-presentation.js +284 -0
- package/lib/vision-provider-transport.js +197 -0
- package/lib/vision-resilience.js +87 -45
- package/lib/vision-routing-evidence.js +366 -0
- package/lib/vision-routing-runtime.js +278 -0
- package/lib/vision-tool-runtime-boundary.js +27 -21
- package/lib/vision-turn-budget-client-prelude.js +9 -9
- package/lib/web/benchmark-panel.js +6 -0
- package/lib/web/diagnostics-panel.js +44 -0
- package/lib/web/index.js +60 -0
- package/lib/web/model-picker.js +22 -0
- package/lib/web/onboarding.js +6 -0
- package/lib/web/product-state.js +59 -0
- package/lib/web/remote-settings-client.js +8 -0
- package/lib/web/routing-section.js +6 -0
- package/lib/web/settings-controller.js +6 -0
- package/lib/web-capability-boundary.js +65 -9
- package/lib/windows-screenshot-dpi-compat.js +148 -0
- package/package.json +14 -6
- package/lib/vision-capability-shadow.js +0 -576
|
@@ -0,0 +1,220 @@
|
|
|
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'
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_ARTIFACTS_DIR = '.dsh-vision-router/artifacts'
|
|
14
|
+
export const ARTIFACT_RUNS_DIR = '.runs'
|
|
15
|
+
|
|
16
|
+
function isPathInside(root, candidate) {
|
|
17
|
+
const relative = path.relative(root, candidate)
|
|
18
|
+
return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isMissing(error) {
|
|
22
|
+
return error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function artifactAbortError() {
|
|
26
|
+
const error = new Error('vision-router: artifact publication aborted')
|
|
27
|
+
error.name = 'AbortError'
|
|
28
|
+
error.code = 'ABORT_ERR'
|
|
29
|
+
return error
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function throwIfVisionAborted() {
|
|
33
|
+
if (currentVisionTurnBudgetSignal()?.aborted) throw artifactAbortError()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function currentArtifactRunId() {
|
|
37
|
+
const value = currentVisionTurnBudget()?.artifactRunId
|
|
38
|
+
return isManagedArtifactRunName(value) ? value : undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function normalizeArtifactsDir(value) {
|
|
42
|
+
if (typeof value !== 'string' || value.trim() === '') return DEFAULT_ARTIFACTS_DIR
|
|
43
|
+
const raw = value.trim()
|
|
44
|
+
if (path.isAbsolute(raw) || path.win32.isAbsolute(raw)) return DEFAULT_ARTIFACTS_DIR
|
|
45
|
+
const parts = raw.split(/[\\/]+/)
|
|
46
|
+
if (parts.some((part) => part === '..')) return DEFAULT_ARTIFACTS_DIR
|
|
47
|
+
const normalized = path.normalize(raw)
|
|
48
|
+
if (normalized === '' || normalized === '.' || normalized === path.sep) return DEFAULT_ARTIFACTS_DIR
|
|
49
|
+
return normalized
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeRelativeArtifactPath(value) {
|
|
53
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
54
|
+
throw new Error('vision-router: artifact path must be a non-empty relative path')
|
|
55
|
+
}
|
|
56
|
+
const raw = value.trim()
|
|
57
|
+
if (path.isAbsolute(raw) || path.win32.isAbsolute(raw)) {
|
|
58
|
+
throw new Error('vision-router: artifact path must stay inside the artifacts directory')
|
|
59
|
+
}
|
|
60
|
+
const parts = raw.split(/[\\/]+/)
|
|
61
|
+
if (parts.some((part) => part === '..')) {
|
|
62
|
+
throw new Error('vision-router: artifact path must stay inside the artifacts directory')
|
|
63
|
+
}
|
|
64
|
+
const normalized = path.normalize(raw)
|
|
65
|
+
if (normalized === '' || normalized === '.' || normalized === path.sep) {
|
|
66
|
+
throw new Error('vision-router: artifact path must name a file')
|
|
67
|
+
}
|
|
68
|
+
return normalized
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function realpathNearestExisting(candidate, realpathImpl) {
|
|
72
|
+
let current = candidate
|
|
73
|
+
while (true) {
|
|
74
|
+
try {
|
|
75
|
+
return await realpathImpl(current)
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (!isMissing(error)) throw error
|
|
78
|
+
const parent = path.dirname(current)
|
|
79
|
+
if (parent === current) throw error
|
|
80
|
+
current = parent
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function safeLstat(target, lstatImpl) {
|
|
86
|
+
try {
|
|
87
|
+
return await lstatImpl(target)
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (isMissing(error)) return undefined
|
|
90
|
+
throw error
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Hardened artifact target primitive below VisionArtifactStore.
|
|
96
|
+
*
|
|
97
|
+
* P2-B gives managed tool-run artifacts their own `.runs/` namespace. The
|
|
98
|
+
* namespace is treated as infrastructure, not user-controlled path data: an
|
|
99
|
+
* existing symlink is rejected even when it points somewhere else inside the
|
|
100
|
+
* workspace. Artifacts outside a managed run keep their original root layout.
|
|
101
|
+
*/
|
|
102
|
+
export async function resolveArtifactTarget(workspace, artifactsDir, relativePath, deps = {}) {
|
|
103
|
+
const realpathImpl = deps.realpath ?? realpath
|
|
104
|
+
const mkdirImpl = deps.mkdir ?? mkdir
|
|
105
|
+
const lstatImpl = deps.lstat ?? lstat
|
|
106
|
+
|
|
107
|
+
throwIfVisionAborted()
|
|
108
|
+
const workspaceReal = await realpathImpl(path.resolve(String(workspace ?? '')))
|
|
109
|
+
throwIfVisionAborted()
|
|
110
|
+
const relativeBase = normalizeArtifactsDir(artifactsDir)
|
|
111
|
+
const runId = currentArtifactRunId()
|
|
112
|
+
const requestedTarget = normalizeRelativeArtifactPath(relativePath)
|
|
113
|
+
const relativeTarget = runId
|
|
114
|
+
? normalizeRelativeArtifactPath(path.join(ARTIFACT_RUNS_DIR, runId, requestedTarget))
|
|
115
|
+
: requestedTarget
|
|
116
|
+
const lexicalBase = path.resolve(workspaceReal, relativeBase)
|
|
117
|
+
if (!isPathInside(workspaceReal, lexicalBase)) {
|
|
118
|
+
throw new Error('vision-router: artifactsDir must stay inside the session workspace')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const lexicalRunsBase = runId ? path.resolve(lexicalBase, ARTIFACT_RUNS_DIR) : undefined
|
|
122
|
+
if (lexicalRunsBase && !isPathInside(lexicalBase, lexicalRunsBase)) {
|
|
123
|
+
throw new Error('vision-router: managed artifact runs must stay inside the artifacts directory')
|
|
124
|
+
}
|
|
125
|
+
if (lexicalRunsBase) {
|
|
126
|
+
const before = await safeLstat(lexicalRunsBase, lstatImpl)
|
|
127
|
+
throwIfVisionAborted()
|
|
128
|
+
if (before?.isSymbolicLink?.()) {
|
|
129
|
+
throw new Error('vision-router: managed artifact runs directory must not be a symlink')
|
|
130
|
+
}
|
|
131
|
+
if (before !== undefined && !before.isDirectory?.()) {
|
|
132
|
+
throw new Error('vision-router: managed artifact runs path must be a directory')
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const lexicalTarget = path.resolve(lexicalBase, relativeTarget)
|
|
137
|
+
if (!isPathInside(lexicalBase, lexicalTarget)) {
|
|
138
|
+
throw new Error('vision-router: artifact target must stay inside the artifacts directory')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const lexicalParent = path.dirname(lexicalTarget)
|
|
142
|
+
const existingAncestorReal = await realpathNearestExisting(lexicalParent, realpathImpl)
|
|
143
|
+
throwIfVisionAborted()
|
|
144
|
+
if (!isPathInside(workspaceReal, existingAncestorReal)) {
|
|
145
|
+
throw new Error('vision-router: artifact parent escapes the session workspace through a symlink')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
await mkdirImpl(lexicalParent, { recursive: true })
|
|
149
|
+
throwIfVisionAborted()
|
|
150
|
+
const parentReal = await realpathImpl(lexicalParent)
|
|
151
|
+
const artifactsBaseReal = await realpathImpl(lexicalBase)
|
|
152
|
+
throwIfVisionAborted()
|
|
153
|
+
if (!isPathInside(workspaceReal, parentReal) || !isPathInside(workspaceReal, artifactsBaseReal)) {
|
|
154
|
+
throw new Error('vision-router: artifact parent escapes the session workspace through a symlink')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let runsBaseReal
|
|
158
|
+
if (lexicalRunsBase) {
|
|
159
|
+
const after = await safeLstat(lexicalRunsBase, lstatImpl)
|
|
160
|
+
throwIfVisionAborted()
|
|
161
|
+
if (!after?.isDirectory?.() || after.isSymbolicLink?.()) {
|
|
162
|
+
throw new Error('vision-router: managed artifact runs directory must be a real directory')
|
|
163
|
+
}
|
|
164
|
+
runsBaseReal = await realpathImpl(lexicalRunsBase)
|
|
165
|
+
throwIfVisionAborted()
|
|
166
|
+
if (!isPathInside(artifactsBaseReal, runsBaseReal)) {
|
|
167
|
+
throw new Error('vision-router: managed artifact runs directory escapes the artifacts directory')
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const target = path.join(parentReal, path.basename(lexicalTarget))
|
|
172
|
+
const existing = await safeLstat(target, lstatImpl)
|
|
173
|
+
throwIfVisionAborted()
|
|
174
|
+
if (existing?.isDirectory?.()) {
|
|
175
|
+
throw new Error('vision-router: artifact target is a directory')
|
|
176
|
+
}
|
|
177
|
+
return { target, parentReal, artifactsBaseReal, runsBaseReal, existing, runId }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Hardened atomic publication primitive used by VisionArtifactStore. */
|
|
181
|
+
export async function writeArtifactFile(workspace, artifactsDir, relativePath, data, deps = {}) {
|
|
182
|
+
const writeFileImpl = deps.writeFile ?? writeFile
|
|
183
|
+
const renameImpl = deps.rename ?? rename
|
|
184
|
+
const unlinkImpl = deps.unlink ?? unlink
|
|
185
|
+
const resolved = await resolveArtifactTarget(workspace, artifactsDir, relativePath, deps)
|
|
186
|
+
const temp = path.join(
|
|
187
|
+
resolved.parentReal,
|
|
188
|
+
`.dsh-vision-router-${process.pid}-${randomUUID()}.tmp`,
|
|
189
|
+
)
|
|
190
|
+
let published = false
|
|
191
|
+
try {
|
|
192
|
+
throwIfVisionAborted()
|
|
193
|
+
await writeFileImpl(temp, data, { mode: 0o600 })
|
|
194
|
+
throwIfVisionAborted()
|
|
195
|
+
if (resolved.existing !== undefined) {
|
|
196
|
+
try {
|
|
197
|
+
await unlinkImpl(resolved.target)
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (!isMissing(error)) throw error
|
|
200
|
+
}
|
|
201
|
+
throwIfVisionAborted()
|
|
202
|
+
}
|
|
203
|
+
await renameImpl(temp, resolved.target)
|
|
204
|
+
published = true
|
|
205
|
+
if (resolved.runId) {
|
|
206
|
+
// New managed runs live under `.runs`, so retention scans that directory
|
|
207
|
+
// directly. Also schedule the old artifact root so pre-P2-B direct
|
|
208
|
+
// `.vision-run-*` directories continue to age out safely.
|
|
209
|
+
if (resolved.runsBaseReal) {
|
|
210
|
+
scheduleArtifactRetention(resolved.runsBaseReal, { protectRunId: resolved.runId })
|
|
211
|
+
}
|
|
212
|
+
scheduleArtifactRetention(resolved.artifactsBaseReal, { protectRunId: resolved.runId })
|
|
213
|
+
}
|
|
214
|
+
return resolved.target
|
|
215
|
+
} finally {
|
|
216
|
+
if (!published) {
|
|
217
|
+
try { await unlinkImpl(temp) } catch { /* best effort */ }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { lstat,
|
|
1
|
+
import { lstat, opendir, rm } from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
|
|
4
4
|
export const ARTIFACT_RUN_PREFIX = '.vision-run-'
|
|
@@ -6,26 +6,30 @@ export const DEFAULT_ARTIFACT_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
|
6
6
|
export const DEFAULT_ARTIFACT_MAX_BYTES = 2 * 1024 * 1024 * 1024
|
|
7
7
|
export const DEFAULT_ARTIFACT_MAX_RUNS = 512
|
|
8
8
|
export const DEFAULT_ARTIFACT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000
|
|
9
|
+
export const DEFAULT_ARTIFACT_SCAN_MAX_ENTRIES = 8_192
|
|
10
|
+
export const DEFAULT_ARTIFACT_SCAN_MAX_DEPTH = 32
|
|
11
|
+
export const DEFAULT_ARTIFACT_SCAN_MAX_MS = 2_000
|
|
9
12
|
|
|
10
13
|
const cleanupState = new Map()
|
|
14
|
+
const activeArtifactRuns = new Map()
|
|
11
15
|
const MAX_TRACKED_ROOTS = 64
|
|
12
16
|
|
|
13
17
|
export function isManagedArtifactRunName(name) {
|
|
14
18
|
return typeof name === 'string' && /^\.vision-run-[A-Za-z0-9._-]+$/.test(name)
|
|
15
19
|
}
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
/** Keep one run protected for the entire vision-tool lifetime. */
|
|
22
|
+
export function retainArtifactRun(runId) {
|
|
23
|
+
if (!isManagedArtifactRunName(runId)) return () => {}
|
|
24
|
+
activeArtifactRuns.set(runId, (activeArtifactRuns.get(runId) ?? 0) + 1)
|
|
25
|
+
let released = false
|
|
26
|
+
return () => {
|
|
27
|
+
if (released) return
|
|
28
|
+
released = true
|
|
29
|
+
const remaining = (activeArtifactRuns.get(runId) ?? 1) - 1
|
|
30
|
+
if (remaining <= 0) activeArtifactRuns.delete(runId)
|
|
31
|
+
else activeArtifactRuns.set(runId, remaining)
|
|
27
32
|
}
|
|
28
|
-
return total
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
function finitePositive(value, fallback) {
|
|
@@ -33,69 +37,307 @@ function finitePositive(value, fallback) {
|
|
|
33
37
|
return Number.isFinite(number) && number > 0 ? number : fallback
|
|
34
38
|
}
|
|
35
39
|
|
|
40
|
+
function finitePositiveInteger(value, fallback) {
|
|
41
|
+
return Math.max(1, Math.floor(finitePositive(value, fallback)))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createScanBudget({ maxEntries, maxDepth, maxMs, clock }) {
|
|
45
|
+
const startedAt = Number(clock())
|
|
46
|
+
return {
|
|
47
|
+
maxEntries: finitePositiveInteger(maxEntries, DEFAULT_ARTIFACT_SCAN_MAX_ENTRIES),
|
|
48
|
+
maxDepth: finitePositiveInteger(maxDepth, DEFAULT_ARTIFACT_SCAN_MAX_DEPTH),
|
|
49
|
+
maxMs: finitePositive(maxMs, DEFAULT_ARTIFACT_SCAN_MAX_MS),
|
|
50
|
+
clock,
|
|
51
|
+
startedAt: Number.isFinite(startedAt) ? startedAt : Date.now(),
|
|
52
|
+
entries: 0,
|
|
53
|
+
limited: false,
|
|
54
|
+
exhausted: false,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createScanBudgets(options = {}) {
|
|
59
|
+
const clock = typeof options.scanClock === 'function' ? options.scanClock : Date.now
|
|
60
|
+
return {
|
|
61
|
+
discovery: createScanBudget({
|
|
62
|
+
maxEntries: options.maxDiscoveryEntries ?? options.maxScanEntries,
|
|
63
|
+
maxDepth: 1,
|
|
64
|
+
maxMs: options.maxDiscoveryMs ?? options.maxScanMs,
|
|
65
|
+
clock,
|
|
66
|
+
}),
|
|
67
|
+
measurement: createScanBudget({
|
|
68
|
+
maxEntries: options.maxScanEntries,
|
|
69
|
+
maxDepth: options.maxScanDepth,
|
|
70
|
+
maxMs: options.maxScanMs,
|
|
71
|
+
clock,
|
|
72
|
+
}),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function scanTimedOut(budget) {
|
|
77
|
+
const current = Number(budget.clock())
|
|
78
|
+
if (!Number.isFinite(current)) return false
|
|
79
|
+
return current - budget.startedAt >= budget.maxMs
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function admitScanEntry(budget) {
|
|
83
|
+
if (budget.exhausted) return false
|
|
84
|
+
if (budget.entries >= budget.maxEntries || scanTimedOut(budget)) {
|
|
85
|
+
budget.exhausted = true
|
|
86
|
+
budget.limited = true
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
budget.entries += 1
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function protectedRunSet(options = {}) {
|
|
94
|
+
const shared = options.protectRunIds instanceof Set
|
|
95
|
+
? options.protectRunIds
|
|
96
|
+
: new Set(Array.isArray(options.protectRunIds) ? options.protectRunIds : [])
|
|
97
|
+
if (isManagedArtifactRunName(options.protectRunId)) shared.add(options.protectRunId)
|
|
98
|
+
return shared
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function runProtected(name, protectedRuns) {
|
|
102
|
+
return protectedRuns.has(name) || activeArtifactRuns.has(name)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function collectManagedRuns(root, budget) {
|
|
106
|
+
let directory
|
|
107
|
+
try {
|
|
108
|
+
directory = await opendir(root)
|
|
109
|
+
} catch (error) {
|
|
110
|
+
const missing = error?.code === 'ENOENT' || error?.code === 'ENOTDIR'
|
|
111
|
+
if (!missing) budget.limited = true
|
|
112
|
+
return { runs: [], complete: missing }
|
|
113
|
+
}
|
|
114
|
+
const runs = []
|
|
115
|
+
let complete = true
|
|
116
|
+
try {
|
|
117
|
+
for await (const entry of directory) {
|
|
118
|
+
if (!admitScanEntry(budget)) {
|
|
119
|
+
complete = false
|
|
120
|
+
break
|
|
121
|
+
}
|
|
122
|
+
if (!entry.isDirectory() || !isManagedArtifactRunName(entry.name)) continue
|
|
123
|
+
const target = path.join(root, entry.name)
|
|
124
|
+
let info
|
|
125
|
+
try {
|
|
126
|
+
info = await lstat(target)
|
|
127
|
+
} catch {
|
|
128
|
+
budget.limited = true
|
|
129
|
+
complete = false
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
if (!info.isDirectory() || info.isSymbolicLink()) continue
|
|
133
|
+
runs.push({
|
|
134
|
+
name: entry.name,
|
|
135
|
+
target,
|
|
136
|
+
mtimeMs: Number(info.mtimeMs) || 0,
|
|
137
|
+
bytes: 0,
|
|
138
|
+
scanComplete: false,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
budget.limited = true
|
|
143
|
+
complete = false
|
|
144
|
+
}
|
|
145
|
+
return { runs, complete }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Measure one managed run with a bounded streaming traversal. `complete=false`
|
|
150
|
+
* means the size is unknown; callers must never reinterpret uncertainty as
|
|
151
|
+
* evidence that destructive byte eviction is safe.
|
|
152
|
+
*/
|
|
153
|
+
async function treeBytesBounded(target, budget, byteCeiling) {
|
|
154
|
+
const stack = [{ target, depth: 0 }]
|
|
155
|
+
let total = 0
|
|
156
|
+
let complete = true
|
|
157
|
+
|
|
158
|
+
while (stack.length > 0) {
|
|
159
|
+
if (budget.exhausted || scanTimedOut(budget)) {
|
|
160
|
+
budget.exhausted = true
|
|
161
|
+
budget.limited = true
|
|
162
|
+
return { bytes: total, complete: false }
|
|
163
|
+
}
|
|
164
|
+
const current = stack.pop()
|
|
165
|
+
let directory
|
|
166
|
+
try {
|
|
167
|
+
directory = await opendir(current.target)
|
|
168
|
+
} catch {
|
|
169
|
+
budget.limited = true
|
|
170
|
+
return { bytes: total, complete: false }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
for await (const entry of directory) {
|
|
175
|
+
if (!admitScanEntry(budget)) return { bytes: total, complete: false }
|
|
176
|
+
if (entry.isSymbolicLink()) continue
|
|
177
|
+
const child = path.join(current.target, entry.name)
|
|
178
|
+
let info
|
|
179
|
+
try {
|
|
180
|
+
info = await lstat(child)
|
|
181
|
+
} catch {
|
|
182
|
+
budget.limited = true
|
|
183
|
+
complete = false
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
if (info.isSymbolicLink()) continue
|
|
187
|
+
if (info.isDirectory()) {
|
|
188
|
+
const nextDepth = current.depth + 1
|
|
189
|
+
if (nextDepth > budget.maxDepth) {
|
|
190
|
+
complete = false
|
|
191
|
+
budget.limited = true
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
stack.push({ target: child, depth: nextDepth })
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
total += Math.max(0, Number(info.size) || 0)
|
|
198
|
+
if (total > byteCeiling) {
|
|
199
|
+
return { bytes: byteCeiling + 1, complete: true, saturated: true }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
complete = false
|
|
204
|
+
budget.limited = true
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { bytes: total, complete }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function cleanupResult(scanned, removed, bytes, scan, includeScanDiagnostics) {
|
|
212
|
+
const result = { scanned, removed, bytes: Math.max(0, bytes) }
|
|
213
|
+
if (includeScanDiagnostics === true) {
|
|
214
|
+
result.scan = {
|
|
215
|
+
entries: scan.discovery.entries + scan.measurement.entries,
|
|
216
|
+
limited: scan.discovery.limited === true || scan.measurement.limited === true,
|
|
217
|
+
exhausted: scan.discovery.exhausted === true || scan.measurement.exhausted === true,
|
|
218
|
+
discoveryEntries: scan.discovery.entries,
|
|
219
|
+
measurementEntries: scan.measurement.entries,
|
|
220
|
+
discoveryComplete: scan.discoveryComplete === true,
|
|
221
|
+
bytesComplete: scan.bytesComplete === true,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return result
|
|
225
|
+
}
|
|
226
|
+
|
|
36
227
|
/**
|
|
37
228
|
* Delete only Vision Router run directories. Unknown files, legacy artifacts,
|
|
38
229
|
* symlinks and user-created directories are intentionally outside this policy.
|
|
230
|
+
*
|
|
231
|
+
* TTL can act on each discovered run independently. maxRuns and maxBytes are
|
|
232
|
+
* global policies, so they run only when discovery is complete; byte eviction
|
|
233
|
+
* additionally requires every surviving run size to be known. Uncertainty is
|
|
234
|
+
* therefore fail-safe for user data rather than being treated as over-budget.
|
|
39
235
|
*/
|
|
40
236
|
export async function cleanupArtifactRuns(root, options = {}) {
|
|
41
237
|
const ttlMs = finitePositive(options.ttlMs, DEFAULT_ARTIFACT_TTL_MS)
|
|
42
238
|
const maxBytes = finitePositive(options.maxBytes, DEFAULT_ARTIFACT_MAX_BYTES)
|
|
43
|
-
const maxRuns =
|
|
239
|
+
const maxRuns = finitePositiveInteger(options.maxRuns, DEFAULT_ARTIFACT_MAX_RUNS)
|
|
44
240
|
const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now()
|
|
45
|
-
const
|
|
241
|
+
const protectedRuns = protectedRunSet(options)
|
|
242
|
+
const budgets = createScanBudgets(options)
|
|
46
243
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
runs.push({
|
|
57
|
-
name: entry.name,
|
|
58
|
-
target,
|
|
59
|
-
mtimeMs: Number(info.mtimeMs) || 0,
|
|
60
|
-
bytes: await treeBytes(target),
|
|
61
|
-
protected: entry.name === protect,
|
|
62
|
-
})
|
|
244
|
+
const discovery = await collectManagedRuns(root, budgets.discovery)
|
|
245
|
+
const runs = discovery.runs
|
|
246
|
+
let bytesComplete = discovery.complete
|
|
247
|
+
if (runs.length === 0) {
|
|
248
|
+
return cleanupResult(0, 0, 0, {
|
|
249
|
+
...budgets,
|
|
250
|
+
discoveryComplete: discovery.complete,
|
|
251
|
+
bytesComplete,
|
|
252
|
+
}, options.includeScanDiagnostics)
|
|
63
253
|
}
|
|
64
254
|
|
|
65
255
|
runs.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
66
256
|
const remove = new Set()
|
|
257
|
+
const byteEvictions = new Set()
|
|
67
258
|
for (const run of runs) {
|
|
68
|
-
if (!run.
|
|
259
|
+
if (!runProtected(run.name, protectedRuns) && now - run.mtimeMs >= ttlMs) remove.add(run)
|
|
69
260
|
}
|
|
70
261
|
|
|
71
262
|
const survivors = () => runs.filter((run) => !remove.has(run))
|
|
72
263
|
let live = survivors()
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
264
|
+
if (discovery.complete) {
|
|
265
|
+
while (live.length > maxRuns) {
|
|
266
|
+
const candidate = [...live].reverse().find((run) => !runProtected(run.name, protectedRuns))
|
|
267
|
+
if (!candidate) break
|
|
268
|
+
remove.add(candidate)
|
|
269
|
+
live = survivors()
|
|
270
|
+
}
|
|
78
271
|
}
|
|
79
272
|
|
|
80
|
-
let totalBytes =
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
273
|
+
let totalBytes = 0
|
|
274
|
+
if (discovery.complete) {
|
|
275
|
+
for (const run of live) {
|
|
276
|
+
const measured = await treeBytesBounded(run.target, budgets.measurement, maxBytes)
|
|
277
|
+
run.bytes = measured.bytes
|
|
278
|
+
run.scanComplete = measured.complete === true
|
|
279
|
+
if (!run.scanComplete) bytesComplete = false
|
|
280
|
+
}
|
|
281
|
+
totalBytes = live.reduce((sum, run) => sum + run.bytes, 0)
|
|
282
|
+
|
|
283
|
+
if (bytesComplete) {
|
|
284
|
+
while (totalBytes > maxBytes) {
|
|
285
|
+
const candidate = [...live].reverse().find((run) => !runProtected(run.name, protectedRuns))
|
|
286
|
+
if (!candidate) break
|
|
287
|
+
remove.add(candidate)
|
|
288
|
+
byteEvictions.add(candidate)
|
|
289
|
+
totalBytes -= candidate.bytes
|
|
290
|
+
live = survivors()
|
|
291
|
+
}
|
|
292
|
+
}
|
|
87
293
|
}
|
|
88
294
|
|
|
89
295
|
let removed = 0
|
|
90
296
|
for (const run of remove) {
|
|
297
|
+
// Protection is checked again immediately before deletion so a concurrent
|
|
298
|
+
// tool that became active after cleanup started cannot lose its run.
|
|
299
|
+
if (runProtected(run.name, protectedRuns)) {
|
|
300
|
+
if (byteEvictions.has(run)) totalBytes += run.bytes
|
|
301
|
+
else bytesComplete = false
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
91
304
|
try {
|
|
92
305
|
await rm(run.target, { recursive: true, force: true })
|
|
93
306
|
removed += 1
|
|
94
307
|
} catch {
|
|
308
|
+
if (byteEvictions.has(run)) totalBytes += run.bytes
|
|
309
|
+
else bytesComplete = false
|
|
95
310
|
// Retention is best-effort and must never make the foreground tool fail.
|
|
96
311
|
}
|
|
97
312
|
}
|
|
98
|
-
|
|
313
|
+
|
|
314
|
+
return cleanupResult(runs.length, removed, totalBytes, {
|
|
315
|
+
...budgets,
|
|
316
|
+
discoveryComplete: discovery.complete,
|
|
317
|
+
bytesComplete,
|
|
318
|
+
}, options.includeScanDiagnostics)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function addScheduledProtection(state, options = {}) {
|
|
322
|
+
if (isManagedArtifactRunName(options.protectRunId)) state.protectedRunIds.add(options.protectRunId)
|
|
323
|
+
if (options.protectRunIds instanceof Set || Array.isArray(options.protectRunIds)) {
|
|
324
|
+
for (const runId of options.protectRunIds) {
|
|
325
|
+
if (isManagedArtifactRunName(runId)) state.protectedRunIds.add(runId)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function trimCleanupState() {
|
|
331
|
+
while (cleanupState.size > MAX_TRACKED_ROOTS) {
|
|
332
|
+
let evicted
|
|
333
|
+
for (const [root, state] of cleanupState) {
|
|
334
|
+
if (state.promise) continue
|
|
335
|
+
evicted = root
|
|
336
|
+
break
|
|
337
|
+
}
|
|
338
|
+
if (evicted === undefined) break
|
|
339
|
+
cleanupState.delete(evicted)
|
|
340
|
+
}
|
|
99
341
|
}
|
|
100
342
|
|
|
101
343
|
/** Coalesce cleanup work per artifact root so foreground tools only schedule it. */
|
|
@@ -104,21 +346,31 @@ export function scheduleArtifactRetention(root, options = {}) {
|
|
|
104
346
|
const now = Date.now()
|
|
105
347
|
let state = cleanupState.get(root)
|
|
106
348
|
if (!state) {
|
|
107
|
-
state = { lastStartedAt: 0, promise: undefined }
|
|
349
|
+
state = { lastStartedAt: 0, promise: undefined, protectedRunIds: new Set() }
|
|
108
350
|
cleanupState.set(root, state)
|
|
109
351
|
}
|
|
352
|
+
|
|
353
|
+
if (state.promise) {
|
|
354
|
+
addScheduledProtection(state, options)
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
|
|
110
358
|
const intervalMs = finitePositive(options.intervalMs, DEFAULT_ARTIFACT_CLEANUP_INTERVAL_MS)
|
|
111
|
-
if (
|
|
359
|
+
if (now - state.lastStartedAt < intervalMs) return
|
|
360
|
+
|
|
361
|
+
addScheduledProtection(state, options)
|
|
112
362
|
state.lastStartedAt = now
|
|
113
|
-
state.promise = cleanupArtifactRuns(root,
|
|
363
|
+
state.promise = cleanupArtifactRuns(root, {
|
|
364
|
+
...options,
|
|
365
|
+
protectRunIds: state.protectedRunIds,
|
|
366
|
+
})
|
|
114
367
|
.catch(() => undefined)
|
|
115
|
-
.finally(() => {
|
|
368
|
+
.finally(() => {
|
|
369
|
+
state.promise = undefined
|
|
370
|
+
state.protectedRunIds.clear()
|
|
371
|
+
})
|
|
116
372
|
|
|
117
373
|
cleanupState.delete(root)
|
|
118
374
|
cleanupState.set(root, state)
|
|
119
|
-
|
|
120
|
-
const oldest = cleanupState.keys().next().value
|
|
121
|
-
if (oldest === undefined) break
|
|
122
|
-
cleanupState.delete(oldest)
|
|
123
|
-
}
|
|
375
|
+
trimCleanupState()
|
|
124
376
|
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
readResponseTextBounded,
|
|
16
16
|
} from './http-body-limit.js'
|
|
17
17
|
import { stripTrailingSlashes } from './string-normalization.js'
|
|
18
|
+
import { currentVisionProviderTransport } from './vision-provider-transport.js'
|
|
18
19
|
|
|
19
20
|
export const CATALOG_ROUTING_CORRECTIONS = [
|
|
20
21
|
{
|
|
@@ -254,7 +255,11 @@ export async function callAnthropicCompatible(provider, messages, options = {})
|
|
|
254
255
|
const url = `${stripTrailingSlashes(provider.baseURL)}/v1/messages`
|
|
255
256
|
let response
|
|
256
257
|
try {
|
|
257
|
-
|
|
258
|
+
const transport = currentVisionProviderTransport()
|
|
259
|
+
const requestFetch = transport
|
|
260
|
+
? (input, init) => transport.fetch(input, init, { providerName: provider.name, allowProxy: true })
|
|
261
|
+
: fetch
|
|
262
|
+
response = await requestFetch(url, {
|
|
258
263
|
method: 'POST',
|
|
259
264
|
headers,
|
|
260
265
|
body: JSON.stringify(system === undefined ? body : { ...body, system }),
|