@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.
Files changed (77) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +21 -42
  3. package/README.zh.md +20 -41
  4. package/assets/logo_eapi_dark.png +0 -0
  5. package/docs/aihubmix-gemini-vision.i18n.yaml +2 -2
  6. package/docs/aihubmix-gemini-vision.md +2 -2
  7. package/docs/aihubmix-gemini-vision.zh.md +2 -2
  8. package/docs/requirements-traceability/README.i18n.yaml +2 -2
  9. package/docs/requirements-traceability/README.md +1 -1
  10. package/docs/requirements-traceability/README.zh.md +1 -1
  11. package/lib/artifact-access.js +20 -2
  12. package/lib/artifact-access.js.map +1 -1
  13. package/lib/client.js +11 -3
  14. package/lib/client.js.map +1 -1
  15. package/lib/config.js +46 -2
  16. package/lib/config.js.map +1 -1
  17. package/lib/evidence-cache.js +2 -1
  18. package/lib/evidence-cache.js.map +1 -1
  19. package/lib/image-input-variants.js +61 -26
  20. package/lib/image-input-variants.js.map +1 -1
  21. package/lib/index.js +53 -6
  22. package/lib/index.js.map +1 -1
  23. package/lib/paste-images.js +67 -19
  24. package/lib/paste-images.js.map +1 -1
  25. package/lib/paths.js +214 -28
  26. package/lib/paths.js.map +1 -1
  27. package/lib/runtime-install.js +204 -19
  28. package/lib/runtime-install.js.map +1 -1
  29. package/lib/runtime-manager.js +76 -10
  30. package/lib/runtime-manager.js.map +1 -1
  31. package/lib/runtime.js +16 -10
  32. package/lib/runtime.js.map +1 -1
  33. package/lib/storage-history.js +154 -0
  34. package/lib/storage-history.js.map +1 -0
  35. package/lib/types/artifact-access.d.ts.map +1 -1
  36. package/lib/types/client/index.d.ts +6 -1
  37. package/lib/types/client/index.d.ts.map +1 -1
  38. package/lib/types/config.d.ts +24 -1
  39. package/lib/types/config.d.ts.map +1 -1
  40. package/lib/types/evidence-cache.d.ts +1 -1
  41. package/lib/types/evidence-cache.d.ts.map +1 -1
  42. package/lib/types/image-input-variants.d.ts +7 -4
  43. package/lib/types/image-input-variants.d.ts.map +1 -1
  44. package/lib/types/index.d.ts.map +1 -1
  45. package/lib/types/paste-images.d.ts +12 -4
  46. package/lib/types/paste-images.d.ts.map +1 -1
  47. package/lib/types/paths.d.ts +31 -5
  48. package/lib/types/paths.d.ts.map +1 -1
  49. package/lib/types/runtime-install.d.ts +21 -0
  50. package/lib/types/runtime-install.d.ts.map +1 -1
  51. package/lib/types/runtime-manager.d.ts +28 -4
  52. package/lib/types/runtime-manager.d.ts.map +1 -1
  53. package/lib/types/runtime.d.ts +4 -1
  54. package/lib/types/runtime.d.ts.map +1 -1
  55. package/lib/types/storage-history.d.ts +63 -0
  56. package/lib/types/storage-history.d.ts.map +1 -0
  57. package/lib/types/upstream.d.ts.map +1 -1
  58. package/lib/types/web.d.ts.map +1 -1
  59. package/lib/upstream.js +10 -0
  60. package/lib/upstream.js.map +1 -1
  61. package/lib/web.js +9 -3
  62. package/lib/web.js.map +1 -1
  63. package/package.json +1 -1
  64. package/src/artifact-access.ts +22 -2
  65. package/src/client/index.tsx +18 -3
  66. package/src/config.ts +76 -2
  67. package/src/evidence-cache.ts +2 -1
  68. package/src/image-input-variants.ts +61 -20
  69. package/src/index.ts +65 -6
  70. package/src/paste-images.ts +81 -19
  71. package/src/paths.ts +249 -28
  72. package/src/runtime-install.ts +231 -20
  73. package/src/runtime-manager.ts +93 -10
  74. package/src/runtime.ts +15 -9
  75. package/src/storage-history.ts +172 -0
  76. package/src/upstream.ts +10 -0
  77. package/src/web.ts +9 -2
@@ -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, '.python-bootstrap-'))
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 rm(work, { recursive: true, force: true })
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, '.prepare-'))
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 rm(quarantine, { recursive: true, force: true })
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 rm(staging, { recursive: true, force: true })
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
 
@@ -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
- async function defaultFactory(ctx: Context, config: ResolvedVisionToolkitConfig): Promise<VisionToolkitRuntime> {
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
- /** Resolve and fully prepare a candidate without changing the active runtime. */
78
- async prepareCandidate(raw: VisionToolkitConfig): Promise<PreparedRuntimeGeneration> {
79
- const config = resolveConfig(raw)
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(this.ctx, config)
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
- /** Prepare and publish the initial or explicitly validated generation. */
112
- async initialize(raw: VisionToolkitConfig): Promise<void> {
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
- this.activateCandidate(await this.prepareCandidate(raw))
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.workspace, '.dsh-vision-toolkit', 'tmp', 'compressed-images')
888
- let current = policy.workspace
889
- for (const segment of ['.dsh-vision-toolkit', 'tmp', 'compressed-images']) {
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.workspace, current)) {
901
- throw new VisionToolkitError('path', `compressed-image cache path escaped the workspace: ${current}`)
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.workspace, canonical)) {
906
- throw new VisionToolkitError('path', 'compressed-image cache resolved outside the workspace')
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' }