@roj-ai/sdk 0.1.12 → 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 +18 -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/core/sessions/session-manager.d.ts.map +1 -1
- package/dist/core/sessions/session-manager.js +13 -5
- package/dist/core/sessions/session-manager.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/agents/plugin.d.ts +20 -0
- package/dist/plugins/agents/plugin.d.ts.map +1 -1
- package/dist/plugins/agents/plugin.js +189 -2
- package/dist/plugins/agents/plugin.js.map +1 -1
- package/dist/plugins/agents/supervision.integration.test.d.ts +2 -0
- package/dist/plugins/agents/supervision.integration.test.d.ts.map +1 -0
- package/dist/plugins/agents/supervision.integration.test.js +215 -0
- package/dist/plugins/agents/supervision.integration.test.js.map +1 -0
- package/dist/plugins/mailbox/plugin.d.ts +1 -0
- package/dist/plugins/mailbox/plugin.d.ts.map +1 -1
- package/dist/plugins/mailbox/plugin.js +17 -0
- package/dist/plugins/mailbox/plugin.js.map +1 -1
- package/dist/plugins/mailbox/schema.d.ts +1 -1
- package/dist/plugins/mailbox/schema.d.ts.map +1 -1
- package/dist/plugins/mailbox/state.d.ts +2 -1
- package/dist/plugins/mailbox/state.d.ts.map +1 -1
- package/dist/plugins/mailbox/state.js +1 -1
- package/dist/plugins/mailbox/state.js.map +1 -1
- 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/middleware/error-handler.d.ts +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/dist/user-config.d.ts +14 -0
- package/dist/user-config.d.ts.map +1 -1
- package/dist/user-config.js.map +1 -1
- package/package.json +2 -2
- package/src/bootstrap.ts +3 -1
- package/src/config.ts +6 -0
- package/src/core/sessions/session-manager.ts +14 -5
- package/src/lib/utils/concurrency.test.ts +169 -0
- package/src/lib/utils/concurrency.ts +72 -0
- package/src/plugins/agents/plugin.ts +228 -3
- package/src/plugins/agents/supervision.integration.test.ts +249 -0
- package/src/plugins/mailbox/plugin.ts +20 -0
- package/src/plugins/mailbox/schema.ts +1 -0
- package/src/plugins/mailbox/state.ts +2 -1
- 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
- package/src/user-config.ts +15 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { AgentId } from '~/core/agents/schema.js'
|
|
3
|
+
import { MockLLMProvider } from '~/core/llm/mock.js'
|
|
4
|
+
import { ToolCallId } from '~/core/tools/schema.js'
|
|
5
|
+
import { agentsPlugin } from '~/plugins/agents/plugin.js'
|
|
6
|
+
import { mailboxEvents } from '~/plugins/mailbox/index.js'
|
|
7
|
+
import { createMultiAgentPreset, TestHarness, type TestSession } from '~/testing/index.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Helper — wait until at least one supervision message has landed in the parent's
|
|
11
|
+
* mailbox (or timeout). We poll because supervision ticks fire on real timers.
|
|
12
|
+
*/
|
|
13
|
+
async function waitForSupervisorMessage(
|
|
14
|
+
session: TestSession,
|
|
15
|
+
toAgentId: AgentId,
|
|
16
|
+
timeoutMs = 2000,
|
|
17
|
+
): Promise<{ message: { from: unknown; content: string } } | undefined> {
|
|
18
|
+
const deadline = Date.now() + timeoutMs
|
|
19
|
+
while (Date.now() < deadline) {
|
|
20
|
+
const events = await session.getEventsByType(mailboxEvents, 'mailbox_message')
|
|
21
|
+
const found = events.find((e) =>
|
|
22
|
+
e.message.from === 'supervisor'
|
|
23
|
+
&& e.toAgentId === toAgentId
|
|
24
|
+
&& typeof e.message.content === 'string',
|
|
25
|
+
)
|
|
26
|
+
if (found) return found
|
|
27
|
+
await new Promise((r) => setTimeout(r, 25))
|
|
28
|
+
}
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('agents plugin supervision', () => {
|
|
33
|
+
it('parent with active children receives a periodic <children-status> snapshot', async () => {
|
|
34
|
+
let orchestratorCalls = 0
|
|
35
|
+
let workerCalls = 0
|
|
36
|
+
|
|
37
|
+
const harness = new TestHarness({
|
|
38
|
+
presets: [{
|
|
39
|
+
...createMultiAgentPreset([
|
|
40
|
+
{ name: 'worker', system: 'Worker agent.', tools: [], agents: [] },
|
|
41
|
+
], { orchestratorSystem: 'Orchestrator agent.' }),
|
|
42
|
+
plugins: [{ pluginName: 'agents', definition: agentsPlugin, config: { superviseChildrenIntervalMs: 100 } }],
|
|
43
|
+
}],
|
|
44
|
+
mockHandler: (request) => {
|
|
45
|
+
if (request.systemPrompt.includes('Orchestrator')) {
|
|
46
|
+
orchestratorCalls++
|
|
47
|
+
if (orchestratorCalls === 1) {
|
|
48
|
+
return {
|
|
49
|
+
content: null,
|
|
50
|
+
toolCalls: [{ id: ToolCallId('tc1'), name: 'start_worker', input: { message: 'Long-running task' } }],
|
|
51
|
+
finishReason: 'stop',
|
|
52
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Subsequent calls: orchestrator does nothing more, just acknowledges.
|
|
56
|
+
return { content: 'noted', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
|
|
57
|
+
}
|
|
58
|
+
// Worker: takes a long time — say something but never reports back.
|
|
59
|
+
workerCalls++
|
|
60
|
+
return { content: `Working on step ${workerCalls}`, toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const session = await harness.createSession('test')
|
|
65
|
+
await session.sendMessage('Start')
|
|
66
|
+
|
|
67
|
+
// Orchestrator is the entry agent in this preset. Wait for a tick.
|
|
68
|
+
const orchestratorId = session.getEntryAgentId()!
|
|
69
|
+
const supervisorMsg = await waitForSupervisorMessage(session as never, orchestratorId)
|
|
70
|
+
|
|
71
|
+
expect(supervisorMsg).toBeDefined()
|
|
72
|
+
expect(supervisorMsg!.message.content).toContain('<children-status>')
|
|
73
|
+
expect(supervisorMsg!.message.content).toContain('worker_1')
|
|
74
|
+
// Cumulative LLM call count should be present
|
|
75
|
+
expect(supervisorMsg!.message.content).toMatch(/worker_1[^,\n]*,[^,\n]*,\s*\d+ tools,\s*\d+ llm/)
|
|
76
|
+
|
|
77
|
+
await harness.shutdown()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('default (no config) → supervision disabled, no tick fires', async () => {
|
|
81
|
+
const harness = new TestHarness({
|
|
82
|
+
presets: [createMultiAgentPreset([
|
|
83
|
+
{ name: 'worker', system: 'Worker agent.', tools: [], agents: [] },
|
|
84
|
+
], { orchestratorSystem: 'Orchestrator agent.' })],
|
|
85
|
+
mockHandler: (request) => {
|
|
86
|
+
if (request.systemPrompt.includes('Orchestrator')) {
|
|
87
|
+
return {
|
|
88
|
+
content: null,
|
|
89
|
+
toolCalls: [{ id: ToolCallId('tc1'), name: 'start_worker', input: { message: 'Do work' } }],
|
|
90
|
+
finishReason: 'stop',
|
|
91
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { content: 'Working', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const session = await harness.createSession('test')
|
|
99
|
+
await session.sendMessage('Start')
|
|
100
|
+
|
|
101
|
+
// Wait long enough for ticks if they were enabled (they shouldn't).
|
|
102
|
+
await new Promise((r) => setTimeout(r, 300))
|
|
103
|
+
|
|
104
|
+
const events = await session.getEventsByType(mailboxEvents, 'mailbox_message')
|
|
105
|
+
const supervisorMessages = events.filter(e => e.message.from === 'supervisor')
|
|
106
|
+
expect(supervisorMessages).toHaveLength(0)
|
|
107
|
+
|
|
108
|
+
await harness.shutdown()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('parent without children → no tick fires', async () => {
|
|
112
|
+
const harness = new TestHarness({
|
|
113
|
+
presets: [{
|
|
114
|
+
...createMultiAgentPreset([
|
|
115
|
+
{ name: 'worker', system: 'Worker agent.', tools: [], agents: [] },
|
|
116
|
+
], { orchestratorSystem: 'Orchestrator agent.' }),
|
|
117
|
+
plugins: [{ pluginName: 'agents', definition: agentsPlugin, config: { superviseChildrenIntervalMs: 100 } }],
|
|
118
|
+
}],
|
|
119
|
+
mockHandler: (request) => {
|
|
120
|
+
// Orchestrator never spawns anyone.
|
|
121
|
+
if (request.systemPrompt.includes('Orchestrator')) {
|
|
122
|
+
return { content: 'Done without spawning', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
|
|
123
|
+
}
|
|
124
|
+
return { content: 'unused', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const session = await harness.createSession('test')
|
|
129
|
+
await session.sendAndWaitForIdle('Start')
|
|
130
|
+
|
|
131
|
+
// Give supervision plenty of room to fire (it shouldn't).
|
|
132
|
+
await new Promise((r) => setTimeout(r, 300))
|
|
133
|
+
|
|
134
|
+
const events = await session.getEventsByType(mailboxEvents, 'mailbox_message')
|
|
135
|
+
const supervisorMessages = events.filter(e => e.message.from === 'supervisor')
|
|
136
|
+
expect(supervisorMessages).toHaveLength(0)
|
|
137
|
+
|
|
138
|
+
await harness.shutdown()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('snapshot includes "first words..last words" preview of last assistant turn', async () => {
|
|
142
|
+
let orchestratorCalls = 0
|
|
143
|
+
|
|
144
|
+
const harness = new TestHarness({
|
|
145
|
+
presets: [{
|
|
146
|
+
...createMultiAgentPreset([
|
|
147
|
+
{ name: 'worker', system: 'Worker agent.', tools: [], agents: [] },
|
|
148
|
+
], { orchestratorSystem: 'Orchestrator agent.' }),
|
|
149
|
+
plugins: [{ pluginName: 'agents', definition: agentsPlugin, config: { superviseChildrenIntervalMs: 100 } }],
|
|
150
|
+
}],
|
|
151
|
+
mockHandler: (request) => {
|
|
152
|
+
if (request.systemPrompt.includes('Orchestrator')) {
|
|
153
|
+
orchestratorCalls++
|
|
154
|
+
if (orchestratorCalls === 1) {
|
|
155
|
+
return {
|
|
156
|
+
content: null,
|
|
157
|
+
toolCalls: [{ id: ToolCallId('tc1'), name: 'start_worker', input: { message: 'Long task' } }],
|
|
158
|
+
finishReason: 'stop',
|
|
159
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { content: 'ack', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
|
|
163
|
+
}
|
|
164
|
+
// Worker says a long sentence that should be truncated to first..last words
|
|
165
|
+
return {
|
|
166
|
+
content: 'Started fetching data and now I am running through the pipeline analyzing the response carefully',
|
|
167
|
+
toolCalls: [],
|
|
168
|
+
finishReason: 'stop',
|
|
169
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const session = await harness.createSession('test')
|
|
175
|
+
await session.sendMessage('Start')
|
|
176
|
+
|
|
177
|
+
const orchestratorId = session.getEntryAgentId()!
|
|
178
|
+
const msg = await waitForSupervisorMessage(session as never, orchestratorId)
|
|
179
|
+
|
|
180
|
+
expect(msg).toBeDefined()
|
|
181
|
+
// Should contain both head (first 5 words) and tail (last 5 words), joined by ".."
|
|
182
|
+
expect(msg!.message.content).toContain('Started fetching data and now')
|
|
183
|
+
expect(msg!.message.content).toContain('pipeline analyzing the response carefully')
|
|
184
|
+
expect(msg!.message.content).toMatch(/\.\.pipeline/)
|
|
185
|
+
|
|
186
|
+
await harness.shutdown()
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('server restart re-establishes timers via onSessionReady', async () => {
|
|
190
|
+
const sharedEventStore = new (await import('~/core/events/memory.js')).MemoryEventStore()
|
|
191
|
+
|
|
192
|
+
// Counter shared across phases — phase 1 spawns once, then orchestrator goes idle;
|
|
193
|
+
// phase 2 just acknowledges any wake-up triggered by the supervision tick.
|
|
194
|
+
let orchestratorCalls = 0
|
|
195
|
+
|
|
196
|
+
const buildHarness = (intervalMs: number | undefined) => new TestHarness({
|
|
197
|
+
eventStore: sharedEventStore,
|
|
198
|
+
presets: [{
|
|
199
|
+
...createMultiAgentPreset([
|
|
200
|
+
{ name: 'worker', system: 'Worker agent.', tools: [], agents: [] },
|
|
201
|
+
], { orchestratorSystem: 'Orchestrator agent.' }),
|
|
202
|
+
...(intervalMs !== undefined && {
|
|
203
|
+
plugins: [{ pluginName: 'agents', definition: agentsPlugin, config: { superviseChildrenIntervalMs: intervalMs } }],
|
|
204
|
+
}),
|
|
205
|
+
}],
|
|
206
|
+
mockHandler: (request) => {
|
|
207
|
+
if (request.systemPrompt.includes('Orchestrator')) {
|
|
208
|
+
orchestratorCalls++
|
|
209
|
+
if (orchestratorCalls === 1) {
|
|
210
|
+
return {
|
|
211
|
+
content: null,
|
|
212
|
+
toolCalls: [{ id: ToolCallId('tc1'), name: 'start_worker', input: { message: 'Long task' } }],
|
|
213
|
+
finishReason: 'stop',
|
|
214
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { content: 'noted', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
|
|
218
|
+
}
|
|
219
|
+
return { content: 'still working', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
|
|
220
|
+
},
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
// Phase 1: create session with supervision DISABLED (default) so no ticks pre-restart.
|
|
224
|
+
const harness1 = buildHarness(undefined)
|
|
225
|
+
const session1 = await harness1.createSession('test')
|
|
226
|
+
await session1.sendAndWaitForIdle('Start')
|
|
227
|
+
const hasWorker = () => {
|
|
228
|
+
for (const agent of session1.state.agents.values()) {
|
|
229
|
+
if (agent.definitionName === 'worker') return true
|
|
230
|
+
}
|
|
231
|
+
return false
|
|
232
|
+
}
|
|
233
|
+
expect(hasWorker()).toBe(true)
|
|
234
|
+
const sessionId = session1.sessionId
|
|
235
|
+
await harness1.shutdown()
|
|
236
|
+
|
|
237
|
+
// Phase 2: restart with supervision enabled. onSessionReady should
|
|
238
|
+
// re-arm the orchestrator's tick because it has a child.
|
|
239
|
+
const harness2 = buildHarness(100)
|
|
240
|
+
const session2 = await harness2.openSession(sessionId)
|
|
241
|
+
|
|
242
|
+
const orchestratorId = session2.getEntryAgentId()!
|
|
243
|
+
const msg = await waitForSupervisorMessage(session2, orchestratorId, 1500)
|
|
244
|
+
expect(msg).toBeDefined()
|
|
245
|
+
expect(msg!.message.content).toContain('worker_1')
|
|
246
|
+
|
|
247
|
+
await harness2.shutdown()
|
|
248
|
+
})
|
|
249
|
+
})
|
|
@@ -90,11 +90,31 @@ export const mailboxPlugin = definePlugin("mailbox")
|
|
|
90
90
|
toAgentId: agentIdSchema,
|
|
91
91
|
content: z.string(),
|
|
92
92
|
debug: z.boolean().optional(),
|
|
93
|
+
fromSupervisor: z.boolean().optional(),
|
|
93
94
|
}),
|
|
94
95
|
output: z.object({ messageId: z.string() }),
|
|
95
96
|
handler: async (ctx, input) => {
|
|
96
97
|
const { toAgentId, content } = input;
|
|
97
98
|
|
|
99
|
+
if (input.fromSupervisor) {
|
|
100
|
+
// System-emitted supervision status — bypasses communication validation.
|
|
101
|
+
const messageId = generateMessageId(getNextMessageSeq(ctx.pluginState));
|
|
102
|
+
await ctx.emitEvent(
|
|
103
|
+
mailboxEvents.create("mailbox_message", {
|
|
104
|
+
toAgentId,
|
|
105
|
+
message: {
|
|
106
|
+
id: messageId,
|
|
107
|
+
from: "supervisor",
|
|
108
|
+
content,
|
|
109
|
+
timestamp: Date.now(),
|
|
110
|
+
consumed: false,
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
ctx.scheduleAgent(toAgentId);
|
|
115
|
+
return Ok({ messageId });
|
|
116
|
+
}
|
|
117
|
+
|
|
98
118
|
if (input.debug) {
|
|
99
119
|
// Debug messages bypass communication validation
|
|
100
120
|
const messageId = generateMessageId(getNextMessageSeq(ctx.pluginState));
|
|
@@ -11,6 +11,7 @@ export type MailboxMessageSender =
|
|
|
11
11
|
| WorkerId
|
|
12
12
|
| 'user'
|
|
13
13
|
| 'debug'
|
|
14
|
+
| 'supervisor'
|
|
14
15
|
| typeof ORCHESTRATOR_ROLE
|
|
15
16
|
| typeof COMMUNICATOR_ROLE
|
|
16
17
|
|
|
@@ -23,7 +24,7 @@ export const mailboxEvents = createEventsFactory({
|
|
|
23
24
|
from: z4.union([
|
|
24
25
|
agentIdSchema,
|
|
25
26
|
workerIdSchema,
|
|
26
|
-
z4.enum(['user', 'debug', COMMUNICATOR_ROLE, ORCHESTRATOR_ROLE]),
|
|
27
|
+
z4.enum(['user', 'debug', 'supervisor', COMMUNICATOR_ROLE, ORCHESTRATOR_ROLE]),
|
|
27
28
|
]),
|
|
28
29
|
content: z4.string(),
|
|
29
30
|
timestamp: z4.number(),
|
|
@@ -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
|
})
|