@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.
- package/dist/bootstrap.d.ts +12 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +3 -1
- package/dist/bootstrap.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +3 -0
- package/dist/config.js.map +1 -1
- package/dist/lib/utils/concurrency.d.ts +25 -0
- package/dist/lib/utils/concurrency.d.ts.map +1 -0
- package/dist/lib/utils/concurrency.js +69 -0
- package/dist/lib/utils/concurrency.js.map +1 -0
- package/dist/lib/utils/concurrency.test.d.ts +2 -0
- package/dist/lib/utils/concurrency.test.d.ts.map +1 -0
- package/dist/lib/utils/concurrency.test.js +135 -0
- package/dist/lib/utils/concurrency.test.js.map +1 -0
- package/dist/plugins/uploads/plugin.d.ts +12 -0
- package/dist/plugins/uploads/plugin.d.ts.map +1 -1
- package/dist/plugins/uploads/plugin.js +188 -44
- package/dist/plugins/uploads/plugin.js.map +1 -1
- package/dist/plugins/uploads/preprocessors/image-classifier.d.ts +9 -0
- package/dist/plugins/uploads/preprocessors/image-classifier.d.ts.map +1 -1
- package/dist/plugins/uploads/preprocessors/image-classifier.js +4 -1
- package/dist/plugins/uploads/preprocessors/image-classifier.js.map +1 -1
- package/dist/plugins/uploads/preprocessors/image-classifier.test.d.ts +2 -0
- package/dist/plugins/uploads/preprocessors/image-classifier.test.d.ts.map +1 -0
- package/dist/plugins/uploads/preprocessors/image-classifier.test.js +113 -0
- package/dist/plugins/uploads/preprocessors/image-classifier.test.js.map +1 -0
- package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.d.ts.map +1 -1
- package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.js +8 -7
- package/dist/plugins/uploads/preprocessors/markitdown-preprocessor.js.map +1 -1
- package/dist/plugins/uploads/preprocessors/zip-preprocessor.d.ts.map +1 -1
- package/dist/plugins/uploads/preprocessors/zip-preprocessor.js +35 -15
- package/dist/plugins/uploads/preprocessors/zip-preprocessor.js.map +1 -1
- package/dist/plugins/uploads/state.d.ts +1 -0
- package/dist/plugins/uploads/state.d.ts.map +1 -1
- package/dist/plugins/uploads/state.js +1 -1
- package/dist/plugins/uploads/state.js.map +1 -1
- package/dist/plugins/uploads/uploads.integration.test.js +97 -0
- package/dist/plugins/uploads/uploads.integration.test.js.map +1 -1
- package/dist/transport/http/routes/upload.d.ts.map +1 -1
- package/dist/transport/http/routes/upload.js +60 -0
- package/dist/transport/http/routes/upload.js.map +1 -1
- package/package.json +2 -2
- package/src/bootstrap.ts +3 -1
- package/src/config.ts +6 -0
- package/src/lib/utils/concurrency.test.ts +169 -0
- package/src/lib/utils/concurrency.ts +72 -0
- package/src/plugins/uploads/plugin.ts +212 -47
- package/src/plugins/uploads/preprocessors/image-classifier.test.ts +142 -0
- package/src/plugins/uploads/preprocessors/image-classifier.ts +13 -1
- package/src/plugins/uploads/preprocessors/markitdown-preprocessor.ts +8 -8
- package/src/plugins/uploads/preprocessors/zip-preprocessor.ts +37 -17
- package/src/plugins/uploads/state.ts +1 -1
- package/src/plugins/uploads/uploads.integration.test.ts +123 -0
- 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
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
462
|
+
const uploadId = generateUploadId()
|
|
463
|
+
const uploadIdStr = String(uploadId)
|
|
464
|
+
const uploadStore = dataFileStore.scoped(`sessions/${input.sessionId}/uploads/${uploadId}`)
|
|
358
465
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
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:
|
|
379
|
-
|
|
380
|
-
|
|
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:
|
|
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:
|
|
401
|
-
status:
|
|
402
|
-
extractedContent,
|
|
566
|
+
uploadId: uploadIdStr,
|
|
567
|
+
status: 'processing' as const,
|
|
403
568
|
})
|
|
404
569
|
},
|
|
405
570
|
})
|