@anionex/dsh-vision-toolkit 0.1.5
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/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +383 -0
- package/README.zh.md +383 -0
- package/assets/dsh-conversation-artifact.png +0 -0
- package/assets/dsh-conversation-image-qa-top.png +0 -0
- package/assets/dsh-conversation-image-qa.png +0 -0
- package/assets/dsh-conversation-pixel-diff.png +0 -0
- package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
- package/assets/dsh-conversation-screenshot-debugging.png +0 -0
- package/assets/dsh-conversation-tool-call.png +0 -0
- package/assets/dsh-conversation-vision-trace.png +0 -0
- package/assets/hero.png +0 -0
- package/assets/social-preview.png +0 -0
- package/assets/upstream/README.md +16 -0
- package/assets/upstream/image-qa.webp +0 -0
- package/assets/upstream/infographic-reference.webp +0 -0
- package/assets/upstream/infographic-result.webp +0 -0
- package/assets/upstream/screenshot-debugging.webp +0 -0
- package/assets/upstream/ui-result.webp +0 -0
- package/assets/upstream/ui-sketch.webp +0 -0
- package/assets/vision-settings.png +0 -0
- package/cordis.patch.yml +6 -0
- package/docs/assets/vision-settings.png +0 -0
- package/docs/requirements-traceability/README.i18n.yaml +6 -0
- package/docs/requirements-traceability/README.md +75 -0
- package/docs/requirements-traceability/README.zh.md +75 -0
- package/examples/ui-restoration/README.i18n.yaml +6 -0
- package/examples/ui-restoration/README.md +70 -0
- package/examples/ui-restoration/README.zh.md +70 -0
- package/examples/ui-restoration/assets/final-heatmap.png +0 -0
- package/examples/ui-restoration/assets/final-report.json +83 -0
- package/examples/ui-restoration/assets/implementation.png +0 -0
- package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
- package/examples/ui-restoration/assets/initial-report.json +83 -0
- package/examples/ui-restoration/assets/initial.png +0 -0
- package/examples/ui-restoration/assets/metrics.json +12 -0
- package/examples/ui-restoration/assets/reference.png +0 -0
- package/examples/ui-restoration/implementation.html +94 -0
- package/examples/ui-restoration/initial.html +57 -0
- package/lib/artifact-access.js +369 -0
- package/lib/artifact-access.js.map +1 -0
- package/lib/artifacts.js +56 -0
- package/lib/artifacts.js.map +1 -0
- package/lib/client.js +952 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +117 -0
- package/lib/config.js.map +1 -0
- package/lib/errors.js +56 -0
- package/lib/errors.js.map +1 -0
- package/lib/exposure.js +213 -0
- package/lib/exposure.js.map +1 -0
- package/lib/index.js +97 -0
- package/lib/index.js.map +1 -0
- package/lib/paste-images.js +199 -0
- package/lib/paste-images.js.map +1 -0
- package/lib/paths.js +325 -0
- package/lib/paths.js.map +1 -0
- package/lib/runtime-install.js +601 -0
- package/lib/runtime-install.js.map +1 -0
- package/lib/runtime-manager.js +126 -0
- package/lib/runtime-manager.js.map +1 -0
- package/lib/runtime.js +1344 -0
- package/lib/runtime.js.map +1 -0
- package/lib/skill.js +139 -0
- package/lib/skill.js.map +1 -0
- package/lib/tools.js +528 -0
- package/lib/tools.js.map +1 -0
- package/lib/types/artifact-access.d.ts +61 -0
- package/lib/types/artifact-access.d.ts.map +1 -0
- package/lib/types/artifacts.d.ts +42 -0
- package/lib/types/artifacts.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +179 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/paste-images.d.ts +57 -0
- package/lib/types/client/paste-images.d.ts.map +1 -0
- package/lib/types/config.d.ts +73 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/errors.d.ts +35 -0
- package/lib/types/errors.d.ts.map +1 -0
- package/lib/types/exposure.d.ts +40 -0
- package/lib/types/exposure.d.ts.map +1 -0
- package/lib/types/index.d.ts +18 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/paste-images.d.ts +21 -0
- package/lib/types/paste-images.d.ts.map +1 -0
- package/lib/types/paths.d.ts +107 -0
- package/lib/types/paths.d.ts.map +1 -0
- package/lib/types/runtime-install.d.ts +49 -0
- package/lib/types/runtime-install.d.ts.map +1 -0
- package/lib/types/runtime-manager.d.ts +60 -0
- package/lib/types/runtime-manager.d.ts.map +1 -0
- package/lib/types/runtime.d.ts +389 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/skill.d.ts +15 -0
- package/lib/types/skill.d.ts.map +1 -0
- package/lib/types/tools.d.ts +22 -0
- package/lib/types/tools.d.ts.map +1 -0
- package/lib/types/upstream.d.ts +207 -0
- package/lib/types/upstream.d.ts.map +1 -0
- package/lib/types/version.d.ts +15 -0
- package/lib/types/version.d.ts.map +1 -0
- package/lib/types/web-request.d.ts +4 -0
- package/lib/types/web-request.d.ts.map +1 -0
- package/lib/types/web.d.ts +74 -0
- package/lib/types/web.d.ts.map +1 -0
- package/lib/upstream.js +675 -0
- package/lib/upstream.js.map +1 -0
- package/lib/version.js +18 -0
- package/lib/version.js.map +1 -0
- package/lib/web-request.js +20 -0
- package/lib/web-request.js.map +1 -0
- package/lib/web.js +244 -0
- package/lib/web.js.map +1 -0
- package/package.json +139 -0
- package/runtime/requirements.lock +3 -0
- package/src/artifact-access.ts +386 -0
- package/src/artifacts.ts +85 -0
- package/src/client/index.tsx +866 -0
- package/src/client/paste-images.tsx +426 -0
- package/src/config.ts +177 -0
- package/src/errors.ts +62 -0
- package/src/exposure.ts +227 -0
- package/src/index.ts +122 -0
- package/src/paste-images.ts +234 -0
- package/src/paths.ts +348 -0
- package/src/runtime-install.ts +723 -0
- package/src/runtime-manager.ts +166 -0
- package/src/runtime.ts +1783 -0
- package/src/skill.ts +143 -0
- package/src/tools.ts +668 -0
- package/src/upstream.ts +861 -0
- package/src/version.ts +37 -0
- package/src/web-request.ts +17 -0
- package/src/web.ts +329 -0
- package/vendor/agent-vision-toolkit/CHANGELOG.md +16 -0
- package/vendor/agent-vision-toolkit/LICENSE +21 -0
- package/vendor/agent-vision-toolkit/README.md +399 -0
- package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +89 -0
- package/vendor/agent-vision-toolkit/bin/crop +90 -0
- package/vendor/agent-vision-toolkit/bin/detect +13 -0
- package/vendor/agent-vision-toolkit/bin/glance +93 -0
- package/vendor/agent-vision-toolkit/bin/ground +13 -0
- package/vendor/agent-vision-toolkit/bin/trace +129 -0
- package/vendor/agent-vision-toolkit/detect.py +56 -0
- package/vendor/agent-vision-toolkit/ground.py +216 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +108 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
- package/vendor/agent-vision-toolkit/vision_client.py +156 -0
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reproducible upstream runtime preparation. Managed mode uses the packaged,
|
|
3
|
+
* hash-verified agent-vision-toolkit snapshot plus an atomic isolated Python
|
|
4
|
+
* environment; external mode accepts only the pinned clean Git commit or an
|
|
5
|
+
* exact exported copy of the packaged snapshot.
|
|
6
|
+
* @module dsh-vision-toolkit/runtime-install
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
10
|
+
import {
|
|
11
|
+
lstat,
|
|
12
|
+
mkdir,
|
|
13
|
+
mkdtemp,
|
|
14
|
+
readFile,
|
|
15
|
+
readdir,
|
|
16
|
+
realpath,
|
|
17
|
+
rename,
|
|
18
|
+
rm,
|
|
19
|
+
stat,
|
|
20
|
+
utimes,
|
|
21
|
+
writeFile,
|
|
22
|
+
} from 'node:fs/promises'
|
|
23
|
+
import { homedir } from 'node:os'
|
|
24
|
+
import { dirname, join, resolve } from 'node:path'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
26
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
27
|
+
import type { ResolvedVisionToolkitConfig } from './config.ts'
|
|
28
|
+
import { VisionToolkitError } from './errors.ts'
|
|
29
|
+
import { UPSTREAM_COMMIT, UPSTREAM_REPOSITORY, UPSTREAM_VERSION } from './version.ts'
|
|
30
|
+
|
|
31
|
+
/** One executable plus fixed prefix arguments (for example Windows `py -3`). */
|
|
32
|
+
export interface RuntimeCommand {
|
|
33
|
+
program: string
|
|
34
|
+
prefix: string[]
|
|
35
|
+
display: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Prepared source and interpreter facts consumed by the upstream adapter. */
|
|
39
|
+
export interface PreparedUpstreamRuntime {
|
|
40
|
+
source: 'managed' | 'external'
|
|
41
|
+
root: string
|
|
42
|
+
python: RuntimeCommand
|
|
43
|
+
cleanHome: string
|
|
44
|
+
pythonVersion: string
|
|
45
|
+
dependencies: Record<string, string>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface CommandResult {
|
|
49
|
+
stdout: string
|
|
50
|
+
stderr: string
|
|
51
|
+
exitCode: number | null
|
|
52
|
+
timedOut: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface UpstreamManifest {
|
|
56
|
+
schemaVersion: number
|
|
57
|
+
repository: string
|
|
58
|
+
version: string
|
|
59
|
+
commit: string
|
|
60
|
+
contentSha256: string
|
|
61
|
+
files: Array<{ path: string; bytes: number; sha256: string }>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface RuntimeMarker {
|
|
65
|
+
schemaVersion: 1
|
|
66
|
+
upstreamCommit: string
|
|
67
|
+
upstreamContentSha256: string
|
|
68
|
+
requirementsSha256: string
|
|
69
|
+
pythonVersion: string
|
|
70
|
+
dependencies: Record<string, string>
|
|
71
|
+
manager: 'uv' | 'venv-pip'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const PACKAGE_ROOT = dirname(fileURLToPath(new URL('../package.json', import.meta.url)))
|
|
75
|
+
const BUNDLED_ROOT = join(PACKAGE_ROOT, 'vendor', 'agent-vision-toolkit')
|
|
76
|
+
const MANIFEST_PATH = join(BUNDLED_ROOT, 'UPSTREAM_MANIFEST.json')
|
|
77
|
+
const REQUIREMENTS_PATH = join(PACKAGE_ROOT, 'runtime', 'requirements.lock')
|
|
78
|
+
const PREPARE_TIMEOUT_MS = 10 * 60 * 1000
|
|
79
|
+
const PROBE_TIMEOUT_MS = 30_000
|
|
80
|
+
const LOCK_STALE_MS = 15 * 60 * 1000
|
|
81
|
+
const LOCK_HEARTBEAT_MS = 5_000
|
|
82
|
+
|
|
83
|
+
/** Absolute root of the packaged upstream snapshot. */
|
|
84
|
+
export function bundledUpstreamRoot(): string {
|
|
85
|
+
return BUNDLED_ROOT
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Convert one command into a user-facing executable string. */
|
|
89
|
+
export function displayCommand(command: RuntimeCommand): string {
|
|
90
|
+
return [command.program, ...command.prefix].join(' ')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sha256(bytes: string | Buffer): string {
|
|
94
|
+
return createHash('sha256').update(bytes).digest('hex')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isolatedPythonEnvironment(home: string): NodeJS.ProcessEnv {
|
|
98
|
+
return {
|
|
99
|
+
HOME: home,
|
|
100
|
+
USERPROFILE: home,
|
|
101
|
+
LOCALAPPDATA: home,
|
|
102
|
+
PYTHONHOME: undefined,
|
|
103
|
+
PYTHONPATH: undefined,
|
|
104
|
+
VIRTUAL_ENV: undefined,
|
|
105
|
+
PYTHONDONTWRITEBYTECODE: '1',
|
|
106
|
+
PYTHONIOENCODING: 'utf-8',
|
|
107
|
+
PYTHONNOUSERSITE: '1',
|
|
108
|
+
PYTHONUTF8: '1',
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function runCollected(
|
|
113
|
+
ctx: Context,
|
|
114
|
+
argv: readonly string[],
|
|
115
|
+
cwd: string,
|
|
116
|
+
options: { timeoutMs?: number; env?: NodeJS.ProcessEnv } = {},
|
|
117
|
+
): Promise<CommandResult> {
|
|
118
|
+
const controller = new AbortController()
|
|
119
|
+
let timedOut = false
|
|
120
|
+
const timer = setTimeout(() => {
|
|
121
|
+
timedOut = true
|
|
122
|
+
controller.abort()
|
|
123
|
+
}, options.timeoutMs ?? PROBE_TIMEOUT_MS)
|
|
124
|
+
try {
|
|
125
|
+
const handle = ctx.subprocess.spawn({
|
|
126
|
+
argv,
|
|
127
|
+
cwd,
|
|
128
|
+
stdio: {
|
|
129
|
+
stdin: 'ignore',
|
|
130
|
+
stdout: { maxBytes: 256 * 1024 },
|
|
131
|
+
stderr: { maxBytes: 256 * 1024 },
|
|
132
|
+
},
|
|
133
|
+
graceMs: 2000,
|
|
134
|
+
signal: controller.signal,
|
|
135
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
136
|
+
})
|
|
137
|
+
const outcome = await handle.done
|
|
138
|
+
return {
|
|
139
|
+
stdout: handle.collected.stdout?.readFrom(0).text ?? '',
|
|
140
|
+
stderr: handle.collected.stderr?.readFrom(0).text ?? '',
|
|
141
|
+
exitCode: outcome.exitCode,
|
|
142
|
+
timedOut,
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (timedOut) return { stdout: '', stderr: '', exitCode: null, timedOut: true }
|
|
146
|
+
throw error
|
|
147
|
+
} finally {
|
|
148
|
+
clearTimeout(timer)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function readManifest(path = MANIFEST_PATH): Promise<UpstreamManifest> {
|
|
153
|
+
let parsed: unknown
|
|
154
|
+
try {
|
|
155
|
+
parsed = JSON.parse(await readFile(path, 'utf8'))
|
|
156
|
+
} catch (error) {
|
|
157
|
+
throw new VisionToolkitError('runtime', `upstream manifest is unreadable: ${path}`, { cause: error })
|
|
158
|
+
}
|
|
159
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
160
|
+
throw new VisionToolkitError('runtime', `upstream manifest is not an object: ${path}`)
|
|
161
|
+
}
|
|
162
|
+
const manifest = parsed as Partial<UpstreamManifest>
|
|
163
|
+
if (
|
|
164
|
+
manifest.schemaVersion !== 1
|
|
165
|
+
|| manifest.repository !== UPSTREAM_REPOSITORY
|
|
166
|
+
|| manifest.version !== UPSTREAM_VERSION
|
|
167
|
+
|| manifest.commit !== UPSTREAM_COMMIT
|
|
168
|
+
|| !/^[a-f0-9]{64}$/.test(manifest.contentSha256 ?? '')
|
|
169
|
+
|| typeof manifest.contentSha256 !== 'string'
|
|
170
|
+
|| !Array.isArray(manifest.files)
|
|
171
|
+
|| manifest.files.length === 0
|
|
172
|
+
) {
|
|
173
|
+
throw new VisionToolkitError('runtime', `upstream manifest identity does not match the packaged pin: ${path}`)
|
|
174
|
+
}
|
|
175
|
+
const seen = new Set<string>()
|
|
176
|
+
let previous = ''
|
|
177
|
+
for (const entry of manifest.files) {
|
|
178
|
+
if (
|
|
179
|
+
typeof entry !== 'object'
|
|
180
|
+
|| entry === null
|
|
181
|
+
|| typeof entry.path !== 'string'
|
|
182
|
+
|| entry.path.length === 0
|
|
183
|
+
|| entry.path.includes('\\')
|
|
184
|
+
|| entry.path.startsWith('/')
|
|
185
|
+
|| entry.path.split('/').some(segment => segment.length === 0 || segment === '.' || segment === '..')
|
|
186
|
+
|| !Number.isInteger(entry.bytes)
|
|
187
|
+
|| entry.bytes < 0
|
|
188
|
+
|| !/^[a-f0-9]{64}$/.test(entry.sha256)
|
|
189
|
+
|| seen.has(entry.path)
|
|
190
|
+
|| (previous.length > 0 && previous >= entry.path)
|
|
191
|
+
) {
|
|
192
|
+
throw new VisionToolkitError('runtime', `upstream manifest contains an invalid file entry: ${path}`)
|
|
193
|
+
}
|
|
194
|
+
seen.add(entry.path)
|
|
195
|
+
previous = entry.path
|
|
196
|
+
}
|
|
197
|
+
return manifest as UpstreamManifest
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Verify every packaged upstream file against the committed content manifest. */
|
|
201
|
+
export async function verifyBundledUpstream(): Promise<UpstreamManifest> {
|
|
202
|
+
const manifest = await readManifest()
|
|
203
|
+
const rows: string[] = []
|
|
204
|
+
for (const entry of manifest.files) {
|
|
205
|
+
const path = join(BUNDLED_ROOT, ...entry.path.split('/'))
|
|
206
|
+
let bytes: Buffer
|
|
207
|
+
try {
|
|
208
|
+
const info = await lstat(path)
|
|
209
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
210
|
+
throw new VisionToolkitError('runtime', `packaged upstream entry is not a regular file: ${entry.path}`)
|
|
211
|
+
}
|
|
212
|
+
bytes = await readFile(path)
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (error instanceof VisionToolkitError) throw error
|
|
215
|
+
throw new VisionToolkitError('runtime', `packaged upstream file is missing: ${entry.path}`, { cause: error })
|
|
216
|
+
}
|
|
217
|
+
const digest = sha256(bytes)
|
|
218
|
+
if (bytes.length !== entry.bytes || digest !== entry.sha256) {
|
|
219
|
+
throw new VisionToolkitError('runtime', `packaged upstream file failed its hash check: ${entry.path}`)
|
|
220
|
+
}
|
|
221
|
+
rows.push(`${entry.path}\0${digest}\n`)
|
|
222
|
+
}
|
|
223
|
+
if (sha256(rows.join('')) !== manifest.contentSha256) {
|
|
224
|
+
throw new VisionToolkitError('runtime', 'packaged upstream aggregate hash does not match its manifest')
|
|
225
|
+
}
|
|
226
|
+
return manifest
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function pythonMetadata(
|
|
230
|
+
ctx: Context,
|
|
231
|
+
command: RuntimeCommand,
|
|
232
|
+
cwd: string,
|
|
233
|
+
): Promise<{ version: string; major: number; minor: number } | undefined> {
|
|
234
|
+
const script = 'import json,sys; print(json.dumps({"version":sys.version.split()[0],"major":sys.version_info[0],"minor":sys.version_info[1]}))'
|
|
235
|
+
let result: CommandResult
|
|
236
|
+
try {
|
|
237
|
+
result = await runCollected(ctx, [command.program, ...command.prefix, '-c', script], cwd, {
|
|
238
|
+
env: isolatedPythonEnvironment(cwd),
|
|
239
|
+
})
|
|
240
|
+
} catch {
|
|
241
|
+
return undefined
|
|
242
|
+
}
|
|
243
|
+
if (result.exitCode !== 0 || result.timedOut) return undefined
|
|
244
|
+
try {
|
|
245
|
+
const parsed = JSON.parse(result.stdout) as { version?: unknown; major?: unknown; minor?: unknown }
|
|
246
|
+
if (typeof parsed.version !== 'string' || typeof parsed.major !== 'number' || typeof parsed.minor !== 'number') {
|
|
247
|
+
return undefined
|
|
248
|
+
}
|
|
249
|
+
return { version: parsed.version, major: parsed.major, minor: parsed.minor }
|
|
250
|
+
} catch {
|
|
251
|
+
return undefined
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function resolveBootstrapPython(
|
|
256
|
+
ctx: Context,
|
|
257
|
+
configured: string | undefined,
|
|
258
|
+
cwd: string,
|
|
259
|
+
): Promise<{ command: RuntimeCommand; version: string; major: number; minor: number }> {
|
|
260
|
+
const candidates: RuntimeCommand[] = configured === undefined
|
|
261
|
+
? process.platform === 'win32'
|
|
262
|
+
? [
|
|
263
|
+
{ program: 'python', prefix: [], display: 'python' },
|
|
264
|
+
{ program: 'py', prefix: ['-3'], display: 'py -3' },
|
|
265
|
+
{ program: 'python3', prefix: [], display: 'python3' },
|
|
266
|
+
]
|
|
267
|
+
: [
|
|
268
|
+
{ program: 'python3', prefix: [], display: 'python3' },
|
|
269
|
+
{ program: 'python', prefix: [], display: 'python' },
|
|
270
|
+
]
|
|
271
|
+
: [{ program: configured, prefix: [], display: configured }]
|
|
272
|
+
for (const command of candidates) {
|
|
273
|
+
const metadata = await pythonMetadata(ctx, command, cwd)
|
|
274
|
+
if (metadata !== undefined && (metadata.major > 3 || metadata.major === 3 && metadata.minor >= 11)) {
|
|
275
|
+
return { command, ...metadata }
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
throw new VisionToolkitError(
|
|
279
|
+
'runtime',
|
|
280
|
+
configured === undefined
|
|
281
|
+
? 'vision-toolkit requires Python 3.11 or newer; tried python3, python, and the Windows py launcher'
|
|
282
|
+
: `vision-toolkit requires Python 3.11 or newer: ${configured}`,
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Persistent per-DSH-home cache root shared by runtime and Web support files. */
|
|
287
|
+
export function visionToolkitStateRoot(): string {
|
|
288
|
+
const dshHome = process.env.DSH_HOME?.trim()
|
|
289
|
+
const base = dshHome === undefined || dshHome.length === 0 ? join(homedir(), '.dsh') : resolve(dshHome)
|
|
290
|
+
return join(base, 'cache', 'dsh-vision-toolkit')
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function expandHome(path: string): string {
|
|
294
|
+
if (path === '~') return homedir()
|
|
295
|
+
if (path.startsWith('~/') || path.startsWith('~\\')) return join(homedir(), path.slice(2))
|
|
296
|
+
return path
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function venvPython(root: string): string {
|
|
300
|
+
return process.platform === 'win32' ? join(root, 'Scripts', 'python.exe') : join(root, 'bin', 'python')
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function dependencyVersions(
|
|
304
|
+
ctx: Context,
|
|
305
|
+
python: RuntimeCommand,
|
|
306
|
+
cwd: string,
|
|
307
|
+
): Promise<Record<string, string>> {
|
|
308
|
+
const script = [
|
|
309
|
+
'import json',
|
|
310
|
+
'from importlib.metadata import version',
|
|
311
|
+
'import PIL',
|
|
312
|
+
'import numpy',
|
|
313
|
+
'import vtracer',
|
|
314
|
+
'print(json.dumps({"pillow":version("pillow"),"numpy":version("numpy"),"vtracer":version("vtracer")}))',
|
|
315
|
+
].join(';')
|
|
316
|
+
let result: CommandResult
|
|
317
|
+
try {
|
|
318
|
+
result = await runCollected(ctx, [python.program, ...python.prefix, '-c', script], cwd, {
|
|
319
|
+
env: isolatedPythonEnvironment(cwd),
|
|
320
|
+
})
|
|
321
|
+
} catch (error) {
|
|
322
|
+
throw new VisionToolkitError('runtime', `failed to start ${displayCommand(python)}`, { cause: error })
|
|
323
|
+
}
|
|
324
|
+
if (result.exitCode !== 0 || result.timedOut) {
|
|
325
|
+
throw new VisionToolkitError(
|
|
326
|
+
'runtime',
|
|
327
|
+
`vision-toolkit Python dependencies are unavailable in ${displayCommand(python)}: ${result.stderr.trim() || 'probe failed'}`,
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
const parsed = JSON.parse(result.stdout) as Record<string, unknown>
|
|
332
|
+
if (Object.values(parsed).some(value => typeof value !== 'string')) throw new Error('non-string dependency version')
|
|
333
|
+
return parsed as Record<string, string>
|
|
334
|
+
} catch (error) {
|
|
335
|
+
throw new VisionToolkitError('runtime', 'vision-toolkit dependency probe returned invalid JSON', { cause: error })
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function parseLockedDependencies(requirements: Buffer): Record<string, string> {
|
|
340
|
+
const dependencies: Record<string, string> = {}
|
|
341
|
+
for (const line of requirements.toString('utf8').split(/\r?\n/)) {
|
|
342
|
+
const trimmed = line.trim()
|
|
343
|
+
if (trimmed.length === 0 || trimmed.startsWith('#')) continue
|
|
344
|
+
const match = /^([A-Za-z0-9_.-]+)==([^\s]+)$/.exec(trimmed)
|
|
345
|
+
if (match === null) {
|
|
346
|
+
throw new VisionToolkitError('runtime', `runtime/requirements.lock contains an unsupported entry: ${trimmed}`)
|
|
347
|
+
}
|
|
348
|
+
dependencies[(match[1] ?? '').toLowerCase()] = match[2] ?? ''
|
|
349
|
+
}
|
|
350
|
+
if (Object.keys(dependencies).length === 0) {
|
|
351
|
+
throw new VisionToolkitError('runtime', 'runtime/requirements.lock contains no dependencies')
|
|
352
|
+
}
|
|
353
|
+
return dependencies
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function assertLockedDependencies(actual: Record<string, string>, expected: Record<string, string>): void {
|
|
357
|
+
for (const [name, version] of Object.entries(expected)) {
|
|
358
|
+
if (actual[name] !== version) {
|
|
359
|
+
throw new VisionToolkitError(
|
|
360
|
+
'runtime',
|
|
361
|
+
`vision-toolkit Python dependency ${name} must be ${version}, received ${actual[name] ?? 'missing'}`,
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function readRuntimeMarker(path: string): Promise<RuntimeMarker | undefined> {
|
|
368
|
+
try {
|
|
369
|
+
const parsed = JSON.parse(await readFile(path, 'utf8')) as Partial<RuntimeMarker>
|
|
370
|
+
if (
|
|
371
|
+
parsed.schemaVersion !== 1
|
|
372
|
+
|| parsed.upstreamCommit !== UPSTREAM_COMMIT
|
|
373
|
+
|| typeof parsed.upstreamContentSha256 !== 'string'
|
|
374
|
+
|| typeof parsed.requirementsSha256 !== 'string'
|
|
375
|
+
|| typeof parsed.pythonVersion !== 'string'
|
|
376
|
+
|| typeof parsed.dependencies !== 'object'
|
|
377
|
+
|| parsed.dependencies === null
|
|
378
|
+
|| (parsed.manager !== 'uv' && parsed.manager !== 'venv-pip')
|
|
379
|
+
) return undefined
|
|
380
|
+
return parsed as RuntimeMarker
|
|
381
|
+
} catch {
|
|
382
|
+
return undefined
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function waitForManagedRuntime(
|
|
387
|
+
markerPath: string,
|
|
388
|
+
lockPath: string,
|
|
389
|
+
expected: Pick<RuntimeMarker, 'upstreamContentSha256' | 'requirementsSha256'>,
|
|
390
|
+
): Promise<RuntimeMarker | undefined> {
|
|
391
|
+
const started = Date.now()
|
|
392
|
+
while (Date.now() - started < PREPARE_TIMEOUT_MS) {
|
|
393
|
+
const marker = await readRuntimeMarker(markerPath)
|
|
394
|
+
if (
|
|
395
|
+
marker?.upstreamContentSha256 === expected.upstreamContentSha256
|
|
396
|
+
&& marker.requirementsSha256 === expected.requirementsSha256
|
|
397
|
+
) return marker
|
|
398
|
+
try {
|
|
399
|
+
const info = await stat(lockPath)
|
|
400
|
+
if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
|
|
401
|
+
await rm(lockPath, { recursive: true, force: true })
|
|
402
|
+
return undefined
|
|
403
|
+
}
|
|
404
|
+
} catch {
|
|
405
|
+
return undefined
|
|
406
|
+
}
|
|
407
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 250))
|
|
408
|
+
}
|
|
409
|
+
throw new VisionToolkitError('runtime', 'timed out waiting for another process to prepare the managed vision runtime')
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function releaseManagedLock(lockPath: string, owner: string): Promise<void> {
|
|
413
|
+
try {
|
|
414
|
+
if ((await readFile(join(lockPath, 'owner'), 'utf8')).trim() !== owner) return
|
|
415
|
+
} catch {
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
await rm(lockPath, { recursive: true, force: true })
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function prepareManaged(
|
|
422
|
+
ctx: Context,
|
|
423
|
+
config: ResolvedVisionToolkitConfig,
|
|
424
|
+
manifest: UpstreamManifest,
|
|
425
|
+
): Promise<PreparedUpstreamRuntime> {
|
|
426
|
+
const stateRoot = visionToolkitStateRoot()
|
|
427
|
+
await mkdir(stateRoot, { recursive: true })
|
|
428
|
+
const cleanHome = join(stateRoot, 'home')
|
|
429
|
+
await mkdir(cleanHome, { recursive: true })
|
|
430
|
+
const bootstrap = await resolveBootstrapPython(ctx, config.runtime.python, cleanHome)
|
|
431
|
+
const requirements = await readFile(REQUIREMENTS_PATH)
|
|
432
|
+
const requirementsSha256 = sha256(requirements)
|
|
433
|
+
const expectedDependencies = parseLockedDependencies(requirements)
|
|
434
|
+
const runtimeId = [
|
|
435
|
+
manifest.contentSha256.slice(0, 16),
|
|
436
|
+
requirementsSha256.slice(0, 16),
|
|
437
|
+
`py${String(bootstrap.major)}${String(bootstrap.minor)}`,
|
|
438
|
+
process.platform,
|
|
439
|
+
process.arch,
|
|
440
|
+
].join('-')
|
|
441
|
+
const finalRoot = join(stateRoot, 'python', runtimeId)
|
|
442
|
+
const parent = dirname(finalRoot)
|
|
443
|
+
await mkdir(parent, { recursive: true })
|
|
444
|
+
const markerPath = join(finalRoot, 'runtime.json')
|
|
445
|
+
const existing = await readRuntimeMarker(markerPath)
|
|
446
|
+
const interpreter = venvPython(finalRoot)
|
|
447
|
+
if (
|
|
448
|
+
existing?.upstreamContentSha256 === manifest.contentSha256
|
|
449
|
+
&& existing.requirementsSha256 === requirementsSha256
|
|
450
|
+
) {
|
|
451
|
+
const python: RuntimeCommand = { program: interpreter, prefix: [], display: interpreter }
|
|
452
|
+
const metadata = await pythonMetadata(ctx, python, cleanHome)
|
|
453
|
+
if (metadata !== undefined) {
|
|
454
|
+
try {
|
|
455
|
+
const dependencies = await dependencyVersions(ctx, python, cleanHome)
|
|
456
|
+
assertLockedDependencies(dependencies, expectedDependencies)
|
|
457
|
+
return { source: 'managed', root: BUNDLED_ROOT, python, cleanHome, pythonVersion: metadata.version, dependencies }
|
|
458
|
+
} catch {
|
|
459
|
+
// A stale/corrupt environment is rebuilt below without disturbing it until the replacement is ready.
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const lockPath = `${finalRoot}.lock`
|
|
465
|
+
const lockOwner = randomUUID()
|
|
466
|
+
let lockAcquired = false
|
|
467
|
+
try {
|
|
468
|
+
await mkdir(lockPath, { recursive: false })
|
|
469
|
+
lockAcquired = true
|
|
470
|
+
await writeFile(join(lockPath, 'owner'), `${lockOwner}\n`, { flag: 'wx' })
|
|
471
|
+
} catch (error) {
|
|
472
|
+
if (lockAcquired) {
|
|
473
|
+
await rm(lockPath, { recursive: true, force: true })
|
|
474
|
+
throw error
|
|
475
|
+
}
|
|
476
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
|
477
|
+
const completed = await waitForManagedRuntime(markerPath, lockPath, {
|
|
478
|
+
upstreamContentSha256: manifest.contentSha256,
|
|
479
|
+
requirementsSha256,
|
|
480
|
+
})
|
|
481
|
+
if (completed !== undefined) {
|
|
482
|
+
const python: RuntimeCommand = { program: interpreter, prefix: [], display: interpreter }
|
|
483
|
+
const metadata = await pythonMetadata(ctx, python, cleanHome)
|
|
484
|
+
if (metadata !== undefined) {
|
|
485
|
+
try {
|
|
486
|
+
const dependencies = await dependencyVersions(ctx, python, cleanHome)
|
|
487
|
+
assertLockedDependencies(dependencies, expectedDependencies)
|
|
488
|
+
return { source: 'managed', root: BUNDLED_ROOT, python, cleanHome, pythonVersion: metadata.version, dependencies }
|
|
489
|
+
} catch {
|
|
490
|
+
// The completed marker is unusable; reacquire the lock and rebuild it.
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return prepareManaged(ctx, config, manifest)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const staging = await mkdtemp(join(parent, '.prepare-'))
|
|
498
|
+
const installEnv: NodeJS.ProcessEnv = {
|
|
499
|
+
...isolatedPythonEnvironment(cleanHome),
|
|
500
|
+
UV_CACHE_DIR: join(stateRoot, 'uv-cache'),
|
|
501
|
+
}
|
|
502
|
+
const heartbeat = setInterval(() => {
|
|
503
|
+
const now = new Date()
|
|
504
|
+
void utimes(lockPath, now, now).catch(() => {})
|
|
505
|
+
}, LOCK_HEARTBEAT_MS)
|
|
506
|
+
heartbeat.unref()
|
|
507
|
+
try {
|
|
508
|
+
let manager: RuntimeMarker['manager'] = 'venv-pip'
|
|
509
|
+
let created = false
|
|
510
|
+
if (bootstrap.command.prefix.length === 0) {
|
|
511
|
+
try {
|
|
512
|
+
const uv = await runCollected(ctx, ['uv', '--version'], stateRoot, { env: installEnv })
|
|
513
|
+
if (uv.exitCode === 0 && !uv.timedOut) {
|
|
514
|
+
const executableEnv = Object.fromEntries(
|
|
515
|
+
Object.entries(installEnv).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
|
516
|
+
)
|
|
517
|
+
const interpreter = await ctx.subprocess.resolveExecutable(bootstrap.command.program, executableEnv)
|
|
518
|
+
const create = await runCollected(
|
|
519
|
+
ctx,
|
|
520
|
+
['uv', 'venv', '--python', interpreter, staging],
|
|
521
|
+
stateRoot,
|
|
522
|
+
{ timeoutMs: PREPARE_TIMEOUT_MS, env: installEnv },
|
|
523
|
+
)
|
|
524
|
+
if (create.exitCode !== 0 || create.timedOut) {
|
|
525
|
+
throw new VisionToolkitError('runtime', `uv failed to create the managed runtime: ${create.stderr.trim()}`)
|
|
526
|
+
}
|
|
527
|
+
const install = await runCollected(
|
|
528
|
+
ctx,
|
|
529
|
+
['uv', 'pip', 'install', '--python', venvPython(staging), '--requirement', REQUIREMENTS_PATH],
|
|
530
|
+
stateRoot,
|
|
531
|
+
{ timeoutMs: PREPARE_TIMEOUT_MS, env: installEnv },
|
|
532
|
+
)
|
|
533
|
+
if (install.exitCode !== 0 || install.timedOut) {
|
|
534
|
+
throw new VisionToolkitError('runtime', `uv failed to install managed runtime dependencies: ${install.stderr.trim()}`)
|
|
535
|
+
}
|
|
536
|
+
manager = 'uv'
|
|
537
|
+
created = true
|
|
538
|
+
}
|
|
539
|
+
} catch (error) {
|
|
540
|
+
if (error instanceof VisionToolkitError) throw error
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (!created) {
|
|
544
|
+
const create = await runCollected(
|
|
545
|
+
ctx,
|
|
546
|
+
[bootstrap.command.program, ...bootstrap.command.prefix, '-m', 'venv', staging],
|
|
547
|
+
stateRoot,
|
|
548
|
+
{ timeoutMs: PREPARE_TIMEOUT_MS, env: installEnv },
|
|
549
|
+
)
|
|
550
|
+
if (create.exitCode !== 0 || create.timedOut) {
|
|
551
|
+
throw new VisionToolkitError('runtime', `Python failed to create the managed runtime: ${create.stderr.trim()}`)
|
|
552
|
+
}
|
|
553
|
+
const install = await runCollected(
|
|
554
|
+
ctx,
|
|
555
|
+
[venvPython(staging), '-m', 'pip', 'install', '--disable-pip-version-check', '--no-input', '-r', REQUIREMENTS_PATH],
|
|
556
|
+
stateRoot,
|
|
557
|
+
{ timeoutMs: PREPARE_TIMEOUT_MS, env: installEnv },
|
|
558
|
+
)
|
|
559
|
+
if (install.exitCode !== 0 || install.timedOut) {
|
|
560
|
+
throw new VisionToolkitError('runtime', `pip failed to install managed runtime dependencies: ${install.stderr.trim()}`)
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const stagedPython: RuntimeCommand = { program: venvPython(staging), prefix: [], display: venvPython(staging) }
|
|
564
|
+
const metadata = await pythonMetadata(ctx, stagedPython, cleanHome)
|
|
565
|
+
if (metadata === undefined) throw new VisionToolkitError('runtime', 'managed Python runtime did not start after installation')
|
|
566
|
+
const dependencies = await dependencyVersions(ctx, stagedPython, cleanHome)
|
|
567
|
+
assertLockedDependencies(dependencies, expectedDependencies)
|
|
568
|
+
const marker: RuntimeMarker = {
|
|
569
|
+
schemaVersion: 1,
|
|
570
|
+
upstreamCommit: UPSTREAM_COMMIT,
|
|
571
|
+
upstreamContentSha256: manifest.contentSha256,
|
|
572
|
+
requirementsSha256,
|
|
573
|
+
pythonVersion: metadata.version,
|
|
574
|
+
dependencies,
|
|
575
|
+
manager,
|
|
576
|
+
}
|
|
577
|
+
await writeFile(join(staging, 'runtime.json'), `${JSON.stringify(marker, null, 2)}\n`)
|
|
578
|
+
const quarantine = `${finalRoot}.replaced-${randomUUID()}`
|
|
579
|
+
let quarantined = false
|
|
580
|
+
try {
|
|
581
|
+
await rename(finalRoot, quarantine)
|
|
582
|
+
quarantined = true
|
|
583
|
+
} catch (error) {
|
|
584
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
585
|
+
}
|
|
586
|
+
try {
|
|
587
|
+
await rename(staging, finalRoot)
|
|
588
|
+
} catch (error) {
|
|
589
|
+
if (quarantined) {
|
|
590
|
+
try {
|
|
591
|
+
await rename(quarantine, finalRoot)
|
|
592
|
+
} catch (restoreError) {
|
|
593
|
+
throw new VisionToolkitError(
|
|
594
|
+
'runtime',
|
|
595
|
+
`managed runtime replacement failed and the prior runtime could not be restored; recovery copy: ${quarantine}`,
|
|
596
|
+
{ cause: new AggregateError([error, restoreError]) },
|
|
597
|
+
)
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
throw error
|
|
601
|
+
}
|
|
602
|
+
await rm(quarantine, { recursive: true, force: true })
|
|
603
|
+
const python: RuntimeCommand = { program: interpreter, prefix: [], display: interpreter }
|
|
604
|
+
return { source: 'managed', root: BUNDLED_ROOT, python, cleanHome, pythonVersion: metadata.version, dependencies }
|
|
605
|
+
} finally {
|
|
606
|
+
clearInterval(heartbeat)
|
|
607
|
+
await rm(staging, { recursive: true, force: true })
|
|
608
|
+
await releaseManagedLock(lockPath, lockOwner)
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function snapshotFiles(root: string): Promise<string[]> {
|
|
613
|
+
const result: string[] = []
|
|
614
|
+
async function visit(directory: string, prefix: string): Promise<void> {
|
|
615
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
616
|
+
const relativePath = prefix.length === 0 ? entry.name : `${prefix}/${entry.name}`
|
|
617
|
+
if (entry.isDirectory()) await visit(join(directory, entry.name), relativePath)
|
|
618
|
+
else if (entry.isFile()) result.push(relativePath)
|
|
619
|
+
else throw new VisionToolkitError('runtime', `external snapshot contains a non-regular entry: ${relativePath}`)
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
await visit(root, '')
|
|
623
|
+
return result.sort()
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function externalMatchesBundledSnapshot(root: string, expected: UpstreamManifest): Promise<boolean> {
|
|
627
|
+
const path = join(root, 'UPSTREAM_MANIFEST.json')
|
|
628
|
+
try {
|
|
629
|
+
const manifest = await readManifest(path)
|
|
630
|
+
if (
|
|
631
|
+
manifest.contentSha256 !== expected.contentSha256
|
|
632
|
+
|| JSON.stringify(manifest.files) !== JSON.stringify(expected.files)
|
|
633
|
+
) return false
|
|
634
|
+
const expectedFiles = [...expected.files.map(entry => entry.path), 'UPSTREAM_MANIFEST.json'].sort()
|
|
635
|
+
if (JSON.stringify(await snapshotFiles(root)) !== JSON.stringify(expectedFiles)) return false
|
|
636
|
+
for (const entry of expected.files) {
|
|
637
|
+
const target = join(root, ...entry.path.split('/'))
|
|
638
|
+
const info = await lstat(target)
|
|
639
|
+
if (!info.isFile() || info.isSymbolicLink()) return false
|
|
640
|
+
const bytes = await readFile(target)
|
|
641
|
+
if (bytes.length !== entry.bytes || sha256(bytes) !== entry.sha256) return false
|
|
642
|
+
}
|
|
643
|
+
return true
|
|
644
|
+
} catch {
|
|
645
|
+
return false
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async function verifyExternalCheckout(ctx: Context, root: string, expected: UpstreamManifest): Promise<void> {
|
|
650
|
+
const exactSnapshot = await externalMatchesBundledSnapshot(root, expected)
|
|
651
|
+
if (exactSnapshot) return
|
|
652
|
+
let head: CommandResult
|
|
653
|
+
try {
|
|
654
|
+
head = await runCollected(ctx, ['git', '-C', root, 'rev-parse', 'HEAD'], root)
|
|
655
|
+
} catch (error) {
|
|
656
|
+
throw new VisionToolkitError(
|
|
657
|
+
'runtime',
|
|
658
|
+
`external agent-vision-toolkit must be the clean pinned commit ${UPSTREAM_COMMIT} or an exact exported snapshot`,
|
|
659
|
+
{ cause: error },
|
|
660
|
+
)
|
|
661
|
+
}
|
|
662
|
+
if (head.exitCode !== 0 || head.stdout.trim() !== UPSTREAM_COMMIT) {
|
|
663
|
+
throw new VisionToolkitError('runtime', `external agent-vision-toolkit must be pinned at commit ${UPSTREAM_COMMIT}`)
|
|
664
|
+
}
|
|
665
|
+
const topLevel = await runCollected(ctx, ['git', '-C', root, 'rev-parse', '--show-toplevel'], root)
|
|
666
|
+
let resolvedTopLevel: string | undefined
|
|
667
|
+
try {
|
|
668
|
+
resolvedTopLevel = topLevel.exitCode === 0 ? await realpath(topLevel.stdout.trim()) : undefined
|
|
669
|
+
} catch {
|
|
670
|
+
resolvedTopLevel = undefined
|
|
671
|
+
}
|
|
672
|
+
if (resolvedTopLevel !== root) {
|
|
673
|
+
throw new VisionToolkitError('runtime', 'external agent-vision-toolkit path must be the checkout root')
|
|
674
|
+
}
|
|
675
|
+
const statusResult = await runCollected(ctx, ['git', '-C', root, 'status', '--porcelain=v1', '--untracked-files=all'], root)
|
|
676
|
+
if (statusResult.exitCode !== 0 || statusResult.stdout.trim().length > 0) {
|
|
677
|
+
throw new VisionToolkitError('runtime', 'external agent-vision-toolkit checkout has modified tracked files; use managed mode or a clean pinned checkout')
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async function prepareExternal(
|
|
682
|
+
ctx: Context,
|
|
683
|
+
config: ResolvedVisionToolkitConfig,
|
|
684
|
+
manifest: UpstreamManifest,
|
|
685
|
+
): Promise<PreparedUpstreamRuntime> {
|
|
686
|
+
const configured = config.runtime.agentVisionToolkitPath
|
|
687
|
+
if (configured === undefined) {
|
|
688
|
+
throw new VisionToolkitError('config', 'runtime.agentVisionToolkitPath is required when runtime.mode is external')
|
|
689
|
+
}
|
|
690
|
+
let root: string
|
|
691
|
+
try {
|
|
692
|
+
root = await realpath(expandHome(configured))
|
|
693
|
+
} catch (error) {
|
|
694
|
+
throw new VisionToolkitError('runtime', `external agent-vision-toolkit checkout is not accessible: ${configured}`, { cause: error })
|
|
695
|
+
}
|
|
696
|
+
await verifyExternalCheckout(ctx, root, manifest)
|
|
697
|
+
const stateRoot = visionToolkitStateRoot()
|
|
698
|
+
await mkdir(stateRoot, { recursive: true })
|
|
699
|
+
const cleanHome = join(stateRoot, 'home')
|
|
700
|
+
await mkdir(cleanHome, { recursive: true })
|
|
701
|
+
const bootstrap = await resolveBootstrapPython(ctx, config.runtime.python, cleanHome)
|
|
702
|
+
const dependencies = await dependencyVersions(ctx, bootstrap.command, cleanHome)
|
|
703
|
+
assertLockedDependencies(dependencies, parseLockedDependencies(await readFile(REQUIREMENTS_PATH)))
|
|
704
|
+
return {
|
|
705
|
+
source: 'external',
|
|
706
|
+
root,
|
|
707
|
+
python: bootstrap.command,
|
|
708
|
+
cleanHome,
|
|
709
|
+
pythonVersion: bootstrap.version,
|
|
710
|
+
dependencies,
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Prepare the configured pinned runtime without making any vision API call. */
|
|
715
|
+
export async function prepareUpstreamRuntime(
|
|
716
|
+
ctx: Context,
|
|
717
|
+
config: ResolvedVisionToolkitConfig,
|
|
718
|
+
): Promise<PreparedUpstreamRuntime> {
|
|
719
|
+
const manifest = await verifyBundledUpstream()
|
|
720
|
+
return config.runtime.mode === 'managed'
|
|
721
|
+
? prepareManaged(ctx, config, manifest)
|
|
722
|
+
: prepareExternal(ctx, config, manifest)
|
|
723
|
+
}
|