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

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