commands-upload-file.host.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. import { Context } from '@deepseek-ai/cordis'
  2. import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
  3. import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
  4. import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment'
  5. import type {
  6. FileAttachmentRef, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment,
  7. } from '@deepseek-ai/dsh-attachment'
  8. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  9. import type { UserMessage } from '@deepseek-ai/dsh-llm'
  10. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  11. import CommandRuntime from '@deepseek-ai/dsh-commands'
  12. import { createScope } from '@deepseek-ai/dsh-scope'
  13. import FileUploads from '@deepseek-ai/dsh-client-file-upload'
  14. import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types'
  15. import { describe, expect, it, vi } from 'vitest'
  16. import type { ApiSessionAgentController } from '../src/agent.ts'
  17. import { SessionCommandController } from '../src/commands.ts'
  18. import type { SessionRequestId } from '../src/types.ts'
  19. const SESSION = SessionId('upload-session')
  20. async function uploadHarness(origin?: 'subagent'): Promise<{
  21. ctx: Context
  22. controller: SessionCommandController
  23. uploads: FileUploads
  24. agent: Agent
  25. followup: ReturnType<typeof vi.fn>
  26. saveFile: ReturnType<typeof vi.fn>
  27. saveFileStream: ReturnType<typeof vi.fn>
  28. saveImages: ReturnType<typeof vi.fn>
  29. disposeAgent: () => void
  30. uploadRoute: (request: Request) => Promise<Response>
  31. }> {
  32. const ctx = new Context()
  33. await ctx.plugin(SessionStore)
  34. await ctx.plugin(AgentRegistry)
  35. await ctx.plugin(CommandRuntime)
  36. const session = ctx.sessions.create(SESSION, {
  37. meta: { cwd: '/workspace', ...(origin === undefined ? {} : { origin }) },
  38. })
  39. const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
  40. const followup = vi.fn()
  41. const agent = {
  42. id: session.id,
  43. session,
  44. inbox,
  45. status: 'idle',
  46. ctx: undefined,
  47. steer: vi.fn(),
  48. followup,
  49. cancel: vi.fn(),
  50. } as unknown as Agent
  51. ;(agent as { ctx: Context }).ctx = createScope(ctx, agent).ctx
  52. const disposeAgent = ctx.agents.register(agent)
  53. const saveFile = vi.fn((input: SaveFileAttachment): Promise<FileAttachmentRef> => Promise.resolve({
  54. attachmentId: AttachmentId(`sha256:${'cd'.repeat(32)}`),
  55. name: input.name ?? 'file',
  56. bytes: input.data.byteLength,
  57. }))
  58. const saveFileStream = vi.fn(async (input: SaveFileStreamAttachment): Promise<FileAttachmentRef> => {
  59. let bytes = 0
  60. for await (const chunk of input.data) bytes += chunk.byteLength
  61. return {
  62. attachmentId: AttachmentId(`sha256:${'ef'.repeat(32)}`),
  63. name: input.name ?? 'file',
  64. bytes,
  65. }
  66. })
  67. const saveImages = vi.fn((): Promise<readonly ImageAttachmentRef[]> =>
  68. Promise.reject(new Error('fixture did not expect image persistence')))
  69. ctx.provide('attachments', Object.setPrototypeOf(
  70. { saveFile, saveFileStream, saveImages },
  71. AttachmentStore.prototype,
  72. ) as never)
  73. let uploadRoute: ((request: Request) => Promise<Response>) | undefined
  74. ctx.provide('connection', {
  75. fetch: {
  76. register: (route: { readonly fetch: (request: Request) => Promise<Response> }) => {
  77. uploadRoute = route.fetch
  78. return () => {}
  79. },
  80. },
  81. } as never)
  82. ctx.provide('llm', {
  83. listProviders: () => [{ id: 'fixture', name: 'Fixture' }],
  84. resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }),
  85. } as never)
  86. const selection: ModelSelectionRef = {
  87. current: { provider: 'fixture', model: 'fixture-model' },
  88. assembled: undefined,
  89. }
  90. const agents = {
  91. resolveAgent: () => Promise.resolve({ agent }),
  92. selectionFor: () => selection,
  93. serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
  94. } as unknown as ApiSessionAgentController
  95. const uploads = new FileUploads(ctx)
  96. if (uploadRoute === undefined) throw new Error('file upload route was not registered')
  97. return {
  98. ctx,
  99. controller: new SessionCommandController(ctx, agents, '/workspace'),
  100. uploads,
  101. agent,
  102. followup,
  103. saveFile,
  104. saveFileStream,
  105. saveImages,
  106. disposeAgent,
  107. uploadRoute,
  108. }
  109. }
  110. function promptRequest(content: Parameters<SessionCommandController['prompt']>[0]['content']) {
  111. return {
  112. requestId: 'req-1' as SessionRequestId,
  113. sessionId: SESSION,
  114. mode: 'queue' as const,
  115. content,
  116. }
  117. }
  118. describe('Session file uploads', () => {
  119. it('registers an HTTP route bound to the upload service', async () => {
  120. const { uploadRoute } = await uploadHarness()
  121. await expect(uploadRoute(new Request('http://host/upload')))
  122. .resolves.toMatchObject({ status: 405 })
  123. })
  124. it('stages one verbatim upload and preserves its order with an admitted image', async () => {
  125. const { ctx, controller, uploads, agent, followup, saveFile, saveImages } = await uploadHarness()
  126. const receipt = await uploads.upload(agent, { data: 'AAAA', name: 'notes.pdf' }, new AbortController().signal)
  127. expect(saveFile).toHaveBeenCalledTimes(1)
  128. expect(receipt.file.name).toBe('notes.pdf')
  129. expect(receipt.file.bytes).toBe(3)
  130. expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
  131. expect(uploads.resolve(agent, 'missing' as FileUploadReceiptId)).toBeUndefined()
  132. const commandHandler = vi.fn((_invocation: unknown) => ({ kind: 'success' as const }))
  133. ctx.commands.register({
  134. name: 'files', description: 'Use staged files', input: { hint: '<task>', attachments: true },
  135. handler: commandHandler,
  136. })
  137. await ctx.commands.execute(
  138. agent,
  139. '/files inspect',
  140. [{ type: 'file', receiptId: receipt.receiptId }],
  141. new AbortController().signal,
  142. )
  143. expect(commandHandler.mock.calls[0]?.[0]).toMatchObject({
  144. attachments: [{ type: 'file', attachment: receipt.file }],
  145. })
  146. const image: ImageAttachmentRef = {
  147. attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`),
  148. mediaType: 'image/png',
  149. bytes: 3,
  150. width: 1,
  151. height: 1,
  152. }
  153. saveImages.mockResolvedValueOnce([image])
  154. await controller.prompt(promptRequest([
  155. { type: 'file', receiptId: receipt.receiptId },
  156. { type: 'image', mediaType: 'image/png', data: 'AAAA' },
  157. { type: 'text', text: 'read it' },
  158. ]))
  159. expect(followup).toHaveBeenCalledTimes(1)
  160. const message = followup.mock.calls[0]?.[0] as UserMessage
  161. expect(message.content).toEqual([
  162. { type: 'file', attachment: receipt.file },
  163. { type: 'image', attachment: image },
  164. { type: 'text', text: 'read it' },
  165. ])
  166. })
  167. it('stages a bounded byte stream and forwards cancellation to storage', async () => {
  168. const { controller, uploads, followup, saveFileStream } = await uploadHarness()
  169. const abort = new AbortController()
  170. const receipt = await uploads.uploadStream({
  171. sessionId: SESSION,
  172. data: (async function* (): AsyncIterable<Uint8Array> {
  173. yield Uint8Array.of(1, 2)
  174. yield Uint8Array.of(3, 4, 5)
  175. })(),
  176. signal: abort.signal,
  177. name: 'huge.bin',
  178. })
  179. expect(saveFileStream).toHaveBeenCalledWith(expect.objectContaining({
  180. signal: abort.signal,
  181. name: 'huge.bin',
  182. }))
  183. expect(receipt.file).toMatchObject({ name: 'huge.bin', bytes: 5 })
  184. await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
  185. expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([
  186. { type: 'file', attachment: receipt.file },
  187. ])
  188. })
  189. it('keeps the stream name optional and maps storage failures through the same error vocabulary', async () => {
  190. const { uploads, saveFileStream } = await uploadHarness()
  191. const stream = (async function* (): AsyncIterable<Uint8Array> {})()
  192. await expect(uploads.uploadStream({ sessionId: SESSION, data: stream }))
  193. .resolves.toMatchObject({ file: { name: 'file', bytes: 0 } })
  194. expect(saveFileStream).toHaveBeenLastCalledWith({ data: stream })
  195. saveFileStream.mockRejectedValueOnce('disk offline')
  196. await expect(uploads.uploadStream({
  197. sessionId: SESSION,
  198. data: (async function* (): AsyncIterable<Uint8Array> { yield Uint8Array.of(1) })(),
  199. }))
  200. .rejects.toMatchObject({
  201. code: 'gateway/internal', message: 'failed to store file upload: disk offline',
  202. })
  203. })
  204. it('rejects a prompt citing a file that was never staged for the session', async () => {
  205. const { controller, followup, saveImages } = await uploadHarness()
  206. await expect(controller.prompt(promptRequest([
  207. { type: 'image', mediaType: 'image/png', data: 'AAAA' },
  208. { type: 'file', receiptId: 'missing-receipt' as FileUploadReceiptId },
  209. ]))).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } })
  210. expect(followup).not.toHaveBeenCalled()
  211. expect(saveImages).not.toHaveBeenCalled()
  212. })
  213. it('publishes no receipt when its exact Agent is disposed during storage', async () => {
  214. const { uploads, agent, saveFile, disposeAgent } = await uploadHarness()
  215. const saved = Promise.withResolvers<FileAttachmentRef>()
  216. saveFile.mockReturnValueOnce(saved.promise)
  217. const uploading = uploads.upload(agent, { data: 'AAAA', name: 'late.bin' }, new AbortController().signal)
  218. await vi.waitFor(() => { expect(saveFile).toHaveBeenCalledOnce() })
  219. disposeAgent()
  220. saved.resolve({
  221. attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), name: 'late.bin', bytes: 3,
  222. })
  223. await expect(uploading).rejects.toMatchObject({ code: 'session/not-found' })
  224. })
  225. it('resolves a cold ordinary Agent and releases the resolver registration', async () => {
  226. const { ctx, uploads, agent, disposeAgent } = await uploadHarness()
  227. disposeAgent()
  228. const resolveAgent = vi.fn(async () => {
  229. ctx.agents.register(agent)
  230. return agent
  231. })
  232. const disposeResolver = uploads.registerAgentResolver(resolveAgent)
  233. expect(() => { uploads.registerAgentResolver(resolveAgent) }).toThrow('already registered')
  234. await expect(uploads.uploadStream({
  235. sessionId: SESSION,
  236. data: (async function* (): AsyncIterable<Uint8Array> { yield Uint8Array.of(1) })(),
  237. })).resolves.toMatchObject({ file: { bytes: 1 } })
  238. expect(resolveAgent).toHaveBeenCalledWith(SESSION)
  239. disposeResolver()
  240. const replacement = vi.fn(async () => agent)
  241. const disposeReplacement = uploads.registerAgentResolver(replacement)
  242. disposeResolver()
  243. expect(() => { uploads.registerAgentResolver(replacement) }).toThrow('already registered')
  244. expect(disposeReplacement).toBeTypeOf('function')
  245. disposeReplacement()
  246. })
  247. it('rejects a cold upload when no Agent resolver is registered', async () => {
  248. const { uploads, disposeAgent } = await uploadHarness()
  249. disposeAgent()
  250. await expect(uploads.uploadStream({
  251. sessionId: SESSION,
  252. data: (async function* (): AsyncIterable<Uint8Array> {})(),
  253. })).rejects.toMatchObject({ code: 'session/not-found' })
  254. })
  255. it('rejects subagent uploads and access outside the receiving Agent scope', async () => {
  256. const child = await uploadHarness('subagent')
  257. await expect(child.uploads.upload(
  258. child.agent,
  259. { data: 'AAAA' },
  260. new AbortController().signal,
  261. )).rejects.toMatchObject({
  262. code: 'subagent/attachment-invalid',
  263. details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' },
  264. })
  265. expect(child.saveFile).not.toHaveBeenCalled()
  266. const ordinary = await uploadHarness()
  267. const receipt = await ordinary.uploads.upload(
  268. ordinary.agent,
  269. { data: 'AAAA' },
  270. new AbortController().signal,
  271. )
  272. const foreignScope = { ...ordinary.agent, ctx: ordinary.ctx } as Agent
  273. expect(() => ordinary.uploads.resolve(foreignScope, receipt.receiptId))
  274. .toThrow('operation requires the Agent\'s own scope')
  275. })
  276. it('retires accepted receipts after their rpcId becomes observable', async () => {
  277. const { controller, uploads, agent } = await uploadHarness()
  278. uploads.retirePrompt(agent, 'not-staged')
  279. const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
  280. await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
  281. expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
  282. uploads.retirePrompt(agent, 'other-request')
  283. expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
  284. uploads.retirePrompt(agent, 'req-1')
  285. expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
  286. })
  287. it('retires observed prompt receipts and drops staged state with the Session', async () => {
  288. const first = await uploadHarness()
  289. const observed = await first.uploads.upload(
  290. first.agent,
  291. { data: 'AAAA' },
  292. new AbortController().signal,
  293. )
  294. await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]))
  295. first.ctx.emit('session/event', first.agent.session, {
  296. type: 'user/message',
  297. data: createUserMessage({
  298. content: [{ type: 'text', text: 'extension event' }],
  299. source: { kind: 'user', rpcId: 1 } as never,
  300. }),
  301. } as never)
  302. expect(first.uploads.resolve(first.agent, observed.receiptId)).toEqual(observed.file)
  303. first.agent.session.append('user/message', createUserMessage({
  304. content: [{ type: 'text', text: 'observed' }],
  305. source: { kind: 'user', rpcId: 'req-1' as SessionRequestId },
  306. }), { surfaceOp: 'append' })
  307. expect(first.uploads.resolve(first.agent, observed.receiptId)).toBeUndefined()
  308. const second = await uploadHarness()
  309. const abandoned = await second.uploads.upload(
  310. second.agent,
  311. { data: 'AAAA' },
  312. new AbortController().signal,
  313. )
  314. second.ctx.emit('session/disposed', second.agent.session)
  315. expect(second.uploads.resolve(second.agent, abandoned.receiptId)).toBeUndefined()
  316. })
  317. it('deduplicates a retried rpcId already present in the Agent inbox', async () => {
  318. const { controller, agent, followup } = await uploadHarness()
  319. const request = promptRequest([{ type: 'text', text: 'once' }])
  320. await controller.prompt(request)
  321. agent.inbox.append('next-turn', followup.mock.calls[0]?.[0] as UserMessage)
  322. await expect(controller.prompt(request)).resolves.toEqual({ accepted: true })
  323. expect(followup).toHaveBeenCalledOnce()
  324. })
  325. it('deduplicates a retried rpcId already present in the durable log', async () => {
  326. const { controller, agent, followup } = await uploadHarness()
  327. const request = promptRequest([{ type: 'text', text: 'once' }])
  328. agent.session.append('turn/start', { turn: 1 })
  329. agent.session.append('user/message', createUserMessage({
  330. content: [{ type: 'text', text: 'unidentified' }],
  331. source: { kind: 'user' },
  332. }), { surfaceOp: 'append' })
  333. agent.session.append('user/message', createUserMessage({
  334. content: [{ type: 'text', text: 'accepted' }],
  335. source: { kind: 'user', rpcId: request.requestId },
  336. }), { surfaceOp: 'append' })
  337. await expect(controller.prompt(request)).resolves.toEqual({ accepted: true })
  338. expect(followup).not.toHaveBeenCalled()
  339. })
  340. it('rejects when the Agent disappears during prompt admission', async () => {
  341. const { controller, saveImages, disposeAgent } = await uploadHarness()
  342. const admitted = Promise.withResolvers<readonly ImageAttachmentRef[]>()
  343. saveImages.mockReturnValueOnce(admitted.promise)
  344. const prompting = controller.prompt(promptRequest([
  345. { type: 'image', mediaType: 'image/png', data: 'AAAA' },
  346. ]))
  347. await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
  348. disposeAgent()
  349. admitted.resolve([{
  350. attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1,
  351. }])
  352. await expect(prompting).rejects.toMatchObject({ code: 'session/not-found' })
  353. })
  354. it('rejects when a previously bound receipt retires during image admission', async () => {
  355. const { controller, uploads, agent, saveImages } = await uploadHarness()
  356. const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
  357. await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
  358. const admitted = Promise.withResolvers<readonly ImageAttachmentRef[]>()
  359. saveImages.mockReturnValueOnce(admitted.promise)
  360. const prompting = controller.prompt({
  361. ...promptRequest([
  362. { type: 'file', receiptId: receipt.receiptId },
  363. { type: 'image', mediaType: 'image/png', data: 'AAAA' },
  364. ]),
  365. requestId: 'req-2' as SessionRequestId,
  366. })
  367. await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
  368. uploads.retirePrompt(agent, 'req-1')
  369. admitted.resolve([{
  370. attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1,
  371. }])
  372. await expect(prompting).rejects.toMatchObject({
  373. code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' },
  374. })
  375. })
  376. it('keeps the prior receipt binding when a later prompt attempt fails', async () => {
  377. const { controller, uploads, agent, followup } = await uploadHarness()
  378. const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
  379. await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
  380. followup.mockImplementationOnce(() => { throw new Error('busy') })
  381. await expect(controller.prompt({
  382. ...promptRequest([{ type: 'file', receiptId: receipt.receiptId }]),
  383. requestId: 'req-2' as SessionRequestId,
  384. })).rejects.toMatchObject({ code: 'session/agent-busy' })
  385. uploads.retirePrompt(agent, 'req-1')
  386. expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
  387. })
  388. it('restores an unbound receipt after prompt delivery fails', async () => {
  389. const { controller, uploads, agent, followup } = await uploadHarness()
  390. const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
  391. followup.mockImplementationOnce(() => { throw new Error('busy') })
  392. await expect(controller.prompt(promptRequest([
  393. { type: 'file', receiptId: receipt.receiptId },
  394. ]))).rejects.toMatchObject({ code: 'session/agent-busy' })
  395. uploads.retirePrompt(agent, 'req-1')
  396. expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
  397. })
  398. it('retires a prompt-bound receipt when its queued occurrence is removed', async () => {
  399. const { controller, uploads, agent, followup } = await uploadHarness()
  400. const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
  401. await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
  402. const queued = followup.mock.calls[0]?.[0] as UserMessage
  403. agent.inbox.append('next-turn', queued)
  404. expect(controller.updateQueue({
  405. sessionId: SESSION,
  406. itemId: queued.id,
  407. action: { kind: 'remove' },
  408. })).toEqual({ accepted: true })
  409. expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
  410. })
  411. it('keeps separate names for identical bytes uploaded more than once', async () => {
  412. const { controller, uploads, agent, followup } = await uploadHarness()
  413. const first = await uploads.upload(agent, { data: 'AAAA', name: 'first.txt' }, new AbortController().signal)
  414. const second = await uploads.upload(agent, { data: 'AAAA', name: 'second.txt' }, new AbortController().signal)
  415. expect(first.file.attachmentId).toBe(second.file.attachmentId)
  416. expect(first.receiptId).not.toBe(second.receiptId)
  417. await controller.prompt(promptRequest([
  418. { type: 'file', receiptId: first.receiptId },
  419. { type: 'file', receiptId: second.receiptId },
  420. ]))
  421. const message = followup.mock.calls[0]?.[0] as UserMessage
  422. expect(message.content).toEqual([
  423. { type: 'file', attachment: first.file },
  424. { type: 'file', attachment: second.file },
  425. ])
  426. })
  427. it('maps a non-canonical payload to the wire attachment error', async () => {
  428. const { uploads, agent, saveFile } = await uploadHarness()
  429. await expect(uploads.upload(agent, { data: 'not base64!!' }, new AbortController().signal))
  430. .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'INVALID_FILE_BASE64' } })
  431. expect(saveFile).not.toHaveBeenCalled()
  432. })
  433. it('maps an unexpected storage failure to the internal wire error', async () => {
  434. const { uploads, agent, saveFile } = await uploadHarness()
  435. saveFile.mockRejectedValueOnce(new Error('disk unavailable'))
  436. await expect(uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal))
  437. .rejects.toMatchObject({
  438. code: 'gateway/internal',
  439. message: 'failed to store file upload: Error: disk unavailable',
  440. })
  441. })
  442. })