@anionex/dsh-vision-toolkit 0.1.38 → 0.1.40
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.i18n.yaml +2 -2
- package/README.md +21 -42
- package/README.zh.md +20 -41
- package/assets/logo_eapi_dark.png +0 -0
- package/docs/aihubmix-gemini-vision.i18n.yaml +2 -2
- package/docs/aihubmix-gemini-vision.md +2 -2
- package/docs/aihubmix-gemini-vision.zh.md +2 -2
- package/docs/requirements-traceability/README.i18n.yaml +2 -2
- package/docs/requirements-traceability/README.md +1 -1
- package/docs/requirements-traceability/README.zh.md +1 -1
- package/lib/artifact-access.js +20 -2
- package/lib/artifact-access.js.map +1 -1
- package/lib/client.js +11 -3
- package/lib/client.js.map +1 -1
- package/lib/config.js +46 -2
- package/lib/config.js.map +1 -1
- package/lib/evidence-cache.js +2 -1
- package/lib/evidence-cache.js.map +1 -1
- package/lib/image-input-variants.js +61 -26
- package/lib/image-input-variants.js.map +1 -1
- package/lib/index.js +53 -6
- package/lib/index.js.map +1 -1
- package/lib/paste-images.js +67 -19
- package/lib/paste-images.js.map +1 -1
- package/lib/paths.js +214 -28
- package/lib/paths.js.map +1 -1
- package/lib/runtime-install.js +204 -19
- package/lib/runtime-install.js.map +1 -1
- package/lib/runtime-manager.js +76 -10
- package/lib/runtime-manager.js.map +1 -1
- package/lib/runtime.js +16 -10
- package/lib/runtime.js.map +1 -1
- package/lib/storage-history.js +154 -0
- package/lib/storage-history.js.map +1 -0
- package/lib/types/artifact-access.d.ts.map +1 -1
- package/lib/types/client/index.d.ts +6 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/config.d.ts +24 -1
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/evidence-cache.d.ts +1 -1
- package/lib/types/evidence-cache.d.ts.map +1 -1
- package/lib/types/image-input-variants.d.ts +7 -4
- package/lib/types/image-input-variants.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/paste-images.d.ts +12 -4
- package/lib/types/paste-images.d.ts.map +1 -1
- package/lib/types/paths.d.ts +31 -5
- package/lib/types/paths.d.ts.map +1 -1
- package/lib/types/runtime-install.d.ts +21 -0
- package/lib/types/runtime-install.d.ts.map +1 -1
- package/lib/types/runtime-manager.d.ts +28 -4
- package/lib/types/runtime-manager.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +4 -1
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/storage-history.d.ts +63 -0
- package/lib/types/storage-history.d.ts.map +1 -0
- package/lib/types/upstream.d.ts.map +1 -1
- package/lib/types/web.d.ts.map +1 -1
- package/lib/upstream.js +10 -0
- package/lib/upstream.js.map +1 -1
- package/lib/web.js +9 -3
- package/lib/web.js.map +1 -1
- package/package.json +1 -1
- package/src/artifact-access.ts +22 -2
- package/src/client/index.tsx +18 -3
- package/src/config.ts +76 -2
- package/src/evidence-cache.ts +2 -1
- package/src/image-input-variants.ts +61 -20
- package/src/index.ts +65 -6
- package/src/paste-images.ts +81 -19
- package/src/paths.ts +249 -28
- package/src/runtime-install.ts +231 -20
- package/src/runtime-manager.ts +93 -10
- package/src/runtime.ts +15 -9
- package/src/storage-history.ts +172 -0
- package/src/upstream.ts +10 -0
- package/src/web.ts +9 -2
package/src/runtime-install.ts
CHANGED
|
@@ -22,9 +22,9 @@ import {
|
|
|
22
22
|
utimes,
|
|
23
23
|
writeFile,
|
|
24
24
|
} from 'node:fs/promises'
|
|
25
|
-
import { createWriteStream } from 'node:fs'
|
|
25
|
+
import { createWriteStream, type Dirent } from 'node:fs'
|
|
26
26
|
import { homedir } from 'node:os'
|
|
27
|
-
import { dirname, join, resolve } from 'node:path'
|
|
27
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
28
28
|
import { Transform } from 'node:stream'
|
|
29
29
|
import { pipeline } from 'node:stream/promises'
|
|
30
30
|
import { fileURLToPath } from 'node:url'
|
|
@@ -88,6 +88,9 @@ const PYPI_MIRROR_BASE_URL = 'https://mirrors.cloud.tencent.com/pypi/simple'
|
|
|
88
88
|
const PROBE_TIMEOUT_MS = 30_000
|
|
89
89
|
const LOCK_STALE_MS = 15 * 60 * 1000
|
|
90
90
|
const LOCK_HEARTBEAT_MS = 5_000
|
|
91
|
+
const WINDOWS_FILE_RETRY_ATTEMPTS = 5
|
|
92
|
+
const WINDOWS_FILE_RETRY_DELAY_MS = 250
|
|
93
|
+
const LEGACY_RUNTIME_GC_STALE_MS = 24 * 60 * 60 * 1000
|
|
91
94
|
|
|
92
95
|
/** Absolute root of the packaged upstream snapshot. */
|
|
93
96
|
export function bundledUpstreamRoot(): string {
|
|
@@ -103,6 +106,207 @@ function sha256(bytes: string | Buffer): string {
|
|
|
103
106
|
return createHash('sha256').update(bytes).digest('hex')
|
|
104
107
|
}
|
|
105
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Windows Defender/antivirus real-time scanning briefly locks freshly written
|
|
111
|
+
* Python DLLs, so recursive removal and directory replacement can fail with
|
|
112
|
+
* EBUSY/EPERM immediately after installation. Retry those transient Windows
|
|
113
|
+
* errors before surfacing them; non-Windows platforms pass through unchanged.
|
|
114
|
+
*/
|
|
115
|
+
export async function withWindowsTransientRetry<T>(operation: () => Promise<T>): Promise<T> {
|
|
116
|
+
let lastError: unknown
|
|
117
|
+
for (let attempt = 1; attempt <= WINDOWS_FILE_RETRY_ATTEMPTS; attempt += 1) {
|
|
118
|
+
try {
|
|
119
|
+
return await operation()
|
|
120
|
+
} catch (error) {
|
|
121
|
+
lastError = error
|
|
122
|
+
const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined
|
|
123
|
+
const transient = process.platform === 'win32' && (code === 'EBUSY' || code === 'EPERM' || code === 'EACCES')
|
|
124
|
+
if (!transient) break
|
|
125
|
+
if (attempt < WINDOWS_FILE_RETRY_ATTEMPTS) {
|
|
126
|
+
await new Promise(resolveWait => setTimeout(resolveWait, WINDOWS_FILE_RETRY_DELAY_MS * attempt))
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
throw lastError
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Best-effort removal used after the primary runtime path has already
|
|
135
|
+
* succeeded or failed. Transient Windows locks must not turn a usable runtime
|
|
136
|
+
* into an error, but leaving the directory behind should still be audible.
|
|
137
|
+
*/
|
|
138
|
+
export async function ignoreCleanupFailure(ctx: Context, label: string, path: string): Promise<void> {
|
|
139
|
+
try {
|
|
140
|
+
await withWindowsTransientRetry(() => rm(path, { recursive: true, force: true }))
|
|
141
|
+
} catch (error) {
|
|
142
|
+
ctx.logger.warn(
|
|
143
|
+
'dsh-vision-toolkit: %s cleanup failed: %s',
|
|
144
|
+
label,
|
|
145
|
+
error instanceof Error ? error.message : String(error),
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
type RuntimeGarbageKind = 'managed runtime staging' | 'managed runtime quarantine' | 'bundled Python staging'
|
|
151
|
+
|
|
152
|
+
interface RuntimeGarbageCandidate {
|
|
153
|
+
kind: RuntimeGarbageKind
|
|
154
|
+
lockName?: string
|
|
155
|
+
lockToken?: string
|
|
156
|
+
minimumAgeMs?: number
|
|
157
|
+
createdAtMs?: number
|
|
158
|
+
observationMarker?: string
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function runtimeGarbageLockToken(lockBase: string): string {
|
|
162
|
+
return sha256(lockBase).slice(0, 12)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function managedRuntimeGarbage(name: string): RuntimeGarbageCandidate | undefined {
|
|
166
|
+
if (name.startsWith('.prepare-')) {
|
|
167
|
+
const current = /^\.prepare-([a-f0-9]{12})-[^/]{6}$/.exec(name)
|
|
168
|
+
return { kind: 'managed runtime staging', ...(current === null ? {} : { lockToken: current[1] }) }
|
|
169
|
+
}
|
|
170
|
+
const replaced = name.indexOf('.replaced-')
|
|
171
|
+
if (replaced > 0) {
|
|
172
|
+
const stamped = /^(\d{13})-/.exec(name.slice(replaced + '.replaced-'.length))
|
|
173
|
+
return {
|
|
174
|
+
kind: 'managed runtime quarantine',
|
|
175
|
+
lockName: `${name.slice(0, replaced)}.lock`,
|
|
176
|
+
minimumAgeMs: LEGACY_RUNTIME_GC_STALE_MS,
|
|
177
|
+
...(stamped === null
|
|
178
|
+
? { observationMarker: '.dsh-vision-toolkit-gc-observed' }
|
|
179
|
+
: { createdAtMs: Number(stamped[1]) }),
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return undefined
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function bundledPythonGarbage(name: string): RuntimeGarbageCandidate | undefined {
|
|
186
|
+
if (!name.startsWith('.python-bootstrap-')) return undefined
|
|
187
|
+
const current = /^\.python-bootstrap-([a-f0-9]{12})-[^/]{6}$/.exec(name)
|
|
188
|
+
return { kind: 'bundled Python staging', ...(current === null ? {} : { lockToken: current[1] }) }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function runtimeLockIsActive(lockPath: string, now: number): Promise<boolean> {
|
|
192
|
+
try {
|
|
193
|
+
const info = await stat(lockPath)
|
|
194
|
+
return now - info.mtimeMs <= LOCK_STALE_MS
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
|
|
197
|
+
return true
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function garbageCollectDirectory(
|
|
202
|
+
ctx: Context,
|
|
203
|
+
parent: string,
|
|
204
|
+
classify: (name: string) => RuntimeGarbageCandidate | undefined,
|
|
205
|
+
now: number,
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
let entries: Dirent[]
|
|
208
|
+
try {
|
|
209
|
+
entries = await readdir(parent, { withFileTypes: true })
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
|
|
212
|
+
ctx.logger.warn(
|
|
213
|
+
'dsh-vision-toolkit: runtime garbage collection scan failed for %s: %s',
|
|
214
|
+
parent,
|
|
215
|
+
error instanceof Error ? error.message : String(error),
|
|
216
|
+
)
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
let activeLockTokens: Set<string> | undefined
|
|
220
|
+
const getActiveLockTokens = async (): Promise<Set<string>> => {
|
|
221
|
+
if (activeLockTokens !== undefined) return activeLockTokens
|
|
222
|
+
activeLockTokens = new Set<string>()
|
|
223
|
+
for (const lock of entries) {
|
|
224
|
+
if (
|
|
225
|
+
lock.isDirectory()
|
|
226
|
+
&& lock.name.endsWith('.lock')
|
|
227
|
+
&& await runtimeLockIsActive(join(parent, lock.name), now)
|
|
228
|
+
) {
|
|
229
|
+
activeLockTokens.add(runtimeGarbageLockToken(lock.name.slice(0, -'.lock'.length)))
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return activeLockTokens
|
|
233
|
+
}
|
|
234
|
+
let legacyCollectionSafe: boolean | undefined
|
|
235
|
+
for (const entry of entries) {
|
|
236
|
+
if (!entry.isDirectory()) continue
|
|
237
|
+
const candidate = classify(entry.name)
|
|
238
|
+
if (candidate === undefined) continue
|
|
239
|
+
const path = join(parent, entry.name)
|
|
240
|
+
if (candidate.lockName !== undefined) {
|
|
241
|
+
if (await runtimeLockIsActive(join(parent, candidate.lockName), now)) continue
|
|
242
|
+
} else if (candidate.lockToken !== undefined) {
|
|
243
|
+
if ((await getActiveLockTokens()).has(candidate.lockToken)) continue
|
|
244
|
+
} else {
|
|
245
|
+
if (legacyCollectionSafe === undefined) {
|
|
246
|
+
legacyCollectionSafe = (await getActiveLockTokens()).size === 0
|
|
247
|
+
}
|
|
248
|
+
if (!legacyCollectionSafe) continue
|
|
249
|
+
try {
|
|
250
|
+
const info = await stat(path)
|
|
251
|
+
if (now - info.mtimeMs <= LEGACY_RUNTIME_GC_STALE_MS) continue
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
|
|
254
|
+
ctx.logger.warn(
|
|
255
|
+
'dsh-vision-toolkit: runtime garbage collection inspection failed for %s: %s',
|
|
256
|
+
path,
|
|
257
|
+
error instanceof Error ? error.message : String(error),
|
|
258
|
+
)
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (candidate.minimumAgeMs !== undefined) {
|
|
263
|
+
let createdAt = candidate.createdAtMs
|
|
264
|
+
if (createdAt === undefined && candidate.observationMarker !== undefined) {
|
|
265
|
+
const marker = join(path, candidate.observationMarker)
|
|
266
|
+
try {
|
|
267
|
+
createdAt = (await stat(marker)).mtimeMs
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
270
|
+
await writeFile(marker, `${now}\n`, { flag: 'wx' }).catch(() => {})
|
|
271
|
+
}
|
|
272
|
+
continue
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (createdAt === undefined) {
|
|
276
|
+
try {
|
|
277
|
+
createdAt = (await stat(path)).mtimeMs
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
|
|
280
|
+
ctx.logger.warn(
|
|
281
|
+
'dsh-vision-toolkit: runtime garbage collection inspection failed for %s: %s',
|
|
282
|
+
path,
|
|
283
|
+
error instanceof Error ? error.message : String(error),
|
|
284
|
+
)
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (now - createdAt <= candidate.minimumAgeMs) continue
|
|
289
|
+
}
|
|
290
|
+
await ignoreCleanupFailure(ctx, `stale ${candidate.kind}`, path)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Opportunistically remove abandoned runtime staging and quarantine trees.
|
|
296
|
+
* Current names encode their owning runtime lock, so live preparation is
|
|
297
|
+
* skipped. Quarantines retain a 24-hour recovery window; legacy names use the
|
|
298
|
+
* same grace period and are collected only when no runtime lock is active
|
|
299
|
+
* because they cannot be associated with a specific lock.
|
|
300
|
+
*/
|
|
301
|
+
export async function garbageCollectRuntimeCache(
|
|
302
|
+
ctx: Context,
|
|
303
|
+
stateRoot: string,
|
|
304
|
+
now: number = Date.now(),
|
|
305
|
+
): Promise<void> {
|
|
306
|
+
await garbageCollectDirectory(ctx, join(stateRoot, 'python'), managedRuntimeGarbage, now)
|
|
307
|
+
await garbageCollectDirectory(ctx, join(stateRoot, 'python-bootstrap'), bundledPythonGarbage, now)
|
|
308
|
+
}
|
|
309
|
+
|
|
106
310
|
export function isolatedPythonEnvironment(home: string): NodeJS.ProcessEnv {
|
|
107
311
|
return {
|
|
108
312
|
HOME: home,
|
|
@@ -488,7 +692,7 @@ async function withDirectoryLock<T>(lockPath: string, fn: () => Promise<T>): Pro
|
|
|
488
692
|
await writeFile(join(lockPath, 'owner'), `${owner}\n`, { flag: 'wx' })
|
|
489
693
|
} catch (error) {
|
|
490
694
|
if (acquired) {
|
|
491
|
-
await rm(lockPath, { recursive: true, force: true }).catch(() => {})
|
|
695
|
+
await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true })).catch(() => {})
|
|
492
696
|
throw error
|
|
493
697
|
}
|
|
494
698
|
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
|
@@ -497,7 +701,7 @@ async function withDirectoryLock<T>(lockPath: string, fn: () => Promise<T>): Pro
|
|
|
497
701
|
try {
|
|
498
702
|
const info = await stat(lockPath)
|
|
499
703
|
if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
|
|
500
|
-
await rm(lockPath, { recursive: true, force: true })
|
|
704
|
+
await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true }))
|
|
501
705
|
return withDirectoryLock(lockPath, fn)
|
|
502
706
|
}
|
|
503
707
|
} catch {
|
|
@@ -518,7 +722,7 @@ async function withDirectoryLock<T>(lockPath: string, fn: () => Promise<T>): Pro
|
|
|
518
722
|
clearInterval(heartbeat)
|
|
519
723
|
try {
|
|
520
724
|
if ((await readFile(join(lockPath, 'owner'), 'utf8')).trim() === owner) {
|
|
521
|
-
await rm(lockPath, { recursive: true, force: true })
|
|
725
|
+
await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true }))
|
|
522
726
|
}
|
|
523
727
|
} catch {
|
|
524
728
|
// The lock was already removed or replaced.
|
|
@@ -552,8 +756,8 @@ export async function acquireBundledPython(
|
|
|
552
756
|
if (ready !== undefined) return
|
|
553
757
|
const parent = dirname(root)
|
|
554
758
|
await mkdir(parent, { recursive: true })
|
|
555
|
-
await rm(root, { recursive: true, force: true })
|
|
556
|
-
const work = await mkdtemp(join(parent,
|
|
759
|
+
await withWindowsTransientRetry(() => rm(root, { recursive: true, force: true }))
|
|
760
|
+
const work = await mkdtemp(join(parent, `.python-bootstrap-${runtimeGarbageLockToken(basename(root))}-`))
|
|
557
761
|
try {
|
|
558
762
|
const archive = join(work, 'python.tar.gz')
|
|
559
763
|
const extractDir = join(work, 'extract')
|
|
@@ -589,9 +793,9 @@ export async function acquireBundledPython(
|
|
|
589
793
|
)
|
|
590
794
|
}
|
|
591
795
|
if (process.platform !== 'win32') await chmod(extractedInterpreter, 0o755)
|
|
592
|
-
await rename(extractDir, root)
|
|
796
|
+
await withWindowsTransientRetry(() => rename(extractDir, root))
|
|
593
797
|
} finally {
|
|
594
|
-
await
|
|
798
|
+
await ignoreCleanupFailure(ctx, 'bundled Python staging', work)
|
|
595
799
|
}
|
|
596
800
|
})
|
|
597
801
|
const metadata = await pythonMetadata(ctx, command, cwd)
|
|
@@ -632,6 +836,7 @@ export async function resolveBootstrapPython(
|
|
|
632
836
|
try {
|
|
633
837
|
const stateRoot = visionToolkitStateRoot()
|
|
634
838
|
await mkdir(stateRoot, { recursive: true })
|
|
839
|
+
await garbageCollectRuntimeCache(ctx, stateRoot)
|
|
635
840
|
const bundled = await acquireBundledPython(ctx, stateRoot, cwd, manifestOverride, requestImpl)
|
|
636
841
|
return {
|
|
637
842
|
command: bundled.command,
|
|
@@ -827,7 +1032,7 @@ async function waitForManagedRuntime(
|
|
|
827
1032
|
try {
|
|
828
1033
|
const info = await stat(lockPath)
|
|
829
1034
|
if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
|
|
830
|
-
await rm(lockPath, { recursive: true, force: true })
|
|
1035
|
+
await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true }))
|
|
831
1036
|
return undefined
|
|
832
1037
|
}
|
|
833
1038
|
} catch {
|
|
@@ -844,7 +1049,7 @@ async function releaseManagedLock(lockPath: string, owner: string): Promise<void
|
|
|
844
1049
|
} catch {
|
|
845
1050
|
return
|
|
846
1051
|
}
|
|
847
|
-
await rm(lockPath, { recursive: true, force: true })
|
|
1052
|
+
await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true }))
|
|
848
1053
|
}
|
|
849
1054
|
|
|
850
1055
|
async function prepareManaged(
|
|
@@ -854,6 +1059,7 @@ async function prepareManaged(
|
|
|
854
1059
|
): Promise<PreparedUpstreamRuntime> {
|
|
855
1060
|
const stateRoot = visionToolkitStateRoot()
|
|
856
1061
|
await mkdir(stateRoot, { recursive: true })
|
|
1062
|
+
await garbageCollectRuntimeCache(ctx, stateRoot)
|
|
857
1063
|
const cleanHome = join(stateRoot, 'home')
|
|
858
1064
|
await mkdir(cleanHome, { recursive: true })
|
|
859
1065
|
const bootstrap = await resolveBootstrapPython(ctx, config.runtime.python, cleanHome)
|
|
@@ -899,7 +1105,7 @@ async function prepareManaged(
|
|
|
899
1105
|
await writeFile(join(lockPath, 'owner'), `${lockOwner}\n`, { flag: 'wx' })
|
|
900
1106
|
} catch (error) {
|
|
901
1107
|
if (lockAcquired) {
|
|
902
|
-
await rm(lockPath, { recursive: true, force: true })
|
|
1108
|
+
await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true })).catch(() => {})
|
|
903
1109
|
throw error
|
|
904
1110
|
}
|
|
905
1111
|
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
|
@@ -923,7 +1129,7 @@ async function prepareManaged(
|
|
|
923
1129
|
return prepareManaged(ctx, config, manifest)
|
|
924
1130
|
}
|
|
925
1131
|
|
|
926
|
-
const staging = await mkdtemp(join(parent,
|
|
1132
|
+
const staging = await mkdtemp(join(parent, `.prepare-${runtimeGarbageLockToken(runtimeId)}-`))
|
|
927
1133
|
const installEnv: NodeJS.ProcessEnv = {
|
|
928
1134
|
...isolatedPythonEnvironment(cleanHome),
|
|
929
1135
|
UV_CACHE_DIR: join(stateRoot, 'uv-cache'),
|
|
@@ -1013,20 +1219,20 @@ async function prepareManaged(
|
|
|
1013
1219
|
manager,
|
|
1014
1220
|
}
|
|
1015
1221
|
await writeFile(join(staging, 'runtime.json'), `${JSON.stringify(marker, null, 2)}\n`)
|
|
1016
|
-
const quarantine = `${finalRoot}.replaced-${randomUUID()}`
|
|
1222
|
+
const quarantine = `${finalRoot}.replaced-${Date.now()}-${randomUUID()}`
|
|
1017
1223
|
let quarantined = false
|
|
1018
1224
|
try {
|
|
1019
|
-
await rename(finalRoot, quarantine)
|
|
1225
|
+
await withWindowsTransientRetry(() => rename(finalRoot, quarantine))
|
|
1020
1226
|
quarantined = true
|
|
1021
1227
|
} catch (error) {
|
|
1022
1228
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
1023
1229
|
}
|
|
1024
1230
|
try {
|
|
1025
|
-
await rename(staging, finalRoot)
|
|
1231
|
+
await withWindowsTransientRetry(() => rename(staging, finalRoot))
|
|
1026
1232
|
} catch (error) {
|
|
1027
1233
|
if (quarantined) {
|
|
1028
1234
|
try {
|
|
1029
|
-
await rename(quarantine, finalRoot)
|
|
1235
|
+
await withWindowsTransientRetry(() => rename(quarantine, finalRoot))
|
|
1030
1236
|
} catch (restoreError) {
|
|
1031
1237
|
throw new VisionToolkitError(
|
|
1032
1238
|
'runtime',
|
|
@@ -1037,13 +1243,18 @@ async function prepareManaged(
|
|
|
1037
1243
|
}
|
|
1038
1244
|
throw error
|
|
1039
1245
|
}
|
|
1040
|
-
await
|
|
1246
|
+
await ignoreCleanupFailure(ctx, 'managed runtime quarantine', quarantine)
|
|
1041
1247
|
const python: RuntimeCommand = { program: interpreter, prefix: [], display: interpreter }
|
|
1042
1248
|
return { source: 'managed', root: BUNDLED_ROOT, python, cleanHome, pythonVersion: metadata.version, dependencies }
|
|
1043
1249
|
} finally {
|
|
1044
1250
|
clearInterval(heartbeat)
|
|
1045
|
-
await
|
|
1046
|
-
await releaseManagedLock(lockPath, lockOwner)
|
|
1251
|
+
await ignoreCleanupFailure(ctx, 'managed runtime staging', staging)
|
|
1252
|
+
await releaseManagedLock(lockPath, lockOwner).catch(error => {
|
|
1253
|
+
ctx.logger.warn(
|
|
1254
|
+
'dsh-vision-toolkit: managed runtime lock cleanup failed: %s',
|
|
1255
|
+
error instanceof Error ? error.message : String(error),
|
|
1256
|
+
)
|
|
1257
|
+
})
|
|
1047
1258
|
}
|
|
1048
1259
|
}
|
|
1049
1260
|
|
package/src/runtime-manager.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { Context } from '@deepseek-ai/cordis'
|
|
9
9
|
import { resolveConfig, type ResolvedVisionToolkitConfig, type VisionToolkitConfig } from './config.ts'
|
|
10
|
+
import { preflightSharedStorageBase } from './paths.ts'
|
|
10
11
|
import { VisionToolkitRuntime } from './runtime.ts'
|
|
11
12
|
import { UpstreamAdapter, type UpstreamVersionInfo } from './upstream.ts'
|
|
12
13
|
|
|
@@ -26,16 +27,30 @@ export interface RuntimeManagerStatus {
|
|
|
26
27
|
lastError?: string
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
/** Storage selection paired to the active or validated startup generation. */
|
|
31
|
+
export interface RuntimeStorageGeneration {
|
|
32
|
+
generation: number
|
|
33
|
+
storageDir?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
29
36
|
/** Test seam for preparing one generation. */
|
|
30
37
|
export type RuntimeGenerationFactory = (
|
|
31
38
|
ctx: Context,
|
|
32
39
|
config: ResolvedVisionToolkitConfig,
|
|
40
|
+
readableStorageDirs: readonly string[],
|
|
33
41
|
) => Promise<VisionToolkitRuntime>
|
|
34
42
|
|
|
35
|
-
|
|
43
|
+
/** Async commit prerequisite run after preparation and before a generation becomes active. */
|
|
44
|
+
export type RuntimeGenerationBeforePublish = (candidate: PreparedRuntimeGeneration) => Promise<void>
|
|
45
|
+
|
|
46
|
+
async function defaultFactory(
|
|
47
|
+
ctx: Context,
|
|
48
|
+
config: ResolvedVisionToolkitConfig,
|
|
49
|
+
readableStorageDirs: readonly string[],
|
|
50
|
+
): Promise<VisionToolkitRuntime> {
|
|
36
51
|
const adapter = new UpstreamAdapter(ctx, config)
|
|
37
52
|
await adapter.prepare()
|
|
38
|
-
return new VisionToolkitRuntime(ctx, config, adapter)
|
|
53
|
+
return new VisionToolkitRuntime(ctx, config, adapter, readableStorageDirs)
|
|
39
54
|
}
|
|
40
55
|
|
|
41
56
|
function fingerprint(config: ResolvedVisionToolkitConfig): string {
|
|
@@ -57,6 +72,8 @@ export class VisionToolkitRuntimeManager {
|
|
|
57
72
|
private generation = 0
|
|
58
73
|
private reconfigureTicket = 0
|
|
59
74
|
private lastError: string | undefined
|
|
75
|
+
private validatedStartupStorageDir: string | null | undefined
|
|
76
|
+
private readonly readableStorageDirs = new Set<string>()
|
|
60
77
|
|
|
61
78
|
constructor(
|
|
62
79
|
private readonly ctx: Context,
|
|
@@ -69,22 +86,69 @@ export class VisionToolkitRuntimeManager {
|
|
|
69
86
|
return this.active.runtime
|
|
70
87
|
}
|
|
71
88
|
|
|
89
|
+
/** Configuration belonging to the currently serving runtime generation. */
|
|
90
|
+
currentConfig(): ResolvedVisionToolkitConfig {
|
|
91
|
+
if (this.active === undefined) throw new Error('dsh-vision-toolkit runtime is not ready')
|
|
92
|
+
return this.active.config
|
|
93
|
+
}
|
|
94
|
+
|
|
72
95
|
/** Whether at least one generation is available. */
|
|
73
96
|
get ready(): boolean {
|
|
74
97
|
return this.active !== undefined
|
|
75
98
|
}
|
|
76
99
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
100
|
+
/** Storage config safe for paste writes even when initial runtime preparation failed. */
|
|
101
|
+
storageGeneration(): RuntimeStorageGeneration {
|
|
102
|
+
if (this.active !== undefined) {
|
|
103
|
+
return {
|
|
104
|
+
generation: this.generation,
|
|
105
|
+
...(this.active.config.storageDir === undefined ? {} : { storageDir: this.active.config.storageDir }),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (this.validatedStartupStorageDir === undefined) {
|
|
109
|
+
throw new Error('dsh-vision-toolkit storage configuration is not ready')
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
generation: this.generation,
|
|
113
|
+
...(this.validatedStartupStorageDir === null ? {} : { storageDir: this.validatedStartupStorageDir }),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Validated storage for best-effort consumers; undefined when startup preflight failed. */
|
|
118
|
+
validatedStorageDirectory(): string | undefined {
|
|
119
|
+
if (this.active !== undefined) return this.active.config.storageDir
|
|
120
|
+
return this.validatedStartupStorageDir ?? undefined
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async prepareResolvedCandidate(config: ResolvedVisionToolkitConfig): Promise<PreparedRuntimeGeneration> {
|
|
80
124
|
const resolvedFingerprint = fingerprint(config)
|
|
81
125
|
if (this.active?.fingerprint === resolvedFingerprint) {
|
|
82
126
|
return { ...this.active, config }
|
|
83
127
|
}
|
|
84
|
-
const runtime = await this.factory(
|
|
128
|
+
const runtime = await this.factory(
|
|
129
|
+
this.ctx,
|
|
130
|
+
config,
|
|
131
|
+
[...new Set([...this.readableStorageDirs, ...config.storageHistory])]
|
|
132
|
+
.filter(storageDir => storageDir !== config.storageDir),
|
|
133
|
+
)
|
|
85
134
|
return { config, fingerprint: resolvedFingerprint, runtime }
|
|
86
135
|
}
|
|
87
136
|
|
|
137
|
+
private rememberStorageDirectory(storageDir: string | undefined): void {
|
|
138
|
+
if (storageDir !== undefined) this.readableStorageDirs.add(storageDir)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private rememberStorageDirectories(storageDirs: readonly string[]): void {
|
|
142
|
+
for (const storageDir of storageDirs) this.rememberStorageDirectory(storageDir)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Resolve and fully prepare a candidate without changing the active runtime. */
|
|
146
|
+
async prepareCandidate(raw: VisionToolkitConfig): Promise<PreparedRuntimeGeneration> {
|
|
147
|
+
const config = resolveConfig(raw)
|
|
148
|
+
if (config.storageDir !== undefined) await preflightSharedStorageBase(config.storageDir)
|
|
149
|
+
return this.prepareResolvedCandidate(config)
|
|
150
|
+
}
|
|
151
|
+
|
|
88
152
|
/**
|
|
89
153
|
* Publish one already-prepared generation atomically.
|
|
90
154
|
* @param candidate - generation returned by {@link prepareCandidate}.
|
|
@@ -97,6 +161,8 @@ export class VisionToolkitRuntimeManager {
|
|
|
97
161
|
}
|
|
98
162
|
this.reconfigureTicket += 1
|
|
99
163
|
this.active = candidate
|
|
164
|
+
this.rememberStorageDirectories(candidate.config.storageHistory)
|
|
165
|
+
this.rememberStorageDirectory(candidate.config.storageDir)
|
|
100
166
|
this.generation += 1
|
|
101
167
|
this.lastError = undefined
|
|
102
168
|
this.ctx.logger.info(
|
|
@@ -108,10 +174,21 @@ export class VisionToolkitRuntimeManager {
|
|
|
108
174
|
)
|
|
109
175
|
}
|
|
110
176
|
|
|
111
|
-
/**
|
|
112
|
-
|
|
177
|
+
/**
|
|
178
|
+
* Prepare and publish the initial or explicitly validated generation.
|
|
179
|
+
* @param raw - untrusted Settings generation to resolve and prepare.
|
|
180
|
+
* @param beforePublish - optional durable prerequisite run after preparation.
|
|
181
|
+
*/
|
|
182
|
+
async initialize(raw: VisionToolkitConfig, beforePublish?: RuntimeGenerationBeforePublish): Promise<void> {
|
|
113
183
|
try {
|
|
114
|
-
|
|
184
|
+
const config = resolveConfig(raw)
|
|
185
|
+
if (config.storageDir !== undefined) await preflightSharedStorageBase(config.storageDir)
|
|
186
|
+
this.validatedStartupStorageDir = config.storageDir ?? null
|
|
187
|
+
this.rememberStorageDirectories(config.storageHistory)
|
|
188
|
+
this.rememberStorageDirectory(config.storageDir)
|
|
189
|
+
const candidate = await this.prepareResolvedCandidate(config)
|
|
190
|
+
await beforePublish?.(candidate)
|
|
191
|
+
this.activateCandidate(candidate)
|
|
115
192
|
} catch (error) {
|
|
116
193
|
this.lastError = messageOf(error)
|
|
117
194
|
throw error
|
|
@@ -121,13 +198,17 @@ export class VisionToolkitRuntimeManager {
|
|
|
121
198
|
/**
|
|
122
199
|
* Apply an externally committed Settings generation. Concurrent edits are
|
|
123
200
|
* last-write-wins; a slower obsolete prepare can never overwrite a newer one.
|
|
201
|
+
* @param raw - externally committed Settings generation.
|
|
202
|
+
* @param beforePublish - optional durable prerequisite run after preparation.
|
|
124
203
|
* @returns whether this call published a new active generation.
|
|
125
204
|
*/
|
|
126
|
-
async reconfigure(raw: VisionToolkitConfig): Promise<boolean> {
|
|
205
|
+
async reconfigure(raw: VisionToolkitConfig, beforePublish?: RuntimeGenerationBeforePublish): Promise<boolean> {
|
|
127
206
|
const ticket = ++this.reconfigureTicket
|
|
128
207
|
let candidate: PreparedRuntimeGeneration
|
|
129
208
|
try {
|
|
130
209
|
candidate = await this.prepareCandidate(raw)
|
|
210
|
+
if (ticket !== this.reconfigureTicket) return false
|
|
211
|
+
await beforePublish?.(candidate)
|
|
131
212
|
} catch (error) {
|
|
132
213
|
if (ticket === this.reconfigureTicket) this.lastError = messageOf(error)
|
|
133
214
|
throw error
|
|
@@ -135,6 +216,8 @@ export class VisionToolkitRuntimeManager {
|
|
|
135
216
|
if (ticket !== this.reconfigureTicket) return false
|
|
136
217
|
const changed = this.active?.fingerprint !== candidate.fingerprint
|
|
137
218
|
this.active = candidate
|
|
219
|
+
this.rememberStorageDirectories(candidate.config.storageHistory)
|
|
220
|
+
this.rememberStorageDirectory(candidate.config.storageDir)
|
|
138
221
|
if (changed) {
|
|
139
222
|
this.generation += 1
|
|
140
223
|
this.ctx.logger.info(
|
package/src/runtime.ts
CHANGED
|
@@ -696,6 +696,7 @@ export class VisionToolkitRuntime {
|
|
|
696
696
|
private readonly ctx: Context,
|
|
697
697
|
private readonly config: ResolvedVisionToolkitConfig,
|
|
698
698
|
adapter?: UpstreamAdapter,
|
|
699
|
+
private readonly readableStorageDirs: readonly string[] = [],
|
|
699
700
|
) {
|
|
700
701
|
this.adapter = adapter ?? new UpstreamAdapter(ctx, config)
|
|
701
702
|
}
|
|
@@ -705,6 +706,11 @@ export class VisionToolkitRuntime {
|
|
|
705
706
|
return this.adapter.versionInfo
|
|
706
707
|
}
|
|
707
708
|
|
|
709
|
+
/** Shared storage root belonging to this immutable runtime generation. */
|
|
710
|
+
get storageDirectory(): string | undefined {
|
|
711
|
+
return this.config.storageDir
|
|
712
|
+
}
|
|
713
|
+
|
|
708
714
|
/** Stable identity for persisted image descriptions produced by this runtime. */
|
|
709
715
|
get evidenceFingerprint(): string {
|
|
710
716
|
return evidenceRuntimeFingerprint(this.config, undefined, process.env.VISION_SSL_VERIFY?.trim())
|
|
@@ -880,13 +886,13 @@ export class VisionToolkitRuntime {
|
|
|
880
886
|
}
|
|
881
887
|
|
|
882
888
|
private pathPolicy(workspace: string): Promise<PathPolicy> {
|
|
883
|
-
return createPathPolicy(workspace, this.config.allowedDirs)
|
|
889
|
+
return createPathPolicy(workspace, this.config.allowedDirs, this.config.storageDir, this.readableStorageDirs)
|
|
884
890
|
}
|
|
885
891
|
|
|
886
892
|
private async compressedImageRoot(policy: PathPolicy): Promise<string> {
|
|
887
|
-
const root = join(policy.
|
|
888
|
-
let current = policy.
|
|
889
|
-
for (const segment of ['
|
|
893
|
+
const root = join(policy.storageRoot, 'tmp', 'compressed-images')
|
|
894
|
+
let current = policy.storageRoot
|
|
895
|
+
for (const segment of ['tmp', 'compressed-images']) {
|
|
890
896
|
current = join(current, segment)
|
|
891
897
|
try {
|
|
892
898
|
await mkdir(current, { mode: 0o700 })
|
|
@@ -897,13 +903,13 @@ export class VisionToolkitRuntime {
|
|
|
897
903
|
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
898
904
|
throw new VisionToolkitError('path', `compressed-image cache path is not a real directory: ${current}`)
|
|
899
905
|
}
|
|
900
|
-
if (!isWithin(policy.
|
|
901
|
-
throw new VisionToolkitError('path', `compressed-image cache path escaped
|
|
906
|
+
if (!isWithin(policy.storageRoot, current)) {
|
|
907
|
+
throw new VisionToolkitError('path', `compressed-image cache path escaped plugin storage: ${current}`)
|
|
902
908
|
}
|
|
903
909
|
}
|
|
904
910
|
const canonical = await realpath(root)
|
|
905
|
-
if (!isWithin(policy.
|
|
906
|
-
throw new VisionToolkitError('path', 'compressed-image cache resolved outside
|
|
911
|
+
if (!isWithin(policy.storageRoot, canonical)) {
|
|
912
|
+
throw new VisionToolkitError('path', 'compressed-image cache resolved outside plugin storage')
|
|
907
913
|
}
|
|
908
914
|
return canonical
|
|
909
915
|
}
|
|
@@ -2078,7 +2084,7 @@ export class VisionToolkitRuntime {
|
|
|
2078
2084
|
let artifactDirectory: HealthCheck
|
|
2079
2085
|
try {
|
|
2080
2086
|
// allowedDirs are session input roots; they do not affect output readiness.
|
|
2081
|
-
const policy = await createPathPolicy(options.workspace, [])
|
|
2087
|
+
const policy = await createPathPolicy(options.workspace, [], this.config.storageDir)
|
|
2082
2088
|
artifactDirectory = await this.writableDirectoryCheck(policy.outputDir, 'Artifact directory')
|
|
2083
2089
|
} catch {
|
|
2084
2090
|
artifactDirectory = { status: 'error', detail: 'Artifact directory could not be prepared' }
|