@mengruo/dsh-vision-toolkit 0.1.3 → 0.1.4

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 (80) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +4 -0
  3. package/README.zh.md +4 -0
  4. package/docs/requirements-traceability/README.i18n.yaml +2 -2
  5. package/docs/requirements-traceability/README.md +1 -1
  6. package/docs/requirements-traceability/README.zh.md +1 -1
  7. package/lib/artifact-access.js +20 -2
  8. package/lib/artifact-access.js.map +1 -1
  9. package/lib/client.js +22 -7
  10. package/lib/client.js.map +1 -1
  11. package/lib/config.js +34 -0
  12. package/lib/config.js.map +1 -1
  13. package/lib/errors.js +25 -2
  14. package/lib/errors.js.map +1 -1
  15. package/lib/evidence-cache.js +2 -1
  16. package/lib/evidence-cache.js.map +1 -1
  17. package/lib/exposure.js +14 -1
  18. package/lib/exposure.js.map +1 -1
  19. package/lib/image-input-variants.js +22 -12
  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-manager.js +76 -10
  28. package/lib/runtime-manager.js.map +1 -1
  29. package/lib/runtime.js +80 -12
  30. package/lib/runtime.js.map +1 -1
  31. package/lib/storage-history.js +154 -0
  32. package/lib/storage-history.js.map +1 -0
  33. package/lib/types/artifact-access.d.ts.map +1 -1
  34. package/lib/types/client/index.d.ts +6 -1
  35. package/lib/types/client/index.d.ts.map +1 -1
  36. package/lib/types/client/paste-images.d.ts +2 -0
  37. package/lib/types/client/paste-images.d.ts.map +1 -1
  38. package/lib/types/config.d.ts +22 -0
  39. package/lib/types/config.d.ts.map +1 -1
  40. package/lib/types/errors.d.ts +18 -2
  41. package/lib/types/errors.d.ts.map +1 -1
  42. package/lib/types/evidence-cache.d.ts +1 -1
  43. package/lib/types/evidence-cache.d.ts.map +1 -1
  44. package/lib/types/exposure.d.ts.map +1 -1
  45. package/lib/types/image-input-variants.d.ts +5 -3
  46. package/lib/types/image-input-variants.d.ts.map +1 -1
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/paste-images.d.ts +12 -4
  49. package/lib/types/paste-images.d.ts.map +1 -1
  50. package/lib/types/paths.d.ts +31 -5
  51. package/lib/types/paths.d.ts.map +1 -1
  52. package/lib/types/runtime-manager.d.ts +28 -4
  53. package/lib/types/runtime-manager.d.ts.map +1 -1
  54. package/lib/types/runtime.d.ts +19 -1
  55. package/lib/types/runtime.d.ts.map +1 -1
  56. package/lib/types/storage-history.d.ts +63 -0
  57. package/lib/types/storage-history.d.ts.map +1 -0
  58. package/lib/types/upstream.d.ts.map +1 -1
  59. package/lib/types/web.d.ts.map +1 -1
  60. package/lib/upstream.js +31 -8
  61. package/lib/upstream.js.map +1 -1
  62. package/lib/web.js +9 -3
  63. package/lib/web.js.map +1 -1
  64. package/package.json +1 -1
  65. package/src/artifact-access.ts +22 -2
  66. package/src/client/index.tsx +18 -3
  67. package/src/client/paste-images.tsx +14 -4
  68. package/src/config.ts +61 -0
  69. package/src/errors.ts +25 -2
  70. package/src/evidence-cache.ts +2 -1
  71. package/src/exposure.ts +16 -2
  72. package/src/image-input-variants.ts +21 -6
  73. package/src/index.ts +65 -6
  74. package/src/paste-images.ts +81 -19
  75. package/src/paths.ts +249 -28
  76. package/src/runtime-manager.ts +93 -10
  77. package/src/runtime.ts +79 -11
  78. package/src/storage-history.ts +172 -0
  79. package/src/upstream.ts +32 -7
  80. package/src/web.ts +9 -2
package/src/paths.ts CHANGED
@@ -6,9 +6,10 @@
6
6
  * @module dsh-vision-toolkit/paths
7
7
  */
8
8
 
9
- import { randomUUID } from 'node:crypto'
10
- import { cp, link, lstat, mkdir, readdir, realpath, rename, rm, stat } from 'node:fs/promises'
11
- import { extname, isAbsolute, join, relative, resolve, sep, win32 } from 'node:path'
9
+ import { createHash, randomUUID } from 'node:crypto'
10
+ import type { Stats } from 'node:fs'
11
+ import { cp, link, lstat, mkdir, mkdtemp, readdir, realpath, rename, rm, stat } from 'node:fs/promises'
12
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep, win32 } from 'node:path'
12
13
  import { homedir, tmpdir } from 'node:os'
13
14
  import { VisionToolkitError } from './errors.ts'
14
15
 
@@ -23,10 +24,22 @@ export interface PathPolicy {
23
24
  tempDir: string
24
25
  /** Real allowed roots: workspace, platform temp, and configured directories. */
25
26
  allowedDirs: string[]
27
+ /** Real plugin-managed root containing artifacts and transient files. */
28
+ storageRoot: string
26
29
  /** Real plugin-managed output directory inside the fence. */
27
30
  outputDir: string
28
31
  }
29
32
 
33
+ /** Resolved plugin storage for one workspace. */
34
+ export interface WorkspaceStorage {
35
+ /** Real workspace root. */
36
+ workspace: string
37
+ /** Real plugin-managed root for this workspace. */
38
+ root: string
39
+ /** Absolute user-visible spelling of the same managed root. */
40
+ visibleRoot: string
41
+ }
42
+
30
43
  /** Whether `child` equals or lies under `parent` on the same path root. */
31
44
  export function isWithin(parent: string, child: string): boolean {
32
45
  const rel = relative(parent, child)
@@ -65,26 +78,231 @@ export function normalizePlatformTempPath(
65
78
  return win32.join(tempDirectory, raw.slice('/tmp/'.length))
66
79
  }
67
80
 
81
+ /** Stable opaque per-user workspace id used below a shared storage root. */
82
+ export function workspaceStorageId(
83
+ workspace: string,
84
+ userIdentity: string = typeof process.geteuid === 'function'
85
+ ? `uid:${process.geteuid()}`
86
+ : `home:${homedir()}`,
87
+ ): string {
88
+ return createHash('sha256')
89
+ .update(userIdentity)
90
+ .update('\0')
91
+ .update(workspace)
92
+ .digest('hex')
93
+ .slice(0, 20)
94
+ }
95
+
96
+ function currentPosixUid(): number {
97
+ if (typeof process.geteuid !== 'function') {
98
+ throw new VisionToolkitError(
99
+ 'path',
100
+ 'configured storage directory is not supported on this platform because ownership and permissions cannot be verified',
101
+ )
102
+ }
103
+ return process.geteuid()
104
+ }
105
+
106
+ export function assertSecureWorkspaceStorage(info: Stats, requested: string): void {
107
+ const currentUid = currentPosixUid()
108
+ if (info.uid !== currentUid || (info.mode & 0o777) !== 0o700) {
109
+ throw new VisionToolkitError('path', `workspace storage directory must be owned by the current user with mode 0700: ${requested}`)
110
+ }
111
+ }
112
+
113
+ /** Resolve a shared base and prove every POSIX ancestor is protected from replacement. */
114
+ export async function assertSecureSharedStorageBase(requested: string): Promise<string> {
115
+ const currentUid = currentPosixUid()
116
+ const requestedPath = resolve(requested)
117
+ const requestedChain: string[] = []
118
+ let requestedCurrent = requestedPath
119
+ while (true) {
120
+ requestedChain.push(requestedCurrent)
121
+ const parent = dirname(requestedCurrent)
122
+ if (parent === requestedCurrent) break
123
+ requestedCurrent = parent
124
+ }
125
+ for (const component of requestedChain.reverse()) {
126
+ const info = await lstat(component)
127
+ if (info.isSymbolicLink()) {
128
+ if (info.uid !== 0) {
129
+ throw new VisionToolkitError('path', `configured storage directory contains an untrusted symbolic link: ${component}`)
130
+ }
131
+ continue
132
+ }
133
+ if (!info.isDirectory()) {
134
+ throw new VisionToolkitError('path', `configured storage path component is not a directory: ${component}`)
135
+ }
136
+ const writableByOthers = (info.mode & 0o022) !== 0
137
+ const sticky = (info.mode & 0o1000) !== 0
138
+ if (
139
+ (info.uid !== currentUid && info.uid !== 0)
140
+ || (writableByOthers && !sticky)
141
+ ) throw new VisionToolkitError('path', `configured storage directory has an untrusted path component: ${component}`)
142
+ }
143
+ const canonical = await realpath(requestedPath)
144
+ let current = canonical
145
+ while (true) {
146
+ const info = await lstat(current)
147
+ const writableByOthers = (info.mode & 0o022) !== 0
148
+ const sticky = (info.mode & 0o1000) !== 0
149
+ if (
150
+ info.isSymbolicLink()
151
+ || !info.isDirectory()
152
+ || (info.uid !== currentUid && info.uid !== 0)
153
+ || (writableByOthers && !sticky)
154
+ ) throw new VisionToolkitError('path', `configured storage directory has an untrusted path component: ${current}`)
155
+ const parent = dirname(current)
156
+ if (parent === current) return canonical
157
+ current = parent
158
+ }
159
+ }
160
+
161
+ function requestedSharedStorageBase(storageDirRaw: string): string {
162
+ currentPosixUid()
163
+ const configured = normalizePlatformTempPath(expandUserHome(storageDirRaw.trim()))
164
+ if (!isAbsolute(configured)) {
165
+ throw new VisionToolkitError('path', `configured storage directory must be an absolute path: ${storageDirRaw}`)
166
+ }
167
+ return resolve(configured)
168
+ }
169
+
170
+ async function ensureSharedStorageBase(storageDirRaw: string): Promise<{ requestedBase: string; base: string }> {
171
+ const requestedBase = requestedSharedStorageBase(storageDirRaw)
172
+ try {
173
+ await mkdir(requestedBase, { recursive: true, mode: 0o700 })
174
+ } catch (error) {
175
+ throw new VisionToolkitError('path', `configured storage directory is not writable: ${requestedBase}`, { cause: error })
176
+ }
177
+ try {
178
+ return { requestedBase, base: await assertSecureSharedStorageBase(requestedBase) }
179
+ } catch (error) {
180
+ throw new VisionToolkitError('path', `configured storage directory is not accessible: ${requestedBase}`, { cause: error })
181
+ }
182
+ }
183
+
184
+ /** Validate and write-probe a configured shared root before Settings activation. */
185
+ export async function preflightSharedStorageBase(storageDirRaw: string): Promise<string> {
186
+ const { base } = await ensureSharedStorageBase(storageDirRaw)
187
+ let probe: string | undefined
188
+ let failure: unknown
189
+ try {
190
+ probe = await mkdtemp(join(base, '.dsh-vision-toolkit-preflight-'))
191
+ assertSecureWorkspaceStorage(await lstat(probe), probe)
192
+ } catch (error) {
193
+ failure = error
194
+ }
195
+ if (probe !== undefined) {
196
+ try {
197
+ await rm(probe, { recursive: true, force: true })
198
+ } catch (error) {
199
+ failure ??= error
200
+ }
201
+ }
202
+ if (failure !== undefined) {
203
+ throw new VisionToolkitError('path', `configured storage directory failed its write preflight: ${base}`, { cause: failure })
204
+ }
205
+ return base
206
+ }
207
+
208
+ async function managedDirectory(
209
+ parent: string,
210
+ requested: string,
211
+ label: string,
212
+ secureWorkspaceStorage = false,
213
+ ): Promise<string> {
214
+ try {
215
+ await mkdir(requested, { mode: 0o700 })
216
+ } catch (error) {
217
+ if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) {
218
+ throw new VisionToolkitError('path', `${label} is not writable: ${requested}`, { cause: error })
219
+ }
220
+ }
221
+ let info
222
+ try {
223
+ info = await lstat(requested)
224
+ } catch (error) {
225
+ throw new VisionToolkitError('path', `${label} is not accessible: ${requested}`, { cause: error })
226
+ }
227
+ if (info.isSymbolicLink() || !info.isDirectory()) {
228
+ throw new VisionToolkitError('path', `${label} must be a real directory: ${requested}`)
229
+ }
230
+ if (secureWorkspaceStorage) assertSecureWorkspaceStorage(info, requested)
231
+ const canonical = await realpath(requested)
232
+ if (!isWithin(parent, canonical)) {
233
+ throw new VisionToolkitError('path', `${label} escaped its configured root: ${requested}`)
234
+ }
235
+ return canonical
236
+ }
237
+
238
+ /**
239
+ * Resolve the plugin-managed root for one workspace. Blank configuration keeps
240
+ * the legacy workspace-local `.dsh-vision-toolkit` directory. A configured
241
+ * shared root receives one stable, automatically generated workspace child.
242
+ */
243
+ export async function resolveWorkspaceStorage(
244
+ workspaceRaw: string,
245
+ storageDirRaw?: string,
246
+ ): Promise<WorkspaceStorage> {
247
+ const expandedWorkspace = expandUserHome(workspaceRaw)
248
+ const visibleWorkspace = resolve(expandedWorkspace)
249
+ let workspace: string
250
+ try {
251
+ workspace = await realpath(visibleWorkspace)
252
+ } catch (error) {
253
+ throw new VisionToolkitError('path', `workspace is not accessible: ${workspaceRaw}`, { cause: error })
254
+ }
255
+
256
+ if (storageDirRaw === undefined || storageDirRaw.trim().length === 0) {
257
+ const visibleRoot = join(visibleWorkspace, '.dsh-vision-toolkit')
258
+ const root = await managedDirectory(workspace, visibleRoot, 'plugin storage directory')
259
+ return { workspace, root, visibleRoot }
260
+ }
261
+
262
+ const { requestedBase, base } = await ensureSharedStorageBase(storageDirRaw)
263
+ const id = workspaceStorageId(workspace)
264
+ const visibleRoot = join(requestedBase, id)
265
+ const root = await managedDirectory(base, join(base, id), 'workspace storage directory', true)
266
+ return { workspace, root, visibleRoot }
267
+ }
268
+
269
+ async function resolveReadableWorkspaceStorageRoot(
270
+ workspace: string,
271
+ storageDirRaw: string,
272
+ ): Promise<string> {
273
+ const requestedBase = requestedSharedStorageBase(storageDirRaw)
274
+ const base = await assertSecureSharedStorageBase(requestedBase)
275
+ const requestedRoot = join(base, workspaceStorageId(workspace))
276
+ const info = await lstat(requestedRoot)
277
+ if (info.isSymbolicLink() || !info.isDirectory()) {
278
+ throw new VisionToolkitError('path', `historical workspace storage must be a real directory: ${requestedRoot}`)
279
+ }
280
+ assertSecureWorkspaceStorage(info, requestedRoot)
281
+ const root = await realpath(requestedRoot)
282
+ if (!isWithin(base, root)) {
283
+ throw new VisionToolkitError('path', `historical workspace storage escaped its configured root: ${requestedRoot}`)
284
+ }
285
+ return root
286
+ }
287
+
68
288
  /**
69
- * Build the per-invocation path policy: realpath the workspace, resolve and
70
- * realpath the platform temp directory and allowed directories, and create
71
- * the output directory inside the fence.
289
+ * Build the per-invocation path policy: resolve workspace storage, realpath the
290
+ * platform temp directory and allowed directories, and create the artifact
291
+ * directory inside the managed root.
72
292
  * @param workspaceRaw - session workspace (or process cwd fallback).
73
293
  * @param allowedDirs - configured extra allowed roots.
74
- * @param outputDirRaw - configured output directory (default `.dsh-vision-toolkit/artifacts`).
294
+ * @param storageDirRaw - optional shared storage root.
295
+ * @param readableStorageDirs - previously validated shared roots retained for persisted input paths.
75
296
  * @returns the resolved policy.
76
297
  */
77
298
  export async function createPathPolicy(
78
299
  workspaceRaw: string,
79
300
  allowedDirs: readonly string[],
80
- outputDirRaw?: string,
301
+ storageDirRaw?: string,
302
+ readableStorageDirs: readonly string[] = [],
81
303
  ): Promise<PathPolicy> {
82
- let workspace: string
83
- try {
84
- workspace = await realpath(expandUserHome(workspaceRaw))
85
- } catch (error) {
86
- throw new VisionToolkitError('path', `workspace is not accessible: ${workspaceRaw}`, { cause: error })
87
- }
304
+ const storage = await resolveWorkspaceStorage(workspaceRaw, storageDirRaw)
305
+ const { workspace } = storage
88
306
  let tempDir: string
89
307
  const tempDirectoryRaw = platformTempDirectory()
90
308
  try {
@@ -92,7 +310,16 @@ export async function createPathPolicy(
92
310
  } catch (error) {
93
311
  throw new VisionToolkitError('path', `platform temporary directory is not accessible: ${tempDirectoryRaw}`, { cause: error })
94
312
  }
95
- const roots = [workspace, tempDir]
313
+ const roots = [workspace, tempDir, storage.root]
314
+ for (const raw of readableStorageDirs) {
315
+ if (raw === storageDirRaw) continue
316
+ try {
317
+ roots.push(await resolveReadableWorkspaceStorageRoot(workspace, raw))
318
+ } catch {
319
+ // Historical roots are read-only compatibility fences. Missing or newly
320
+ // unsafe roots stay unauthorized without breaking the active runtime.
321
+ }
322
+ }
96
323
  for (const raw of allowedDirs) {
97
324
  const candidate = expandUserHome(raw)
98
325
  const target = isAbsolute(candidate) ? candidate : resolve(workspace, candidate)
@@ -102,20 +329,14 @@ export async function createPathPolicy(
102
329
  throw new VisionToolkitError('path', `allowedDirs entry is not accessible: ${raw}`, { cause: error })
103
330
  }
104
331
  }
105
- const outputRaw = outputDirRaw === undefined || outputDirRaw.trim().length === 0
106
- ? join(workspace, '.dsh-vision-toolkit', 'artifacts')
107
- : resolve(workspace, expandUserHome(outputDirRaw))
108
- if (!roots.some(root => isWithin(root, outputRaw))) {
109
- throw new VisionToolkitError('path', 'output directory must stay inside the workspace or an allowedDirs entry')
110
- }
111
- let outputDir: string
112
- try {
113
- await mkdir(outputRaw, { recursive: true })
114
- outputDir = await realpath(outputRaw)
115
- } catch (error) {
116
- throw new VisionToolkitError('path', `output directory is not writable: ${outputRaw}`, { cause: error })
332
+ const outputDir = await managedDirectory(storage.root, join(storage.root, 'artifacts'), 'artifact directory')
333
+ return {
334
+ workspace,
335
+ tempDir,
336
+ allowedDirs: [...new Set(roots)],
337
+ storageRoot: storage.root,
338
+ outputDir,
117
339
  }
118
- return { workspace, tempDir, allowedDirs: [...new Set(roots)], outputDir }
119
340
  }
120
341
 
121
342
  /**
@@ -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(