@roj-ai/sdk 0.1.13 → 0.1.14

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 (56) hide show
  1. package/dist/bootstrap.d.ts +12 -0
  2. package/dist/bootstrap.d.ts.map +1 -1
  3. package/dist/bootstrap.js +3 -1
  4. package/dist/bootstrap.js.map +1 -1
  5. package/dist/config.d.ts +2 -0
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js +3 -0
  8. package/dist/config.js.map +1 -1
  9. package/dist/lib/utils/concurrency.d.ts +25 -0
  10. package/dist/lib/utils/concurrency.d.ts.map +1 -0
  11. package/dist/lib/utils/concurrency.js +69 -0
  12. package/dist/lib/utils/concurrency.js.map +1 -0
  13. package/dist/lib/utils/concurrency.test.d.ts +2 -0
  14. package/dist/lib/utils/concurrency.test.d.ts.map +1 -0
  15. package/dist/lib/utils/concurrency.test.js +135 -0
  16. package/dist/lib/utils/concurrency.test.js.map +1 -0
  17. package/dist/plugins/uploads/plugin.d.ts +12 -0
  18. package/dist/plugins/uploads/plugin.d.ts.map +1 -1
  19. package/dist/plugins/uploads/plugin.js +188 -44
  20. package/dist/plugins/uploads/plugin.js.map +1 -1
  21. package/dist/plugins/uploads/preprocessors/image-classifier.d.ts +9 -0
  22. package/dist/plugins/uploads/preprocessors/image-classifier.d.ts.map +1 -1
  23. package/dist/plugins/uploads/preprocessors/image-classifier.js +4 -1
  24. package/dist/plugins/uploads/preprocessors/image-classifier.js.map +1 -1
  25. package/dist/plugins/uploads/preprocessors/image-classifier.test.d.ts +2 -0
  26. package/dist/plugins/uploads/preprocessors/image-classifier.test.d.ts.map +1 -0
  27. package/dist/plugins/uploads/preprocessors/image-classifier.test.js +113 -0
  28. package/dist/plugins/uploads/preprocessors/image-classifier.test.js.map +1 -0
  29. package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.d.ts.map +1 -1
  30. package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.js +8 -7
  31. package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.js.map +1 -1
  32. package/dist/plugins/uploads/preprocessors/zip-preprocessor.d.ts.map +1 -1
  33. package/dist/plugins/uploads/preprocessors/zip-preprocessor.js +35 -15
  34. package/dist/plugins/uploads/preprocessors/zip-preprocessor.js.map +1 -1
  35. package/dist/plugins/uploads/state.d.ts +1 -0
  36. package/dist/plugins/uploads/state.d.ts.map +1 -1
  37. package/dist/plugins/uploads/state.js +1 -1
  38. package/dist/plugins/uploads/state.js.map +1 -1
  39. package/dist/plugins/uploads/uploads.integration.test.js +97 -0
  40. package/dist/plugins/uploads/uploads.integration.test.js.map +1 -1
  41. package/dist/transport/http/routes/upload.d.ts.map +1 -1
  42. package/dist/transport/http/routes/upload.js +60 -0
  43. package/dist/transport/http/routes/upload.js.map +1 -1
  44. package/package.json +2 -2
  45. package/src/bootstrap.ts +3 -1
  46. package/src/config.ts +6 -0
  47. package/src/lib/utils/concurrency.test.ts +169 -0
  48. package/src/lib/utils/concurrency.ts +72 -0
  49. package/src/plugins/uploads/plugin.ts +212 -47
  50. package/src/plugins/uploads/preprocessors/image-classifier.test.ts +142 -0
  51. package/src/plugins/uploads/preprocessors/image-classifier.ts +13 -1
  52. package/src/plugins/uploads/preprocessors/markitdown-preprocessor.ts +8 -8
  53. package/src/plugins/uploads/preprocessors/zip-preprocessor.ts +37 -17
  54. package/src/plugins/uploads/state.ts +1 -1
  55. package/src/plugins/uploads/uploads.integration.test.ts +123 -0
  56. package/src/transport/http/routes/upload.ts +87 -0
@@ -0,0 +1,169 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { mapWithConcurrency, Semaphore } from './concurrency.js'
3
+
4
+ function defer<T = void>(): { promise: Promise<T>; resolve: (v: T) => void; reject: (e: unknown) => void } {
5
+ let resolve!: (v: T) => void
6
+ let reject!: (e: unknown) => void
7
+ const promise = new Promise<T>((res, rej) => {
8
+ resolve = res
9
+ reject = rej
10
+ })
11
+ return { promise, resolve, reject }
12
+ }
13
+
14
+ describe('mapWithConcurrency', () => {
15
+ it('preserves input order regardless of completion order', async () => {
16
+ const delays = [50, 10, 30, 5, 20]
17
+ const results = await mapWithConcurrency(delays, 3, async (ms, i) => {
18
+ await new Promise(r => setTimeout(r, ms))
19
+ return `${i}:${ms}`
20
+ })
21
+ expect(results).toEqual(['0:50', '1:10', '2:30', '3:5', '4:20'])
22
+ })
23
+
24
+ it('does not exceed the concurrency limit', async () => {
25
+ let active = 0
26
+ let peak = 0
27
+ const items = Array.from({ length: 20 }, (_, i) => i)
28
+
29
+ await mapWithConcurrency(items, 4, async () => {
30
+ active++
31
+ peak = Math.max(peak, active)
32
+ await new Promise(r => setTimeout(r, 5))
33
+ active--
34
+ })
35
+
36
+ expect(peak).toBeLessThanOrEqual(4)
37
+ expect(peak).toBe(4)
38
+ })
39
+
40
+ it('handles empty input without spawning workers', async () => {
41
+ let calls = 0
42
+ const results = await mapWithConcurrency([], 5, async () => {
43
+ calls++
44
+ return 1
45
+ })
46
+ expect(results).toEqual([])
47
+ expect(calls).toBe(0)
48
+ })
49
+
50
+ it('caps worker count at item count when concurrency > items', async () => {
51
+ let active = 0
52
+ let peak = 0
53
+ const items = [1, 2]
54
+
55
+ await mapWithConcurrency(items, 10, async () => {
56
+ active++
57
+ peak = Math.max(peak, active)
58
+ await new Promise(r => setTimeout(r, 5))
59
+ active--
60
+ })
61
+
62
+ expect(peak).toBe(2)
63
+ })
64
+
65
+ it('propagates errors thrown by the worker fn', async () => {
66
+ await expect(
67
+ mapWithConcurrency([1, 2, 3], 2, async (n) => {
68
+ if (n === 2) throw new Error('boom')
69
+ return n
70
+ }),
71
+ ).rejects.toThrow('boom')
72
+ })
73
+ })
74
+
75
+ describe('Semaphore', () => {
76
+ it('rejects invalid limits', () => {
77
+ expect(() => new Semaphore(0)).toThrow()
78
+ expect(() => new Semaphore(-1)).toThrow()
79
+ expect(() => new Semaphore(1.5)).toThrow()
80
+ })
81
+
82
+ it('caps concurrent executions at limit', async () => {
83
+ const gate = new Semaphore(3)
84
+ let active = 0
85
+ let peak = 0
86
+
87
+ const tasks = Array.from({ length: 12 }, () =>
88
+ gate.run(async () => {
89
+ active++
90
+ peak = Math.max(peak, active)
91
+ await new Promise(r => setTimeout(r, 10))
92
+ active--
93
+ }),
94
+ )
95
+
96
+ await Promise.all(tasks)
97
+ expect(peak).toBe(3)
98
+ expect(active).toBe(0)
99
+ })
100
+
101
+ it('admits waiters in FIFO order', async () => {
102
+ const gate = new Semaphore(1)
103
+ const order: number[] = []
104
+ const blocker = defer()
105
+
106
+ // Hold the only slot
107
+ const held = gate.run(async () => {
108
+ await blocker.promise
109
+ })
110
+
111
+ // Queue three waiters in known order, with their own blockers so the test
112
+ // can observe entry order without relying on real timers.
113
+ const gates = [defer(), defer(), defer()]
114
+ const queued = gates.map((g, i) =>
115
+ gate.run(async () => {
116
+ order.push(i)
117
+ await g.promise
118
+ }),
119
+ )
120
+
121
+ // Yield so all three are queued.
122
+ await new Promise(r => setTimeout(r, 0))
123
+ expect(order).toEqual([])
124
+
125
+ // Release the holder; waiter 0 should run first.
126
+ blocker.resolve()
127
+ await new Promise(r => setTimeout(r, 0))
128
+ expect(order).toEqual([0])
129
+
130
+ gates[0]!.resolve()
131
+ await new Promise(r => setTimeout(r, 0))
132
+ expect(order).toEqual([0, 1])
133
+
134
+ gates[1]!.resolve()
135
+ await new Promise(r => setTimeout(r, 0))
136
+ expect(order).toEqual([0, 1, 2])
137
+
138
+ gates[2]!.resolve()
139
+ await Promise.all([held, ...queued])
140
+ })
141
+
142
+ it('releases the slot when the body throws', async () => {
143
+ const gate = new Semaphore(1)
144
+
145
+ await expect(gate.run(async () => {
146
+ throw new Error('boom')
147
+ })).rejects.toThrow('boom')
148
+
149
+ // Slot must be free again — this would deadlock otherwise.
150
+ const result = await gate.run(async () => 42)
151
+ expect(result).toBe(42)
152
+ })
153
+
154
+ it('serializes work under limit=1', async () => {
155
+ const gate = new Semaphore(1)
156
+ const events: string[] = []
157
+
158
+ const tasks = [0, 1, 2].map(i =>
159
+ gate.run(async () => {
160
+ events.push(`start:${i}`)
161
+ await new Promise(r => setTimeout(r, 5))
162
+ events.push(`end:${i}`)
163
+ }),
164
+ )
165
+
166
+ await Promise.all(tasks)
167
+ expect(events).toEqual(['start:0', 'end:0', 'start:1', 'end:1', 'start:2', 'end:2'])
168
+ })
169
+ })
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Run an async mapping with bounded concurrency.
3
+ *
4
+ * Spawns up to `concurrency` workers that pull from a shared cursor.
5
+ * Results preserve input order.
6
+ */
7
+ export async function mapWithConcurrency<T, R>(
8
+ items: readonly T[],
9
+ concurrency: number,
10
+ fn: (item: T, index: number) => Promise<R>,
11
+ ): Promise<R[]> {
12
+ const results: R[] = new Array(items.length)
13
+ let next = 0
14
+ const workerCount = Math.max(1, Math.min(concurrency, items.length))
15
+ const workers = Array.from({ length: workerCount }, async () => {
16
+ while (true) {
17
+ const i = next++
18
+ if (i >= items.length) return
19
+ results[i] = await fn(items[i] as T, i)
20
+ }
21
+ })
22
+ await Promise.all(workers)
23
+ return results
24
+ }
25
+
26
+ /**
27
+ * Counting semaphore with FIFO waiter queue.
28
+ *
29
+ * Use `run(fn)` to execute work under the gate — acquires before invoking,
30
+ * releases on resolve/reject. Suitable for bounding contention on a shared
31
+ * resource (e.g. concurrent LLM calls) regardless of how many call sites
32
+ * compete for it.
33
+ */
34
+ export class Semaphore {
35
+ private active = 0
36
+ private readonly waiters: Array<() => void> = []
37
+
38
+ constructor(private readonly limit: number) {
39
+ if (!Number.isInteger(limit) || limit < 1) {
40
+ throw new Error(`Semaphore limit must be a positive integer, got ${limit}`)
41
+ }
42
+ }
43
+
44
+ async run<T>(fn: () => Promise<T>): Promise<T> {
45
+ await this.acquire()
46
+ try {
47
+ return await fn()
48
+ } finally {
49
+ this.release()
50
+ }
51
+ }
52
+
53
+ private acquire(): Promise<void> {
54
+ if (this.active < this.limit) {
55
+ this.active++
56
+ return Promise.resolve()
57
+ }
58
+ return new Promise<void>(resolve => {
59
+ this.waiters.push(resolve)
60
+ })
61
+ }
62
+
63
+ private release(): void {
64
+ const next = this.waiters.shift()
65
+ if (next) {
66
+ // Slot transfers directly to the next waiter; active stays unchanged.
67
+ next()
68
+ } else {
69
+ this.active--
70
+ }
71
+ }
72
+ }
@@ -2,11 +2,24 @@ import z from 'zod/v4'
2
2
  import { ValidationErrors } from '~/core/errors.js'
3
3
  import type { FileStore } from '~/core/file-store/types.js'
4
4
  import { definePlugin } from '~/core/plugins/plugin-builder.js'
5
+ import { SessionId } from '~/core/sessions/schema.js'
5
6
  import { Err, Ok } from '~/lib/utils/result.js'
6
7
  import type { PreprocessorRegistry } from './preprocessor.js'
7
8
  import { generateUploadId, type MessageAttachment, UploadId, type UploadMetadata } from './schema.js'
8
9
  import { type PendingUpload, uploadEvents, type UploadsState } from './state.js'
9
10
 
11
+ // ============================================================================
12
+ // Notification schemas
13
+ // ============================================================================
14
+
15
+ const statusChangedSchema = z.object({
16
+ sessionId: z.string(),
17
+ uploadId: z.string(),
18
+ status: z.enum(['processing', 'ready', 'failed']),
19
+ extractedContent: z.string().optional(),
20
+ error: z.string().optional(),
21
+ })
22
+
10
23
  // ============================================================================
11
24
  // Constants
12
25
  // ============================================================================
@@ -66,6 +79,69 @@ function formatUploadsForLLM(uploads: PendingUpload[], sessionRoot: string): str
66
79
  return blocks.join('\n')
67
80
  }
68
81
 
82
+ /**
83
+ * Run preprocessor (with timeout) and persist final upload metadata to disk.
84
+ * Returns the resolved status + extracted/derived data for the caller to emit.
85
+ */
86
+ async function runPreprocessAndPersist(args: {
87
+ uploadId: string
88
+ sessionId: SessionId
89
+ uploadStore: FileStore
90
+ filePath: string
91
+ filename: string
92
+ mimeType: string
93
+ size: number
94
+ createdAt: number
95
+ preprocessorRegistry?: PreprocessorRegistry
96
+ }): Promise<{
97
+ status: 'ready' | 'failed'
98
+ extractedContent?: string
99
+ derivedPaths?: string[]
100
+ error?: string
101
+ }> {
102
+ const preprocessor = args.preprocessorRegistry?.getForMimeType(args.mimeType)
103
+
104
+ let status: 'ready' | 'failed' = 'ready'
105
+ let extractedContent: string | undefined
106
+ let derivedPaths: string[] | undefined
107
+ let errorMessage: string | undefined
108
+
109
+ if (preprocessor) {
110
+ const processPromise = preprocessor.process(args.filePath, args.mimeType, {
111
+ files: args.uploadStore,
112
+ })
113
+ const timeoutPromise = sleep(PROCESSING_TIMEOUT_MS).then(() => ({
114
+ ok: false as const,
115
+ error: new Error('Processing timeout'),
116
+ }))
117
+ const result = await Promise.race([processPromise, timeoutPromise])
118
+ if (result.ok) {
119
+ extractedContent = result.value.extractedContent
120
+ derivedPaths = result.value.derivedPaths
121
+ } else {
122
+ status = 'failed'
123
+ errorMessage = result.error.message
124
+ }
125
+ }
126
+
127
+ const metadata: UploadMetadata = {
128
+ uploadId: UploadId(args.uploadId),
129
+ sessionId: args.sessionId,
130
+ filename: args.filename,
131
+ mimeType: args.mimeType,
132
+ size: args.size,
133
+ path: args.filePath,
134
+ status,
135
+ extractedContent,
136
+ derivedPaths,
137
+ createdAt: args.createdAt,
138
+ completedAt: Date.now(),
139
+ }
140
+ await args.uploadStore.write('meta.json', JSON.stringify(metadata, null, 2))
141
+
142
+ return { status, extractedContent, derivedPaths, error: errorMessage }
143
+ }
144
+
69
145
  // ============================================================================
70
146
  // Plugin
71
147
  // ============================================================================
@@ -73,6 +149,7 @@ function formatUploadsForLLM(uploads: PendingUpload[], sessionRoot: string): str
73
149
  export const uploadsPlugin = definePlugin('uploads')
74
150
  .pluginConfig<UploadsPluginConfig>()
75
151
  .events([uploadEvents])
152
+ .notification('uploadStatusChanged', { schema: statusChangedSchema })
76
153
  .state<UploadsState>({
77
154
  key: 'uploads',
78
155
  initial: (): UploadsState => ({ pending: [] }),
@@ -315,91 +392,179 @@ export const uploadsPlugin = definePlugin('uploads')
315
392
  handler: async (ctx, input) => {
316
393
  const { dataFileStore, preprocessorRegistry } = ctx.pluginConfig
317
394
 
318
- // Validate
319
395
  if (input.size > MAX_FILE_SIZE) {
320
396
  return Err(ValidationErrors.invalid(`File too large: max ${MAX_FILE_SIZE / (1024 * 1024)}MB`))
321
397
  }
322
-
323
398
  if (!isAllowedMimeType(input.mimeType)) {
324
399
  return Err(ValidationErrors.invalid(`Unsupported file type: ${input.mimeType}`))
325
400
  }
326
401
 
327
- // Generate upload ID and scoped store
328
402
  const uploadId = generateUploadId()
329
403
  const uploadStore = dataFileStore.scoped(`sessions/${input.sessionId}/uploads/${uploadId}`)
330
404
 
331
- // Write file to disk
332
405
  const writeResult = await uploadStore.write(input.filename, input.fileBuffer)
333
-
334
406
  if (!writeResult.ok) {
335
407
  return Err(ValidationErrors.invalid('Failed to write file'))
336
408
  }
337
409
 
338
- const filePath = writeResult.value.path
339
-
340
- // Run preprocessor (with timeout)
341
- let processingResult: 'success' | 'failed' | 'skipped' = 'skipped'
342
- let extractedContent: string | undefined
343
- let derivedPaths: string[] | undefined
410
+ const result = await runPreprocessAndPersist({
411
+ uploadId: String(uploadId),
412
+ sessionId: ctx.sessionId,
413
+ uploadStore,
414
+ filePath: writeResult.value.path,
415
+ filename: input.filename,
416
+ mimeType: input.mimeType,
417
+ size: input.size,
418
+ createdAt: Date.now(),
419
+ preprocessorRegistry,
420
+ })
344
421
 
345
- const preprocessor = preprocessorRegistry?.getForMimeType(input.mimeType)
422
+ await ctx.emitEvent(uploadEvents.create('attachment_uploaded', {
423
+ uploadId,
424
+ filename: input.filename,
425
+ mimeType: input.mimeType,
426
+ size: input.size,
427
+ status: result.status,
428
+ extractedContent: result.extractedContent,
429
+ derivedPaths: result.derivedPaths,
430
+ error: result.error,
431
+ }))
346
432
 
347
- if (preprocessor) {
348
- const processPromise = preprocessor.process(filePath, input.mimeType, {
349
- files: uploadStore,
350
- })
433
+ return Ok({
434
+ uploadId: String(uploadId),
435
+ status: result.status,
436
+ extractedContent: result.extractedContent,
437
+ })
438
+ },
439
+ })
440
+ .method('uploadAsync', {
441
+ input: z.object({
442
+ sessionId: z.string(),
443
+ filename: z.string(),
444
+ mimeType: z.string(),
445
+ size: z.number(),
446
+ fileBuffer: z.custom<Buffer>(),
447
+ }),
448
+ output: z.object({
449
+ uploadId: z.string(),
450
+ status: z.enum(['processing']),
451
+ }),
452
+ handler: async (ctx, input) => {
453
+ const { dataFileStore, preprocessorRegistry } = ctx.pluginConfig
351
454
 
352
- const timeoutPromise = sleep(PROCESSING_TIMEOUT_MS).then(() => ({
353
- ok: false as const,
354
- error: new Error('Processing timeout'),
355
- }))
455
+ if (input.size > MAX_FILE_SIZE) {
456
+ return Err(ValidationErrors.invalid(`File too large: max ${MAX_FILE_SIZE / (1024 * 1024)}MB`))
457
+ }
458
+ if (!isAllowedMimeType(input.mimeType)) {
459
+ return Err(ValidationErrors.invalid(`Unsupported file type: ${input.mimeType}`))
460
+ }
356
461
 
357
- const result = await Promise.race([processPromise, timeoutPromise])
462
+ const uploadId = generateUploadId()
463
+ const uploadIdStr = String(uploadId)
464
+ const uploadStore = dataFileStore.scoped(`sessions/${input.sessionId}/uploads/${uploadId}`)
358
465
 
359
- if (result.ok) {
360
- processingResult = 'success'
361
- extractedContent = result.value.extractedContent
362
- derivedPaths = result.value.derivedPaths
363
- } else {
364
- processingResult = 'failed'
365
- }
466
+ const writeResult = await uploadStore.write(input.filename, input.fileBuffer)
467
+ if (!writeResult.ok) {
468
+ return Err(ValidationErrors.invalid('Failed to write file'))
366
469
  }
367
470
 
368
- // Create upload metadata
369
- const now = Date.now()
370
- const uploadStatus = processingResult === 'failed' ? 'failed' as const : 'ready' as const
371
- const metadata: UploadMetadata = {
471
+ const filePath = writeResult.value.path
472
+ const createdAt = Date.now()
473
+
474
+ // Persist initial 'processing' metadata so listPending sees it before preprocessor finishes.
475
+ const processingMeta: UploadMetadata = {
372
476
  uploadId,
373
477
  sessionId: ctx.sessionId,
374
478
  filename: input.filename,
375
479
  mimeType: input.mimeType,
376
480
  size: input.size,
377
481
  path: filePath,
378
- status: uploadStatus,
379
- extractedContent,
380
- derivedPaths,
381
- createdAt: now,
382
- completedAt: now,
482
+ status: 'processing',
483
+ createdAt,
484
+ completedAt: createdAt,
383
485
  }
486
+ await uploadStore.write('meta.json', JSON.stringify(processingMeta, null, 2))
384
487
 
385
- // Save metadata
386
- await uploadStore.write('meta.json', JSON.stringify(metadata, null, 2))
387
-
388
- // Emit event
389
488
  await ctx.emitEvent(uploadEvents.create('attachment_uploaded', {
390
489
  uploadId,
391
490
  filename: input.filename,
392
491
  mimeType: input.mimeType,
393
492
  size: input.size,
394
- status: uploadStatus,
395
- extractedContent,
396
- derivedPaths,
493
+ status: 'processing',
397
494
  }))
495
+ ctx.notify('uploadStatusChanged', {
496
+ sessionId: input.sessionId,
497
+ uploadId: uploadIdStr,
498
+ status: 'processing',
499
+ })
500
+
501
+ // Capture refs from ctx before the handler returns — `notify`/`emitEvent`
502
+ // closures stay valid for the lifetime of the session, which in roj
503
+ // outlives any single handler call.
504
+ const { emitEvent, notify, logger } = ctx
505
+ const sessionId = ctx.sessionId
506
+
507
+ void (async () => {
508
+ try {
509
+ const result = await runPreprocessAndPersist({
510
+ uploadId: uploadIdStr,
511
+ sessionId,
512
+ uploadStore,
513
+ filePath,
514
+ filename: input.filename,
515
+ mimeType: input.mimeType,
516
+ size: input.size,
517
+ createdAt,
518
+ preprocessorRegistry,
519
+ })
520
+
521
+ await emitEvent(uploadEvents.create('attachment_uploaded', {
522
+ uploadId,
523
+ filename: input.filename,
524
+ mimeType: input.mimeType,
525
+ size: input.size,
526
+ status: result.status,
527
+ extractedContent: result.extractedContent,
528
+ derivedPaths: result.derivedPaths,
529
+ error: result.error,
530
+ }))
531
+ notify('uploadStatusChanged', {
532
+ sessionId: input.sessionId,
533
+ uploadId: uploadIdStr,
534
+ status: result.status,
535
+ extractedContent: result.extractedContent,
536
+ error: result.error,
537
+ })
538
+ } catch (err) {
539
+ const message = err instanceof Error ? err.message : String(err)
540
+ logger.error('Async upload processing crashed', err instanceof Error ? err : undefined, {
541
+ uploadId: uploadIdStr,
542
+ filename: input.filename,
543
+ })
544
+ try {
545
+ await emitEvent(uploadEvents.create('attachment_uploaded', {
546
+ uploadId,
547
+ filename: input.filename,
548
+ mimeType: input.mimeType,
549
+ size: input.size,
550
+ status: 'failed',
551
+ error: message,
552
+ }))
553
+ } catch {
554
+ // Even event emission failed — best-effort; nothing useful left to do.
555
+ }
556
+ notify('uploadStatusChanged', {
557
+ sessionId: input.sessionId,
558
+ uploadId: uploadIdStr,
559
+ status: 'failed',
560
+ error: message,
561
+ })
562
+ }
563
+ })()
398
564
 
399
565
  return Ok({
400
- uploadId: String(uploadId),
401
- status: uploadStatus,
402
- extractedContent,
566
+ uploadId: uploadIdStr,
567
+ status: 'processing' as const,
403
568
  })
404
569
  },
405
570
  })