@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,142 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { SessionFileStore } from '~/core/file-store/file-store.js'
|
|
6
|
+
import { MockLLMProvider } from '~/core/llm/mock.js'
|
|
7
|
+
import { Semaphore } from '~/lib/utils/concurrency.js'
|
|
8
|
+
import { silentLogger } from '../../../lib/logger/logger.js'
|
|
9
|
+
import { createNodePlatform } from '../../../testing/node-platform.js'
|
|
10
|
+
import { ImageClassifierPreprocessor } from './image-classifier.js'
|
|
11
|
+
|
|
12
|
+
function defer<T = void>(): { promise: Promise<T>; resolve: (v: T) => void } {
|
|
13
|
+
let resolve!: (v: T) => void
|
|
14
|
+
const promise = new Promise<T>((res) => {
|
|
15
|
+
resolve = res
|
|
16
|
+
})
|
|
17
|
+
return { promise, resolve }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('ImageClassifierPreprocessor.gate', () => {
|
|
21
|
+
const platform = createNodePlatform()
|
|
22
|
+
let workDir: string
|
|
23
|
+
|
|
24
|
+
beforeAll(async () => {
|
|
25
|
+
workDir = await mkdtemp(join(tmpdir(), 'roj-classifier-gate-'))
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
afterAll(async () => {
|
|
29
|
+
await rm(workDir, { recursive: true, force: true }).catch(() => {})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('caps concurrent vision LLM calls when a gate is provided', async () => {
|
|
33
|
+
const N = 8
|
|
34
|
+
const LIMIT = 3
|
|
35
|
+
|
|
36
|
+
// Track concurrency inside the mock LLM handler — each call increments
|
|
37
|
+
// `active` on entry, awaits a release barrier, then decrements on exit.
|
|
38
|
+
let active = 0
|
|
39
|
+
let peak = 0
|
|
40
|
+
const release = defer()
|
|
41
|
+
|
|
42
|
+
const llmProvider = new MockLLMProvider(async () => {
|
|
43
|
+
active++
|
|
44
|
+
peak = Math.max(peak, active)
|
|
45
|
+
await release.promise
|
|
46
|
+
active--
|
|
47
|
+
return {
|
|
48
|
+
content: 'desc',
|
|
49
|
+
toolCalls: [],
|
|
50
|
+
finishReason: 'stop',
|
|
51
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const gate = new Semaphore(LIMIT)
|
|
56
|
+
const classifier = new ImageClassifierPreprocessor({
|
|
57
|
+
llmProvider,
|
|
58
|
+
logger: silentLogger,
|
|
59
|
+
fs: platform.fs,
|
|
60
|
+
gate,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// Create N tiny dummy image files — content doesn't matter, classifier
|
|
64
|
+
// only stats them and hands the path to the (mocked) LLM.
|
|
65
|
+
const imagePaths = await Promise.all(
|
|
66
|
+
Array.from({ length: N }, async (_, i) => {
|
|
67
|
+
const p = join(workDir, `img-${i}.png`)
|
|
68
|
+
await writeFile(p, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
|
|
69
|
+
return p
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const fileStore = new SessionFileStore(workDir, undefined, false, platform.fs, 'session')
|
|
74
|
+
|
|
75
|
+
const tasks = imagePaths.map((p, i) =>
|
|
76
|
+
classifier.process(p, 'image/png', { files: fileStore.scoped(`img-${i}-meta`) }),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
// Let the workers queue up; the first LIMIT should be in-flight.
|
|
80
|
+
await new Promise(r => setTimeout(r, 20))
|
|
81
|
+
expect(active).toBeLessThanOrEqual(LIMIT)
|
|
82
|
+
expect(active).toBe(LIMIT)
|
|
83
|
+
|
|
84
|
+
// Release everyone; wait for completion and check peak.
|
|
85
|
+
release.resolve()
|
|
86
|
+
const results = await Promise.all(tasks)
|
|
87
|
+
|
|
88
|
+
expect(peak).toBe(LIMIT)
|
|
89
|
+
expect(active).toBe(0)
|
|
90
|
+
expect(llmProvider.getCallCount()).toBe(N)
|
|
91
|
+
for (const r of results) {
|
|
92
|
+
expect(r.ok).toBe(true)
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('does not gate when no semaphore is provided (all run concurrently)', async () => {
|
|
97
|
+
const N = 6
|
|
98
|
+
let active = 0
|
|
99
|
+
let peak = 0
|
|
100
|
+
const release = defer()
|
|
101
|
+
|
|
102
|
+
const llmProvider = new MockLLMProvider(async () => {
|
|
103
|
+
active++
|
|
104
|
+
peak = Math.max(peak, active)
|
|
105
|
+
await release.promise
|
|
106
|
+
active--
|
|
107
|
+
return {
|
|
108
|
+
content: 'desc',
|
|
109
|
+
toolCalls: [],
|
|
110
|
+
finishReason: 'stop',
|
|
111
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const classifier = new ImageClassifierPreprocessor({
|
|
116
|
+
llmProvider,
|
|
117
|
+
logger: silentLogger,
|
|
118
|
+
fs: platform.fs,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const imagePaths = await Promise.all(
|
|
122
|
+
Array.from({ length: N }, async (_, i) => {
|
|
123
|
+
const p = join(workDir, `nogate-${i}.png`)
|
|
124
|
+
await writeFile(p, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
|
|
125
|
+
return p
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const fileStore = new SessionFileStore(workDir, undefined, false, platform.fs, 'session')
|
|
130
|
+
|
|
131
|
+
const tasks = imagePaths.map((p, i) =>
|
|
132
|
+
classifier.process(p, 'image/png', { files: fileStore.scoped(`nogate-${i}-meta`) }),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
await new Promise(r => setTimeout(r, 20))
|
|
136
|
+
expect(active).toBe(N)
|
|
137
|
+
|
|
138
|
+
release.resolve()
|
|
139
|
+
await Promise.all(tasks)
|
|
140
|
+
expect(peak).toBe(N)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { LLMProvider } from '~/core/llm/provider.js'
|
|
9
9
|
import { ModelId } from '~/core/llm/schema.js'
|
|
10
|
+
import type { Semaphore } from '~/lib/utils/concurrency.js'
|
|
10
11
|
import type { Result } from '~/lib/utils/result.js'
|
|
11
12
|
import { Err, Ok } from '~/lib/utils/result.js'
|
|
12
13
|
import type { FileSystem } from '~/platform/fs.js'
|
|
@@ -28,6 +29,13 @@ export interface ImageClassifierConfig {
|
|
|
28
29
|
fs: FileSystem
|
|
29
30
|
/** Whether to skip vision and just return metadata */
|
|
30
31
|
skipVision?: boolean
|
|
32
|
+
/**
|
|
33
|
+
* Optional semaphore to bound concurrent vision LLM calls. All callers
|
|
34
|
+
* sharing one instance compete for the same set of permits — useful when
|
|
35
|
+
* recursive preprocessors (ZIP → docs → images) would otherwise fan out
|
|
36
|
+
* into many simultaneous inferences.
|
|
37
|
+
*/
|
|
38
|
+
gate?: Semaphore
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
// ============================================================================
|
|
@@ -47,6 +55,7 @@ export class ImageClassifierPreprocessor implements Preprocessor {
|
|
|
47
55
|
private readonly logger: Logger
|
|
48
56
|
private readonly fs: FileSystem
|
|
49
57
|
private readonly skipVision: boolean
|
|
58
|
+
private readonly gate: Semaphore | undefined
|
|
50
59
|
|
|
51
60
|
constructor(config: ImageClassifierConfig) {
|
|
52
61
|
this.llmProvider = config.llmProvider
|
|
@@ -54,6 +63,7 @@ export class ImageClassifierPreprocessor implements Preprocessor {
|
|
|
54
63
|
this.logger = config.logger
|
|
55
64
|
this.fs = config.fs
|
|
56
65
|
this.skipVision = config.skipVision ?? false
|
|
66
|
+
this.gate = config.gate
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
async process(
|
|
@@ -124,7 +134,7 @@ export class ImageClassifierPreprocessor implements Preprocessor {
|
|
|
124
134
|
): Promise<string | null> {
|
|
125
135
|
try {
|
|
126
136
|
// Use file:// URL - resolved to base64 lazily in LLM provider
|
|
127
|
-
const
|
|
137
|
+
const inferenceCall = () => this.llmProvider.inference({
|
|
128
138
|
model: this.visionModel,
|
|
129
139
|
systemPrompt: 'You are an image description assistant. Describe images concisely in 1-2 sentences.',
|
|
130
140
|
messages: [
|
|
@@ -146,6 +156,8 @@ export class ImageClassifierPreprocessor implements Preprocessor {
|
|
|
146
156
|
temperature: 0.3,
|
|
147
157
|
})
|
|
148
158
|
|
|
159
|
+
const result = await (this.gate ? this.gate.run(inferenceCall) : inferenceCall())
|
|
160
|
+
|
|
149
161
|
if (result.ok && result.value.content) {
|
|
150
162
|
return result.value.content.trim()
|
|
151
163
|
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { dirname } from 'node:path'
|
|
16
|
+
import { mapWithConcurrency } from '~/lib/utils/concurrency.js'
|
|
16
17
|
import type { Result } from '~/lib/utils/result.js'
|
|
17
18
|
import { Err, Ok } from '~/lib/utils/result.js'
|
|
18
19
|
import type { FileSystem } from '~/platform/fs.js'
|
|
@@ -22,6 +23,7 @@ import type { Logger } from '../../../lib/logger/logger.js'
|
|
|
22
23
|
import type { Preprocessor, PreprocessorContext, PreprocessorRegistry, PreprocessorResult } from '../preprocessor.js'
|
|
23
24
|
|
|
24
25
|
const MAX_IMAGES = 50
|
|
26
|
+
const IMAGE_CLASSIFY_CONCURRENCY = 10
|
|
25
27
|
|
|
26
28
|
function makeExec(processRunner: ProcessRunner) {
|
|
27
29
|
return (cmd: string, args: string[]) => processRunner.execFile(cmd, args, { timeout: 60_000, maxBuffer: 50 * 1024 * 1024 })
|
|
@@ -228,19 +230,17 @@ export async function classifyExtractedImages(
|
|
|
228
230
|
registry: PreprocessorRegistry,
|
|
229
231
|
logger: Logger,
|
|
230
232
|
): Promise<Array<{ relativePath: string; description: string }>> {
|
|
231
|
-
const results: Array<{ relativePath: string; description: string }> = []
|
|
232
|
-
|
|
233
233
|
const listResult = await imageStore.list('', { maxDepth: 3 })
|
|
234
|
-
if (!listResult.ok) return
|
|
234
|
+
if (!listResult.ok) return []
|
|
235
235
|
|
|
236
236
|
const imageFiles = listResult.value
|
|
237
237
|
.filter(e => e.type === 'file' && IMAGE_EXT_RE.test(e.name))
|
|
238
238
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
239
239
|
.slice(0, MAX_IMAGES)
|
|
240
240
|
|
|
241
|
-
|
|
241
|
+
const settled = await mapWithConcurrency(imageFiles, IMAGE_CLASSIFY_CONCURRENCY, async (imgFile) => {
|
|
242
242
|
const imgPathResult = imageStore.realPath(imgFile.name)
|
|
243
|
-
if (!imgPathResult.ok)
|
|
243
|
+
if (!imgPathResult.ok) return null
|
|
244
244
|
|
|
245
245
|
const imgMime = guessImageMime(imgFile.name)
|
|
246
246
|
let description = imgMime
|
|
@@ -255,8 +255,8 @@ export async function classifyExtractedImages(
|
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
-
|
|
259
|
-
}
|
|
258
|
+
return { relativePath: `${relativePrefix}/${imgFile.name}`, description }
|
|
259
|
+
})
|
|
260
260
|
|
|
261
|
-
return
|
|
261
|
+
return settled.filter((r): r is { relativePath: string; description: string } => r !== null)
|
|
262
262
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { extname } from 'node:path'
|
|
11
|
+
import { mapWithConcurrency } from '~/lib/utils/concurrency.js'
|
|
11
12
|
import type { Result } from '~/lib/utils/result.js'
|
|
12
13
|
import { Err, Ok } from '~/lib/utils/result.js'
|
|
13
14
|
import type { ProcessRunner } from '~/platform/process.js'
|
|
@@ -17,6 +18,7 @@ import type { Preprocessor, PreprocessorContext, PreprocessorRegistry, Preproces
|
|
|
17
18
|
const MAX_DEPTH = 3
|
|
18
19
|
const MAX_FILES = 500
|
|
19
20
|
const MAX_TOTAL_SIZE = 100 * 1024 * 1024 // 100MB
|
|
21
|
+
const ZIP_FILE_CONCURRENCY = 10
|
|
20
22
|
|
|
21
23
|
const MIME_MAP: Record<string, string> = {
|
|
22
24
|
'.pdf': 'application/pdf',
|
|
@@ -116,38 +118,45 @@ export class ZipPreprocessor implements Preprocessor {
|
|
|
116
118
|
return Err(new Error('Failed to list extracted files'))
|
|
117
119
|
}
|
|
118
120
|
|
|
119
|
-
const derivedPaths: string[] = []
|
|
120
|
-
const manifest: string[] = []
|
|
121
|
-
let fileCount = 0
|
|
122
|
-
let totalSize = 0
|
|
123
|
-
|
|
124
121
|
const files = listResult.value
|
|
125
122
|
.filter(e => e.type === 'file')
|
|
126
123
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
127
124
|
|
|
125
|
+
// Pick eligible files first (limits depend on cumulative iteration order, so this stays sequential)
|
|
126
|
+
const eligible: typeof files = []
|
|
127
|
+
let totalSize = 0
|
|
128
|
+
let truncationNotice: string | null = null
|
|
129
|
+
|
|
128
130
|
for (const file of files) {
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
+
if (eligible.length >= MAX_FILES) {
|
|
132
|
+
truncationNotice = `... (truncated, ${files.length - eligible.length} more files)`
|
|
131
133
|
break
|
|
132
134
|
}
|
|
133
|
-
|
|
134
135
|
const fileSize = file.size ?? 0
|
|
135
|
-
totalSize
|
|
136
|
-
|
|
137
|
-
manifest.push('... (total size limit reached)')
|
|
136
|
+
if (totalSize + fileSize > MAX_TOTAL_SIZE) {
|
|
137
|
+
truncationNotice = '... (total size limit reached)'
|
|
138
138
|
break
|
|
139
139
|
}
|
|
140
|
+
totalSize += fileSize
|
|
141
|
+
eligible.push(file)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const fileCount = eligible.length
|
|
140
145
|
|
|
141
|
-
|
|
146
|
+
// Process eligible files in parallel with bounded concurrency
|
|
147
|
+
const processed = await mapWithConcurrency(eligible, ZIP_FILE_CONCURRENCY, async (file) => {
|
|
148
|
+
const collectedPaths: string[] = []
|
|
142
149
|
|
|
143
150
|
const fileRealPath = extractStore.realPath(file.name)
|
|
144
151
|
if (!fileRealPath.ok) {
|
|
145
|
-
|
|
146
|
-
|
|
152
|
+
return {
|
|
153
|
+
manifestEntry: `- ${file.name} (path resolution failed)`,
|
|
154
|
+
derivedPaths: collectedPaths,
|
|
155
|
+
}
|
|
147
156
|
}
|
|
148
157
|
|
|
149
158
|
const relativePath = `extracted/${file.name}`
|
|
150
|
-
|
|
159
|
+
collectedPaths.push(relativePath)
|
|
151
160
|
|
|
152
161
|
const mime = getMimeType(file.name)
|
|
153
162
|
let contentSummary = ''
|
|
@@ -171,7 +180,7 @@ export class ZipPreprocessor implements Preprocessor {
|
|
|
171
180
|
if (subResult.ok) {
|
|
172
181
|
if (subResult.value.derivedPaths) {
|
|
173
182
|
for (const dp of subResult.value.derivedPaths) {
|
|
174
|
-
|
|
183
|
+
collectedPaths.push(`extracted/${file.name}-content/${dp}`)
|
|
175
184
|
}
|
|
176
185
|
}
|
|
177
186
|
if (subResult.value.extractedContent) {
|
|
@@ -186,8 +195,19 @@ export class ZipPreprocessor implements Preprocessor {
|
|
|
186
195
|
}
|
|
187
196
|
}
|
|
188
197
|
|
|
189
|
-
|
|
198
|
+
return {
|
|
199
|
+
manifestEntry: `- ${file.name} (${formatSize(file.size ?? 0)})${contentSummary}`,
|
|
200
|
+
derivedPaths: collectedPaths,
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const derivedPaths: string[] = []
|
|
205
|
+
const manifest: string[] = []
|
|
206
|
+
for (const item of processed) {
|
|
207
|
+
derivedPaths.push(...item.derivedPaths)
|
|
208
|
+
manifest.push(item.manifestEntry)
|
|
190
209
|
}
|
|
210
|
+
if (truncationNotice) manifest.push(truncationNotice)
|
|
191
211
|
|
|
192
212
|
const fullManifest = `## ZIP Contents (${fileCount} files)\n\n${manifest.join('\n')}`
|
|
193
213
|
|
|
@@ -9,7 +9,7 @@ export const uploadEvents = createEventsFactory({
|
|
|
9
9
|
filename: z4.string(),
|
|
10
10
|
mimeType: z4.string(),
|
|
11
11
|
size: z4.number(),
|
|
12
|
-
status: z4.enum(['ready', 'failed']),
|
|
12
|
+
status: z4.enum(['processing', 'ready', 'failed']),
|
|
13
13
|
extractedContent: z4.string().optional(),
|
|
14
14
|
derivedPaths: z4.array(z4.string()).optional(),
|
|
15
15
|
error: z4.string().optional(),
|
|
@@ -613,6 +613,129 @@ describe('uploads plugin', () => {
|
|
|
613
613
|
await harness.shutdown()
|
|
614
614
|
})
|
|
615
615
|
|
|
616
|
+
// =========================================================================
|
|
617
|
+
// uploadAsync method
|
|
618
|
+
// =========================================================================
|
|
619
|
+
|
|
620
|
+
it('uploadAsync returns processing immediately, then statusChanged → ready', async () => {
|
|
621
|
+
const harness = new TestHarness({
|
|
622
|
+
presets: [createTestPreset()],
|
|
623
|
+
llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
const session = await harness.createSession('test')
|
|
627
|
+
const fileContent = Buffer.from('Hello, async world!')
|
|
628
|
+
|
|
629
|
+
const result = await session.callPluginMethod('uploads.uploadAsync', {
|
|
630
|
+
sessionId: String(session.sessionId),
|
|
631
|
+
filename: 'async.txt',
|
|
632
|
+
mimeType: 'text/plain',
|
|
633
|
+
size: fileContent.length,
|
|
634
|
+
fileBuffer: fileContent,
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
const data = okValue(
|
|
638
|
+
result,
|
|
639
|
+
z.object({ uploadId: z.string(), status: z.enum(['processing']) }),
|
|
640
|
+
)
|
|
641
|
+
expect(data.status).toBe('processing')
|
|
642
|
+
|
|
643
|
+
// Wait for the terminal statusChanged notification (ready or failed).
|
|
644
|
+
const terminal = await harness.notifications.waitFor((n) => {
|
|
645
|
+
if (n.pluginName !== 'uploads' || n.type !== 'uploadStatusChanged') return false
|
|
646
|
+
const p = n.payload as { uploadId: string; status: string }
|
|
647
|
+
return p.uploadId === data.uploadId && (p.status === 'ready' || p.status === 'failed')
|
|
648
|
+
})
|
|
649
|
+
expect((terminal.payload as { status: string }).status).toBe('ready')
|
|
650
|
+
|
|
651
|
+
// Two attachment_uploaded events were emitted: processing → ready.
|
|
652
|
+
const events = await session.getEventsByType(uploadEvents, 'attachment_uploaded')
|
|
653
|
+
const own = events.filter((e) => String(e.uploadId) === data.uploadId)
|
|
654
|
+
expect(own).toHaveLength(2)
|
|
655
|
+
expect(own[0].status).toBe('processing')
|
|
656
|
+
expect(own[1].status).toBe('ready')
|
|
657
|
+
|
|
658
|
+
// State only carries ready uploads — processing event is filtered by reducer.
|
|
659
|
+
const uploads = selectPluginState<UploadsState>(session.state, 'uploads')
|
|
660
|
+
if (!uploads) throw new Error('Expected uploads state')
|
|
661
|
+
expect(uploads.pending).toHaveLength(1)
|
|
662
|
+
expect(uploads.pending[0]?.uploadId).toBe(data.uploadId)
|
|
663
|
+
expect(uploads.pending[0]?.status).toBe('ready')
|
|
664
|
+
|
|
665
|
+
// First notification was processing.
|
|
666
|
+
const all = harness.notifications.getByType('uploads', 'uploadStatusChanged')
|
|
667
|
+
expect(all.length).toBeGreaterThanOrEqual(2)
|
|
668
|
+
expect((all[0]?.payload as { status: string }).status).toBe('processing')
|
|
669
|
+
|
|
670
|
+
await harness.shutdown()
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
it('uploadAsync rejects oversize file synchronously (no event)', async () => {
|
|
674
|
+
const harness = new TestHarness({
|
|
675
|
+
presets: [createTestPreset()],
|
|
676
|
+
llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
const session = await harness.createSession('test')
|
|
680
|
+
|
|
681
|
+
const result = await session.callPluginMethod('uploads.uploadAsync', {
|
|
682
|
+
sessionId: String(session.sessionId),
|
|
683
|
+
filename: 'huge.txt',
|
|
684
|
+
mimeType: 'text/plain',
|
|
685
|
+
size: 11 * 1024 * 1024,
|
|
686
|
+
fileBuffer: Buffer.from('tiny'),
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
expect(result.ok).toBe(false)
|
|
690
|
+
if (!result.ok) {
|
|
691
|
+
expect(result.error.message).toContain('File too large')
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const events = await session.getEventsByType(uploadEvents, 'attachment_uploaded')
|
|
695
|
+
expect(events).toHaveLength(0)
|
|
696
|
+
|
|
697
|
+
await harness.shutdown()
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
it('uploadAsync — listPending exposes processing then ready', async () => {
|
|
701
|
+
const harness = new TestHarness({
|
|
702
|
+
presets: [createTestPreset()],
|
|
703
|
+
llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
const session = await harness.createSession('test')
|
|
707
|
+
|
|
708
|
+
const result = await session.callPluginMethod('uploads.uploadAsync', {
|
|
709
|
+
sessionId: String(session.sessionId),
|
|
710
|
+
filename: 'list-async.txt',
|
|
711
|
+
mimeType: 'text/plain',
|
|
712
|
+
size: 4,
|
|
713
|
+
fileBuffer: Buffer.from('data'),
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
const data = okValue(
|
|
717
|
+
result,
|
|
718
|
+
z.object({ uploadId: z.string(), status: z.enum(['processing']) }),
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
// Wait for terminal notification so the second meta.json write has landed.
|
|
722
|
+
await harness.notifications.waitFor((n) => {
|
|
723
|
+
if (n.pluginName !== 'uploads' || n.type !== 'uploadStatusChanged') return false
|
|
724
|
+
const p = n.payload as { uploadId: string; status: string }
|
|
725
|
+
return p.uploadId === data.uploadId && p.status === 'ready'
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
const listResult = await session.callPluginMethod('uploads.listPending', {
|
|
729
|
+
sessionId: String(session.sessionId),
|
|
730
|
+
})
|
|
731
|
+
const list = okValue(listResult, listPendingSchema)
|
|
732
|
+
const ours = list.uploads.find((u) => u.uploadId === data.uploadId)
|
|
733
|
+
expect(ours).toBeDefined()
|
|
734
|
+
expect(ours?.status).toBe('ready')
|
|
735
|
+
|
|
736
|
+
await harness.shutdown()
|
|
737
|
+
})
|
|
738
|
+
|
|
616
739
|
it('load deleted upload → error (not ready)', async () => {
|
|
617
740
|
const harness = new TestHarness({
|
|
618
741
|
presets: [createTestPreset()],
|
|
@@ -113,6 +113,93 @@ export function createUploadRoutes(): Hono<AppEnv> {
|
|
|
113
113
|
)
|
|
114
114
|
})
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* POST /sessions/:sessionId/upload-async
|
|
118
|
+
*
|
|
119
|
+
* Async variant of /upload — returns immediately with status: 'processing'
|
|
120
|
+
* and continues preprocessing in the background. Clients should listen for
|
|
121
|
+
* the `uploads.uploadStatusChanged` notification to learn when the upload
|
|
122
|
+
* becomes `ready` or `failed`, or fall back to polling `uploads.listPending`.
|
|
123
|
+
*
|
|
124
|
+
* Form fields: same as /upload.
|
|
125
|
+
*
|
|
126
|
+
* Response:
|
|
127
|
+
* - 202: { uploadId, status: 'processing' }
|
|
128
|
+
* - 400: Validation error
|
|
129
|
+
* - 404: Session not found
|
|
130
|
+
*/
|
|
131
|
+
app.post('/:sessionId/upload-async', async (c: AppContext) => {
|
|
132
|
+
const { sessionRuntime, logger } = getServices(c)
|
|
133
|
+
const sessionId = SessionId(c.req.param('sessionId')!)
|
|
134
|
+
|
|
135
|
+
const sessionResult = await sessionRuntime.getSession(sessionId)
|
|
136
|
+
if (!sessionResult.ok) {
|
|
137
|
+
return c.json(
|
|
138
|
+
{ error: { type: 'session_not_found', message: `Session not found: ${sessionId}` } },
|
|
139
|
+
404,
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let body: Record<string, string | File>
|
|
144
|
+
try {
|
|
145
|
+
body = await c.req.parseBody()
|
|
146
|
+
} catch {
|
|
147
|
+
return c.json(
|
|
148
|
+
{ error: { type: 'parse_error', message: 'Failed to parse multipart form data' } },
|
|
149
|
+
400,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const file = body.file
|
|
154
|
+
if (!file || !(file instanceof File)) {
|
|
155
|
+
return c.json(
|
|
156
|
+
{ error: { type: 'validation_error', message: 'No file provided' } },
|
|
157
|
+
400,
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const fileBuffer = Buffer.from(await file.arrayBuffer())
|
|
162
|
+
|
|
163
|
+
const result = await sessionRuntime.callPluginMethod(sessionId, 'uploads.uploadAsync', {
|
|
164
|
+
sessionId: String(sessionId),
|
|
165
|
+
filename: file.name,
|
|
166
|
+
mimeType: file.type,
|
|
167
|
+
size: file.size,
|
|
168
|
+
fileBuffer,
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
if (!result.ok) {
|
|
172
|
+
return c.json(
|
|
173
|
+
{ error: { type: result.error.type, message: result.error.type === 'validation_error' ? result.error.message : 'Upload failed' } },
|
|
174
|
+
400,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const uploadResult = result.value
|
|
179
|
+
if (typeof uploadResult !== 'object' || uploadResult === null || !('uploadId' in uploadResult)) {
|
|
180
|
+
return c.json(
|
|
181
|
+
{ error: { type: 'internal_error', message: 'Plugin did not return expected result' } },
|
|
182
|
+
500,
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
logger.info('File upload accepted (async)', {
|
|
187
|
+
sessionId,
|
|
188
|
+
uploadId: uploadResult.uploadId,
|
|
189
|
+
filename: file.name,
|
|
190
|
+
mimeType: file.type,
|
|
191
|
+
size: file.size,
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
return c.json(
|
|
195
|
+
{
|
|
196
|
+
uploadId: uploadResult.uploadId,
|
|
197
|
+
status: 'status' in uploadResult ? uploadResult.status : 'processing',
|
|
198
|
+
},
|
|
199
|
+
202,
|
|
200
|
+
)
|
|
201
|
+
})
|
|
202
|
+
|
|
116
203
|
/**
|
|
117
204
|
* POST /sessions/:sessionId/upload-from-url
|
|
118
205
|
*
|