@anionex/dsh-vision-toolkit 0.1.39 → 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 (73) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +10 -2
  3. package/README.zh.md +10 -2
  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 +147 -4
  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 +8 -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/web.d.ts.map +1 -1
  58. package/lib/web.js +9 -3
  59. package/lib/web.js.map +1 -1
  60. package/package.json +1 -1
  61. package/src/artifact-access.ts +22 -2
  62. package/src/client/index.tsx +18 -3
  63. package/src/config.ts +76 -2
  64. package/src/evidence-cache.ts +2 -1
  65. package/src/image-input-variants.ts +61 -20
  66. package/src/index.ts +65 -6
  67. package/src/paste-images.ts +81 -19
  68. package/src/paths.ts +249 -28
  69. package/src/runtime-install.ts +168 -5
  70. package/src/runtime-manager.ts +93 -10
  71. package/src/runtime.ts +15 -9
  72. package/src/storage-history.ts +172 -0
  73. package/src/web.ts +9 -2
@@ -23,6 +23,7 @@ import type {
23
23
  LlmProviderInfo,
24
24
  LlmResolvedModelInfo,
25
25
  Message,
26
+ ResolvedRetryPolicy,
26
27
  StreamChunk,
27
28
  } from '@deepseek-ai/dsh-llm'
28
29
  // Type-only imports activate the host service declarations on Context.
@@ -94,6 +95,18 @@ export function variantProviderId(upstream: string): string {
94
95
  return `${VARIANT_PROVIDER_PREFIX}${upstream}`
95
96
  }
96
97
 
98
+ /** Stable semantic key for detecting a provider retry-policy replacement. */
99
+ function retryPolicyKey(policy: ResolvedRetryPolicy): string {
100
+ const backoff = [policy.initialDelayMs, policy.maxDelayMs, policy.jitterRatio]
101
+ if (policy.mode === 'always') return JSON.stringify([policy.mode, ...backoff])
102
+ return JSON.stringify([
103
+ policy.mode,
104
+ policy.maxRetries,
105
+ [...policy.retryableCodes].sort(),
106
+ ...backoff,
107
+ ])
108
+ }
109
+
97
110
  /**
98
111
  * Whether one model earns an image-input variant: the host must positively
99
112
  * declare it text-only. A model with unknown modalities is left alone — its
@@ -129,18 +142,21 @@ async function materializeImage(
129
142
  data: Uint8Array,
130
143
  extension: string,
131
144
  sessionId: string | undefined,
145
+ storageDir: string | undefined,
132
146
  ): Promise<MaterializedImage> {
133
147
  const session = sessionId === undefined ? undefined : ctx.sessions.get(sessionId as never)
134
148
  const cwd = session?.header.cwd
135
149
  if (sessionId !== undefined && cwd !== undefined && isAbsolute(cwd)) {
136
- const root = await sessionPasteRoot(ctx, sessionId)
150
+ const root = await sessionPasteRoot(ctx, sessionId, storageDir)
137
151
  const identity = createHash('sha256')
138
152
  .update(`${sessionId}\u0000${String(block.attachment.attachmentId)}`)
139
153
  .digest('hex')
140
154
  .slice(0, 32)
141
- const file = join(root.visibleRoot, `attachment-${identity}${extension}`)
155
+ const filename = `attachment-${identity}${extension}`
156
+ const writePath = join(root.writeRoot, filename)
157
+ const file = join(root.visibleRoot, filename)
142
158
  try {
143
- await writeFile(file, Buffer.from(data), { mode: 0o600, flag: 'wx' })
159
+ await writeFile(writePath, Buffer.from(data), { mode: 0o600, flag: 'wx' })
144
160
  } catch (error) {
145
161
  if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error
146
162
  }
@@ -344,6 +360,7 @@ async function readImageBlock(
344
360
  block: ImageBlock,
345
361
  query: string,
346
362
  sessionId?: string,
363
+ storageDir?: string,
347
364
  ): Promise<ContentBlock> {
348
365
  const attachments = ctx.get('attachments')
349
366
  const current = runtime()
@@ -358,7 +375,7 @@ async function readImageBlock(
358
375
  let pathEvidence = ''
359
376
  try {
360
377
  const stored = await attachments.readImage(block.attachment)
361
- const materialized = await materializeImage(ctx, block, stored.data, extension, sessionId)
378
+ const materialized = await materializeImage(ctx, block, stored.data, extension, sessionId, storageDir)
362
379
  temporaryDirectory = materialized.temporaryDirectory
363
380
  if (materialized.persistent) pathEvidence = imagePathEvidence(materialized.file)
364
381
 
@@ -401,6 +418,7 @@ async function readImageBlock(
401
418
  * @param signal - the caller's cancellation for this conversion pass.
402
419
  * @param sessionId - the live Session identity, when available.
403
420
  * @param runtimeHash - stable fingerprint of the vision provider and evidence runtime.
421
+ * @param storageDir - optional shared plugin storage root.
404
422
  * @returns the rewritten message list.
405
423
  */
406
424
  export async function convertImagesToEvidence(
@@ -411,6 +429,7 @@ export async function convertImagesToEvidence(
411
429
  signal?: AbortSignal,
412
430
  sessionId?: string,
413
431
  runtimeHash = 'process-only-runtime',
432
+ storageDir?: string,
414
433
  ): Promise<Message[]> {
415
434
  const session = sessionId === undefined ? undefined : ctx.sessions.get(sessionId as never)
416
435
  const sessionIdentity = session === undefined
@@ -467,7 +486,7 @@ export async function convertImagesToEvidence(
467
486
  prompt: query,
468
487
  runtimeHash,
469
488
  }), () =>
470
- readImageBlock(ctx, runtime, block, query, sessionId)),
489
+ readImageBlock(ctx, runtime, block, query, sessionId, storageDir)),
471
490
  signal,
472
491
  ),
473
492
  signal,
@@ -504,6 +523,7 @@ export class ImageInputVariantAdapter extends LlmAdapter {
504
523
  private readonly runtime: () => VisionToolkitRuntime | undefined,
505
524
  private readonly cache: EvidenceCache,
506
525
  private readonly hidden: () => boolean = () => false,
526
+ private readonly startupStorageDirectory: () => string | undefined = () => undefined,
507
527
  ) {
508
528
  super()
509
529
  }
@@ -515,6 +535,10 @@ export class ImageInputVariantAdapter extends LlmAdapter {
515
535
  }
516
536
  }
517
537
 
538
+ override providerRetryPolicy(_provider: string): ResolvedRetryPolicy {
539
+ return this.llm.providerRetryPolicy(this.upstream)
540
+ }
541
+
518
542
  override async listModels(provider: string): Promise<readonly LlmModelInfo[]> {
519
543
  const models = await this.llm.listModels(this.upstream)
520
544
  return models.filter(shouldWrapModel).map((model) => ({
@@ -552,6 +576,7 @@ export class ImageInputVariantAdapter extends LlmAdapter {
552
576
 
553
577
  override async *stream(options: GenerateOptions): AsyncGenerator<StreamChunk> {
554
578
  const current = this.runtime()
579
+ const storageDir = current === undefined ? this.startupStorageDirectory() : current.storageDirectory
555
580
  let captured: CapturedEvidenceRuntime | undefined
556
581
  if (current !== undefined && options.messages.some(message => contentHasImage(message.content))) {
557
582
  try {
@@ -573,7 +598,12 @@ export class ImageInputVariantAdapter extends LlmAdapter {
573
598
  options.messages,
574
599
  options.signal,
575
600
  options.sessionId === undefined ? undefined : String(options.sessionId),
576
- captured?.evidenceFingerprint ?? 'process-only-runtime',
601
+ captured?.evidenceFingerprint ?? createHash('sha256')
602
+ .update('process-only-runtime')
603
+ .update('\0')
604
+ .update(storageDir ?? '')
605
+ .digest('hex'),
606
+ storageDir,
577
607
  )
578
608
  // Delegate through the host service under the upstream route: the variant
579
609
  // is a wire-only facade, and the upstream route owns retry and replay.
@@ -809,13 +839,13 @@ export function installImageInputVariants(
809
839
  ctx: Context,
810
840
  getConfig: () => ResolvedVisionToolkitConfig,
811
841
  getRuntime: () => VisionToolkitRuntime | undefined,
842
+ getStartupStorageDirectory: () => string | undefined = () => undefined,
812
843
  ): { dispose: () => void; reconcile: () => void } {
813
844
  const evidenceStore = new SessionEvidenceStore(ctx)
814
845
  const evidenceCache = new EvidenceCache(EVIDENCE_CACHE_LIMIT, evidenceStore)
815
- const registrations = new Map<string, () => void>()
816
- // The host snapshots adapter provider metadata (including the group display
817
- // name) at registration time, so a transparent-routing toggle must rebuild
818
- // every wrapper for the new names to reach the model selector.
846
+ const registrations = new Map<string, { dispose: () => void; retryPolicyKey: string }>()
847
+ // The host snapshots adapter provider metadata and retry policy at registration
848
+ // time, so changes to either require rebuilding the wrapper route.
819
849
  let lastHidden = false
820
850
  // Upstream ids observed missing and the timestamp of the first missing
821
851
  // observation. A wrapper is only released after its upstream stays absent
@@ -832,12 +862,12 @@ export function installImageInputVariants(
832
862
  let sweepQueued = false
833
863
 
834
864
  const release = (upstream: string): void => {
835
- const dispose = registrations.get(upstream)
836
- if (dispose === undefined) return
865
+ const registration = registrations.get(upstream)
866
+ if (registration === undefined) return
837
867
  registrations.delete(upstream)
838
868
  stale.delete(upstream)
839
869
  try {
840
- dispose()
870
+ registration.dispose()
841
871
  } catch (error) {
842
872
  ctx.logger.warn(
843
873
  'dsh-vision-toolkit: image-input variant release failed for "%s": %s',
@@ -916,18 +946,28 @@ export function installImageInputVariants(
916
946
  continue
917
947
  }
918
948
  const eligible = models.some(shouldWrapModel)
919
- const registered = registrations.has(upstream)
920
- if (registered && live.has(variantId)) {
949
+ let upstreamRetryPolicyKey: string
950
+ try {
951
+ upstreamRetryPolicyKey = retryPolicyKey(llm.providerRetryPolicy(upstream))
952
+ } catch {
953
+ // Preserve an existing wrapper through a transient upstream registry
954
+ // gap; the next topology event or periodic sweep will retry the probe.
955
+ continue
956
+ }
957
+ let registration = registrations.get(upstream)
958
+ if (registration !== undefined && live.has(variantId)) {
921
959
  // Re-read the live registry after the await: a rebuild inside the
922
960
  // probe window can drop our wrapper without touching our map.
923
961
  const liveNow = new Set(llm.listProviders().map(provider => provider.id))
924
962
  if (liveNow.has(variantId)) {
925
- // Healthy handle: only react when the route lost every eligible model.
926
- if (!eligible) release(upstream)
927
- continue
963
+ if (eligible && registration.retryPolicyKey === upstreamRetryPolicyKey) continue
964
+ // Eligibility and retry policy are registration-time facts. Rebuild
965
+ // whenever either no longer matches the live upstream route.
966
+ release(upstream)
967
+ registration = undefined
928
968
  }
929
969
  }
930
- if (registered) {
970
+ if (registration !== undefined) {
931
971
  // Dead handle from a registry rebuild/reset: drop it and register a
932
972
  // fresh wrapper below.
933
973
  release(upstream)
@@ -945,9 +985,10 @@ export function installImageInputVariants(
945
985
  getRuntime,
946
986
  evidenceCache,
947
987
  () => getConfig().imageInputVariants.hidden,
988
+ getStartupStorageDirectory,
948
989
  ),
949
990
  )
950
- registrations.set(upstream, dispose)
991
+ registrations.set(upstream, { dispose, retryPolicyKey: upstreamRetryPolicyKey })
951
992
  stale.delete(upstream)
952
993
  } catch (error) {
953
994
  ctx.logger.warn(
package/src/index.ts CHANGED
@@ -18,13 +18,16 @@ import { ArtifactAccessController, prepareArtifactAccessKey } from './artifact-a
18
18
  import {
19
19
  Config,
20
20
  VISION_TOOLKIT_SETTINGS_NAMESPACE,
21
+ prepareWatchedSettingsGeneration,
21
22
  resolveConfig,
23
+ type ResolvedVisionToolkitConfig,
22
24
  type VisionToolkitConfig,
23
25
  } from './config.ts'
24
26
  import { VisionToolExposure } from './exposure.ts'
25
27
  import { createPasteTakeoverResolver, installImageInputVariants } from './image-input-variants.ts'
26
28
  import { VisionToolkitRuntimeManager } from './runtime-manager.ts'
27
29
  import { VISION_SKILLS_SKILL } from './skill.ts'
30
+ import { StorageHistoryStore } from './storage-history.ts'
28
31
  import { createVisionTools } from './tools.ts'
29
32
  import { PLUGIN_VERSION } from './version.ts'
30
33
  import { installVisionToolkitWeb, VisionToolkitWebBackend } from './web.ts'
@@ -51,8 +54,33 @@ export async function apply(ctx: Context, config: VisionToolkitConfig = {}): Pro
51
54
  const artifacts = new ArtifactAccessController(await prepareArtifactAccessKey())
52
55
  const lifecycle = new AbortController()
53
56
  const disposers: Array<() => void> = []
57
+ const storageHistory = new StorageHistoryStore(ctx)
58
+ disposers.push(() => { storageHistory.dispose() })
59
+ let storageHistoryWarningReported = false
54
60
  let operationalDisposers: { activationTool: () => void; exposure: () => void; skill: () => void } | undefined
55
61
 
62
+ const persistStorageHistory = async (candidate: VisionToolkitConfig, required: boolean): Promise<void> => {
63
+ try {
64
+ const persisted = await storageHistory.persist(candidate)
65
+ if (persisted) return
66
+ const error = new Error(
67
+ 'configured storage history requires @deepseek-ai/dsh-storage-domain when Settings cannot persist it',
68
+ )
69
+ if (required) throw error
70
+ if (!storageHistoryWarningReported) {
71
+ storageHistoryWarningReported = true
72
+ ctx.logger.warn('dsh-vision-toolkit: %s', error.message)
73
+ }
74
+ } catch (error) {
75
+ if (required) throw error
76
+ if (!storageHistoryWarningReported) {
77
+ storageHistoryWarningReported = true
78
+ const message = error instanceof Error ? error.message : String(error)
79
+ ctx.logger.warn('dsh-vision-toolkit: configured storage history was not persisted. %s', message)
80
+ }
81
+ }
82
+ }
83
+
56
84
  const ensureOperational = (): void => {
57
85
  if (!manager.ready || operationalDisposers !== undefined) return
58
86
  const exposure = new VisionToolExposure(ctx, () => createVisionTools(
@@ -84,10 +112,16 @@ export async function apply(ctx: Context, config: VisionToolkitConfig = {}): Pro
84
112
  }
85
113
  }
86
114
 
115
+ const initialConfig = await storageHistory.restore(settings.get())
87
116
  try {
88
- await manager.initialize(settings.get())
117
+ await manager.initialize(initialConfig, candidate => persistStorageHistory(candidate.config, false))
89
118
  ensureOperational()
90
119
  } catch (error) {
120
+ const resolvedInitial = resolveConfig(initialConfig)
121
+ if (resolvedInitial.storageDir === undefined
122
+ || manager.validatedStorageDirectory() === resolvedInitial.storageDir) {
123
+ await persistStorageHistory(initialConfig, false)
124
+ }
91
125
  const message = error instanceof Error ? error.message : String(error)
92
126
  ctx.logger.error(
93
127
  'dsh-vision-toolkit %s: runtime not ready; the vision-skills skill, activation bootstrap, and Agent-scoped visual tools are NOT registered. Settings remain available for repair. %s',
@@ -97,29 +131,54 @@ export async function apply(ctx: Context, config: VisionToolkitConfig = {}): Pro
97
131
  }
98
132
 
99
133
  const backend = new VisionToolkitWebBackend(ctx, manager, artifacts, ensureOperational)
134
+ const currentConfig = (): ResolvedVisionToolkitConfig => manager.ready
135
+ ? manager.currentConfig()
136
+ : resolveConfig(settings.get())
100
137
  const pastedImages = new PastedImageBackend(ctx, {
101
138
  maxUploadBytes: () => MAX_PASTE_IMAGE_BYTES,
139
+ storageGeneration: () => manager.storageGeneration(),
102
140
  })
103
141
  // Image-input variants register asynchronously once eligible routes exist;
104
142
  // the runtime getter stays lazy so variants appear even when the runtime
105
143
  // becomes ready after the first sweep.
106
144
  const variants = installImageInputVariants(
107
145
  ctx,
108
- () => resolveConfig(settings.get()),
146
+ currentConfig,
109
147
  () => manager.ready ? manager.current() : undefined,
148
+ () => manager.validatedStorageDirectory(),
110
149
  )
111
150
  installVisionToolkitWeb(
112
151
  ctx,
113
152
  backend,
114
153
  artifacts,
115
154
  pastedImages,
116
- createPasteTakeoverResolver(ctx, () => resolveConfig(settings.get())),
117
- () => ({ hidden: resolveConfig(settings.get()).imageInputVariants.hidden }),
155
+ createPasteTakeoverResolver(ctx, currentConfig),
156
+ () => ({ hidden: currentConfig().imageInputVariants.hidden }),
118
157
  )
119
158
  disposers.push(variants.dispose)
120
- disposers.push(settings.watch(async (next) => {
159
+ disposers.push(settings.watch(async (next, previous) => {
121
160
  try {
122
- await manager.reconfigure(next)
161
+ const prepared = await prepareWatchedSettingsGeneration(
162
+ next,
163
+ previous,
164
+ ctx.settings.writable,
165
+ storageHistory => settings.update({ storageHistory }),
166
+ )
167
+ if (prepared.persistenceError !== undefined) {
168
+ const message = prepared.persistenceError instanceof Error
169
+ ? prepared.persistenceError.message
170
+ : String(prepared.persistenceError)
171
+ ctx.logger.warn('dsh-vision-toolkit: activating Settings without persisting internal storage history. %s', message)
172
+ }
173
+ if (prepared.config === undefined) return
174
+ const candidate = await storageHistory.restore(prepared.config)
175
+ await manager.reconfigure(
176
+ candidate,
177
+ generation => persistStorageHistory(
178
+ generation.config,
179
+ prepared.requiresDurableStorageHistory === true,
180
+ ),
181
+ )
123
182
  ensureOperational()
124
183
  variants.reconcile()
125
184
  } catch (error) {
@@ -1,11 +1,13 @@
1
- /** Workspace-local storage for images pasted into the DSH Web composer. */
1
+ /** Plugin-managed storage for images pasted into the DSH Web composer. */
2
2
 
3
3
  import { createHash, randomUUID } from 'node:crypto'
4
- import { lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises'
4
+ import { constants as fsConstants } from 'node:fs'
5
+ import { copyFile, lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises'
5
6
  import type { IncomingMessage, ServerResponse } from 'node:http'
6
7
  import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
7
8
  import type { Context } from '@deepseek-ai/cordis'
8
9
  import type {} from '@deepseek-ai/dsh-session'
10
+ import { resolveWorkspaceStorage } from './paths.ts'
9
11
  import { sameOriginPost } from './web-request.ts'
10
12
 
11
13
  /** Exact route used by the browser paste integration. */
@@ -51,6 +53,7 @@ export interface PasteVerdict {
51
53
  }
52
54
 
53
55
  const MAX_NAME_BYTES = 180
56
+ const MAX_STORAGE_GENERATION_RETRIES = 4
54
57
 
55
58
  /**
56
59
  * Hard per-image upload ceiling for pastes. Files between the configured
@@ -151,7 +154,7 @@ export function safePastedImageName(raw: string, mediaType: string): string {
151
154
  export function ensurePathInside(root: string, target: string): void {
152
155
  const rel = relative(root, target)
153
156
  if (rel !== '' && (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))) {
154
- throw new Error(`resolved pasted-image path escapes its workspace root: ${target}`)
157
+ throw new Error(`resolved pasted-image path escapes its managed root: ${target}`)
155
158
  }
156
159
  }
157
160
 
@@ -168,7 +171,7 @@ async function ensureManagedDirectory(workspace: string, path: string): Promise<
168
171
  }
169
172
  const entry = await lstat(path)
170
173
  if (entry.isSymbolicLink()) {
171
- throw new Error(`resolved pasted-image path escapes its workspace root: symbolic link ${path}`)
174
+ throw new Error(`resolved pasted-image path escapes its managed root: symbolic link ${path}`)
172
175
  }
173
176
  if (!entry.isDirectory()) throw new Error(`pasted-image path is not a directory: ${path}`)
174
177
  const canonical = await realpath(path)
@@ -178,29 +181,33 @@ async function ensureManagedDirectory(workspace: string, path: string): Promise<
178
181
 
179
182
  /**
180
183
  * Resolve the managed per-session image directory used by both browser pastes
181
- * and native attachment bridging. Keeping both flows under the same workspace
182
- * root makes the resulting absolute path valid for the model's visual tools.
184
+ * and native attachment bridging. A configured shared storage root receives a
185
+ * stable workspace-specific child, keeping projects isolated without writing
186
+ * plugin files into the project directory.
183
187
  */
184
- export async function sessionPasteRoot(ctx: Context, sessionId: string): Promise<PasteRoot> {
188
+ export async function sessionPasteRoot(
189
+ ctx: Context,
190
+ sessionId: string,
191
+ storageDir?: string,
192
+ ): Promise<PasteRoot> {
185
193
  const session = ctx.sessions.get(sessionId as never)
186
194
  if (session === undefined) throw new Error(`live Session not found: ${sessionId}`)
187
195
  const cwd = session.header.cwd
188
196
  if (cwd === undefined || !isAbsolute(cwd)) throw new Error(`Session has no absolute workspace: ${sessionId}`)
189
197
 
190
- const visibleWorkspace = resolve(cwd)
191
- const workspace = await realpath(visibleWorkspace)
192
- const pluginRoot = join(visibleWorkspace, '.dsh-vision-toolkit')
193
- await ensureManagedDirectory(workspace, pluginRoot)
198
+ const storage = await resolveWorkspaceStorage(resolve(cwd), storageDir)
199
+ const pluginRoot = storage.root
194
200
  const temporaryRoot = join(pluginRoot, 'tmp')
195
- await ensureManagedDirectory(workspace, temporaryRoot)
201
+ await ensureManagedDirectory(pluginRoot, temporaryRoot)
196
202
  const requestedRoot = join(temporaryRoot, 'pasted-images')
197
- const root = await ensureManagedDirectory(workspace, requestedRoot)
203
+ const root = await ensureManagedDirectory(temporaryRoot, requestedRoot)
204
+ const visibleRoot = join(storage.visibleRoot, 'tmp', 'pasted-images')
198
205
 
199
206
  const sessionKey = createHash('sha256').update(sessionId).digest('hex').slice(0, 20)
200
207
  const requestedSessionRoot = join(requestedRoot, sessionKey)
201
208
  const sessionRoot = await ensureManagedDirectory(root, requestedSessionRoot)
202
209
  ensurePathInside(root, sessionRoot)
203
- return { writeRoot: sessionRoot, visibleRoot: requestedSessionRoot }
210
+ return { writeRoot: sessionRoot, visibleRoot: join(visibleRoot, sessionKey) }
204
211
  }
205
212
 
206
213
  async function writeImage(
@@ -240,9 +247,38 @@ async function writeImage(
240
247
  }
241
248
  }
242
249
 
250
+ async function copyImage(
251
+ source: string,
252
+ directory: string,
253
+ filename: string,
254
+ ): Promise<string> {
255
+ const id = randomUUID()
256
+ const finalPath = join(directory, `${id}-${filename}`)
257
+ const stagingPath = join(directory, `.${id}.partial`)
258
+ ensurePathInside(directory, finalPath)
259
+ ensurePathInside(directory, stagingPath)
260
+ try {
261
+ await copyFile(source, stagingPath, fsConstants.COPYFILE_EXCL)
262
+ await rename(stagingPath, finalPath)
263
+ return finalPath
264
+ } catch (error) {
265
+ await rm(stagingPath, { force: true }).catch(() => {})
266
+ throw error
267
+ }
268
+ }
269
+
270
+ export interface PasteStorageGeneration {
271
+ generation: number
272
+ storageDir?: string
273
+ }
274
+
275
+ class PasteStorageChangedError extends Error {}
276
+
243
277
  /** Runtime limit face kept separate for focused backend tests. */
244
278
  export interface PasteImageRuntime {
245
279
  maxUploadBytes(): number
280
+ storageDirectory?(): string | undefined
281
+ storageGeneration?(): PasteStorageGeneration
246
282
  }
247
283
 
248
284
  /** Same-origin, live-Session-bound image upload endpoint. */
@@ -252,6 +288,16 @@ export class PastedImageBackend {
252
288
  private readonly runtime: PasteImageRuntime,
253
289
  ) {}
254
290
 
291
+ private storageGeneration(): PasteStorageGeneration {
292
+ const current = this.runtime.storageGeneration?.()
293
+ if (current !== undefined) return current
294
+ const storageDir = this.runtime.storageDirectory?.()
295
+ return {
296
+ generation: 0,
297
+ ...(storageDir === undefined ? {} : { storageDir }),
298
+ }
299
+ }
300
+
255
301
  async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
256
302
  if (req.method !== 'POST') {
257
303
  res.setHeader('Allow', 'POST')
@@ -263,6 +309,7 @@ export class PastedImageBackend {
263
309
  return
264
310
  }
265
311
 
312
+ let managedPath: string | undefined
266
313
  try {
267
314
  const url = new URL(req.url ?? PASTE_IMAGES_ROUTE, 'http://dsh.internal')
268
315
  const sessionId = singleQuery(url, 'sessionId')
@@ -273,12 +320,27 @@ export class PastedImageBackend {
273
320
  if (contentLength !== undefined && Number(contentLength) !== size) {
274
321
  throw new TypeError('Content-Length does not match the declared size')
275
322
  }
276
- const directory = await sessionPasteRoot(this.ctx, sessionId)
277
- const writtenPath = await writeImage(req, directory.writeRoot, filename, size, this.runtime.maxUploadBytes())
278
- const absolutePath = join(directory.visibleRoot, basename(writtenPath))
279
- responseJson(res, 201, { ok: true, value: { absolutePath, filename, bytes: size } })
323
+ let storage = this.storageGeneration()
324
+ let directory = await sessionPasteRoot(this.ctx, sessionId, storage.storageDir)
325
+ managedPath = await writeImage(req, directory.writeRoot, filename, size, this.runtime.maxUploadBytes())
326
+ for (let attempt = 0; attempt < MAX_STORAGE_GENERATION_RETRIES; attempt += 1) {
327
+ const current = this.storageGeneration()
328
+ if (current.generation === storage.generation && current.storageDir === storage.storageDir) {
329
+ const absolutePath = join(directory.visibleRoot, basename(managedPath))
330
+ responseJson(res, 201, { ok: true, value: { absolutePath, filename, bytes: size } })
331
+ return
332
+ }
333
+ const nextDirectory = await sessionPasteRoot(this.ctx, sessionId, current.storageDir)
334
+ const migratedPath = await copyImage(managedPath, nextDirectory.writeRoot, filename)
335
+ await rm(managedPath, { force: true }).catch(() => {})
336
+ managedPath = migratedPath
337
+ directory = nextDirectory
338
+ storage = current
339
+ }
340
+ throw new PasteStorageChangedError('Vision Toolkit settings changed repeatedly during image copy; retry the paste')
280
341
  } catch (error) {
281
- const status = error instanceof RangeError ? 413 : 400
342
+ if (managedPath !== undefined) await rm(managedPath, { force: true }).catch(() => {})
343
+ const status = error instanceof PasteStorageChangedError ? 409 : error instanceof RangeError ? 413 : 400
282
344
  this.ctx.logger.warn('dsh-vision-toolkit pasted image rejected: %s', message(error))
283
345
  requestError(res, status, 'paste-image-rejected', message(error))
284
346
  }