@anionex/dsh-vision-toolkit 0.1.37 → 0.1.39

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.
@@ -88,6 +88,8 @@ 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
91
93
 
92
94
  /** Absolute root of the packaged upstream snapshot. */
93
95
  export function bundledUpstreamRoot(): string {
@@ -103,6 +105,47 @@ function sha256(bytes: string | Buffer): string {
103
105
  return createHash('sha256').update(bytes).digest('hex')
104
106
  }
105
107
 
108
+ /**
109
+ * Windows Defender/antivirus real-time scanning briefly locks freshly written
110
+ * Python DLLs, so recursive removal and directory replacement can fail with
111
+ * EBUSY/EPERM immediately after installation. Retry those transient Windows
112
+ * errors before surfacing them; non-Windows platforms pass through unchanged.
113
+ */
114
+ export async function withWindowsTransientRetry<T>(operation: () => Promise<T>): Promise<T> {
115
+ let lastError: unknown
116
+ for (let attempt = 1; attempt <= WINDOWS_FILE_RETRY_ATTEMPTS; attempt += 1) {
117
+ try {
118
+ return await operation()
119
+ } catch (error) {
120
+ lastError = error
121
+ const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined
122
+ const transient = process.platform === 'win32' && (code === 'EBUSY' || code === 'EPERM' || code === 'EACCES')
123
+ if (!transient) break
124
+ if (attempt < WINDOWS_FILE_RETRY_ATTEMPTS) {
125
+ await new Promise(resolveWait => setTimeout(resolveWait, WINDOWS_FILE_RETRY_DELAY_MS * attempt))
126
+ }
127
+ }
128
+ }
129
+ throw lastError
130
+ }
131
+
132
+ /**
133
+ * Best-effort removal used after the primary runtime path has already
134
+ * succeeded or failed. Transient Windows locks must not turn a usable runtime
135
+ * into an error, but leaving the directory behind should still be audible.
136
+ */
137
+ export async function ignoreCleanupFailure(ctx: Context, label: string, path: string): Promise<void> {
138
+ try {
139
+ await withWindowsTransientRetry(() => rm(path, { recursive: true, force: true }))
140
+ } catch (error) {
141
+ ctx.logger.warn(
142
+ 'dsh-vision-toolkit: %s cleanup failed: %s',
143
+ label,
144
+ error instanceof Error ? error.message : String(error),
145
+ )
146
+ }
147
+ }
148
+
106
149
  export function isolatedPythonEnvironment(home: string): NodeJS.ProcessEnv {
107
150
  return {
108
151
  HOME: home,
@@ -488,7 +531,7 @@ async function withDirectoryLock<T>(lockPath: string, fn: () => Promise<T>): Pro
488
531
  await writeFile(join(lockPath, 'owner'), `${owner}\n`, { flag: 'wx' })
489
532
  } catch (error) {
490
533
  if (acquired) {
491
- await rm(lockPath, { recursive: true, force: true }).catch(() => {})
534
+ await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true })).catch(() => {})
492
535
  throw error
493
536
  }
494
537
  if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
@@ -497,7 +540,7 @@ async function withDirectoryLock<T>(lockPath: string, fn: () => Promise<T>): Pro
497
540
  try {
498
541
  const info = await stat(lockPath)
499
542
  if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
500
- await rm(lockPath, { recursive: true, force: true })
543
+ await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true }))
501
544
  return withDirectoryLock(lockPath, fn)
502
545
  }
503
546
  } catch {
@@ -518,7 +561,7 @@ async function withDirectoryLock<T>(lockPath: string, fn: () => Promise<T>): Pro
518
561
  clearInterval(heartbeat)
519
562
  try {
520
563
  if ((await readFile(join(lockPath, 'owner'), 'utf8')).trim() === owner) {
521
- await rm(lockPath, { recursive: true, force: true })
564
+ await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true }))
522
565
  }
523
566
  } catch {
524
567
  // The lock was already removed or replaced.
@@ -552,7 +595,7 @@ export async function acquireBundledPython(
552
595
  if (ready !== undefined) return
553
596
  const parent = dirname(root)
554
597
  await mkdir(parent, { recursive: true })
555
- await rm(root, { recursive: true, force: true })
598
+ await withWindowsTransientRetry(() => rm(root, { recursive: true, force: true }))
556
599
  const work = await mkdtemp(join(parent, '.python-bootstrap-'))
557
600
  try {
558
601
  const archive = join(work, 'python.tar.gz')
@@ -589,9 +632,9 @@ export async function acquireBundledPython(
589
632
  )
590
633
  }
591
634
  if (process.platform !== 'win32') await chmod(extractedInterpreter, 0o755)
592
- await rename(extractDir, root)
635
+ await withWindowsTransientRetry(() => rename(extractDir, root))
593
636
  } finally {
594
- await rm(work, { recursive: true, force: true })
637
+ await ignoreCleanupFailure(ctx, 'bundled Python staging', work)
595
638
  }
596
639
  })
597
640
  const metadata = await pythonMetadata(ctx, command, cwd)
@@ -827,7 +870,7 @@ async function waitForManagedRuntime(
827
870
  try {
828
871
  const info = await stat(lockPath)
829
872
  if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
830
- await rm(lockPath, { recursive: true, force: true })
873
+ await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true }))
831
874
  return undefined
832
875
  }
833
876
  } catch {
@@ -844,7 +887,7 @@ async function releaseManagedLock(lockPath: string, owner: string): Promise<void
844
887
  } catch {
845
888
  return
846
889
  }
847
- await rm(lockPath, { recursive: true, force: true })
890
+ await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true }))
848
891
  }
849
892
 
850
893
  async function prepareManaged(
@@ -899,7 +942,7 @@ async function prepareManaged(
899
942
  await writeFile(join(lockPath, 'owner'), `${lockOwner}\n`, { flag: 'wx' })
900
943
  } catch (error) {
901
944
  if (lockAcquired) {
902
- await rm(lockPath, { recursive: true, force: true })
945
+ await withWindowsTransientRetry(() => rm(lockPath, { recursive: true, force: true })).catch(() => {})
903
946
  throw error
904
947
  }
905
948
  if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
@@ -1016,17 +1059,17 @@ async function prepareManaged(
1016
1059
  const quarantine = `${finalRoot}.replaced-${randomUUID()}`
1017
1060
  let quarantined = false
1018
1061
  try {
1019
- await rename(finalRoot, quarantine)
1062
+ await withWindowsTransientRetry(() => rename(finalRoot, quarantine))
1020
1063
  quarantined = true
1021
1064
  } catch (error) {
1022
1065
  if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
1023
1066
  }
1024
1067
  try {
1025
- await rename(staging, finalRoot)
1068
+ await withWindowsTransientRetry(() => rename(staging, finalRoot))
1026
1069
  } catch (error) {
1027
1070
  if (quarantined) {
1028
1071
  try {
1029
- await rename(quarantine, finalRoot)
1072
+ await withWindowsTransientRetry(() => rename(quarantine, finalRoot))
1030
1073
  } catch (restoreError) {
1031
1074
  throw new VisionToolkitError(
1032
1075
  'runtime',
@@ -1037,13 +1080,18 @@ async function prepareManaged(
1037
1080
  }
1038
1081
  throw error
1039
1082
  }
1040
- await rm(quarantine, { recursive: true, force: true })
1083
+ await ignoreCleanupFailure(ctx, 'managed runtime quarantine', quarantine)
1041
1084
  const python: RuntimeCommand = { program: interpreter, prefix: [], display: interpreter }
1042
1085
  return { source: 'managed', root: BUNDLED_ROOT, python, cleanHome, pythonVersion: metadata.version, dependencies }
1043
1086
  } finally {
1044
1087
  clearInterval(heartbeat)
1045
- await rm(staging, { recursive: true, force: true })
1046
- await releaseManagedLock(lockPath, lockOwner)
1088
+ await ignoreCleanupFailure(ctx, 'managed runtime staging', staging)
1089
+ await releaseManagedLock(lockPath, lockOwner).catch(error => {
1090
+ ctx.logger.warn(
1091
+ 'dsh-vision-toolkit: managed runtime lock cleanup failed: %s',
1092
+ error instanceof Error ? error.message : String(error),
1093
+ )
1094
+ })
1047
1095
  }
1048
1096
  }
1049
1097
 
package/src/runtime.ts CHANGED
@@ -16,6 +16,7 @@ import { SaxesParser } from 'saxes'
16
16
  import { describeArtifact, type ArtifactDescriptor } from './artifacts.ts'
17
17
  import { isBuiltInFreeVisionProvider, type ResolvedVisionToolkitConfig } from './config.ts'
18
18
  import { BUILT_IN_FREE_VISION_KEY } from './defaults.ts'
19
+ import { evidenceRuntimeFingerprint } from './evidence-cache.ts'
19
20
  import { VisionToolkitError } from './errors.ts'
20
21
  import {
21
22
  assertDistinctOutput,
@@ -480,6 +481,12 @@ export interface ToolCallOptions {
480
481
  sessionScope?: object
481
482
  }
482
483
 
484
+ /** One immutable vision-service snapshot used to generate cache-keyed evidence. */
485
+ export interface CapturedEvidenceRuntime {
486
+ readonly evidenceFingerprint: string
487
+ glance(request: GlanceRequest, options: ToolCallOptions): Promise<GlanceResult>
488
+ }
489
+
483
490
  interface GlanceCacheEntry {
484
491
  key: string
485
492
  result: GlanceResult
@@ -698,6 +705,25 @@ export class VisionToolkitRuntime {
698
705
  return this.adapter.versionInfo
699
706
  }
700
707
 
708
+ /** Stable identity for persisted image descriptions produced by this runtime. */
709
+ get evidenceFingerprint(): string {
710
+ return evidenceRuntimeFingerprint(this.config, undefined, process.env.VISION_SSL_VERIFY?.trim())
711
+ }
712
+
713
+ /** Capture the credential and provider identity used by one evidence conversion. */
714
+ async captureEvidenceRuntime(): Promise<CapturedEvidenceRuntime> {
715
+ const env = await this.resolveVisionEnv()
716
+ const evidenceFingerprint = evidenceRuntimeFingerprint(
717
+ this.config,
718
+ createHash('sha256').update(env.VISION_API_KEY).digest('hex'),
719
+ env.VISION_SSL_VERIFY,
720
+ )
721
+ return Object.freeze({
722
+ evidenceFingerprint,
723
+ glance: (request: GlanceRequest, options: ToolCallOptions) => this.glanceWithEnv(request, options, env),
724
+ })
725
+ }
726
+
701
727
  private timeout(options: ToolCallOptions): number {
702
728
  const value = options.timeoutMs ?? this.config.timeoutMs
703
729
  if (!Number.isInteger(value) || value < 1000 || value > MAX_TIMEOUT_MS) {
@@ -1213,6 +1239,14 @@ export class VisionToolkitRuntime {
1213
1239
 
1214
1240
  /** glance: describe, targeted QA, OCR, or multi-image comparison. */
1215
1241
  async glance(request: GlanceRequest, options: ToolCallOptions): Promise<GlanceResult> {
1242
+ return this.glanceWithEnv(request, options)
1243
+ }
1244
+
1245
+ private async glanceWithEnv(
1246
+ request: GlanceRequest,
1247
+ options: ToolCallOptions,
1248
+ capturedEnv?: UpstreamEnvironment,
1249
+ ): Promise<GlanceResult> {
1216
1250
  return this.runOperation('vision_glance', options, async (operation) => {
1217
1251
  if (request.images.length === 0) throw new VisionToolkitError('input', 'glance requires at least one image')
1218
1252
  if (request.query !== undefined && request.ocr === true) {
@@ -1235,7 +1269,7 @@ export class VisionToolkitRuntime {
1235
1269
  this.accountImage(image, operation)
1236
1270
  images.push(image)
1237
1271
  }
1238
- const env = await this.resolveVisionEnv()
1272
+ const env = capturedEnv ?? await this.resolveVisionEnv()
1239
1273
  const cacheKey = options.sessionScope === undefined
1240
1274
  ? undefined
1241
1275
  : await this.glanceCacheKey(request, images, env, operation.signal)
package/src/upstream.ts CHANGED
@@ -541,9 +541,19 @@ const VISION_MODEL_GUARD = [
541
541
  ' requested=prompt or vision_client.DEFAULT_PROMPT',
542
542
  ' return original_describe(image_url,f"{policy}\\n\\n{requested}",*args,**kwargs)',
543
543
  ' vision_client.describe_image=guarded_describe',
544
+ ' ground_module=None',
545
+ ' original_parse_matches=None',
546
+ ' if Path(script).name in {"ground","detect"} and importlib.util.find_spec("ground") is not None:',
547
+ ' import ground as ground_module',
548
+ ' original_parse_matches=ground_module.parse_matches',
549
+ ' def normalized_parse_matches(*args,**kwargs):',
550
+ ' matches=original_parse_matches(*args,**kwargs)',
551
+ ' return [ground_module.Match(" ".join(str(match.label).split()),match.bbox) for match in matches]',
552
+ ' ground_module.parse_matches=normalized_parse_matches',
544
553
  ' try:',
545
554
  ' runpy.run_path(script,run_name="__main__")',
546
555
  ' finally:',
556
+ ' if ground_module is not None: ground_module.parse_matches=original_parse_matches',
547
557
  ' vision_client.describe_image=original_describe',
548
558
  ].join('\n')
549
559