@pikku/core 0.12.70 → 0.12.72
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/CHANGELOG.md +143 -0
- package/LICENSE +21 -0
- package/dist/function/function-runner.js +7 -2
- package/dist/handle-error.d.ts +2 -0
- package/dist/handle-error.js +8 -2
- package/dist/schema.d.ts +24 -0
- package/dist/schema.js +46 -0
- package/dist/services/in-memory-queue-service.d.ts +6 -0
- package/dist/services/in-memory-queue-service.js +8 -1
- package/dist/services/in-memory-workflow-service.d.ts +3 -5
- package/dist/services/in-memory-workflow-service.js +10 -19
- package/dist/services/workflow-service.d.ts +7 -5
- package/dist/types/core.types.d.ts +7 -0
- package/dist/wirings/ai-agent/ai-agent-agui.js +0 -8
- package/dist/wirings/ai-agent/ai-agent-prepare.js +1 -2
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +0 -6
- package/dist/wirings/http/http-runner.d.ts +1 -1
- package/dist/wirings/http/http-runner.js +4 -2
- package/dist/wirings/http/http.types.d.ts +2 -0
- package/dist/wirings/http/index.d.ts +2 -1
- package/dist/wirings/http/index.js +1 -1
- package/dist/wirings/http/pikku-fetch-http-request.d.ts +11 -1
- package/dist/wirings/http/pikku-fetch-http-request.js +67 -3
- package/dist/wirings/mcp/mcp-runner.d.ts +5 -0
- package/dist/wirings/mcp/mcp-runner.js +5 -2
- package/dist/wirings/workflow/graph/graph-runner.js +3 -2
- package/dist/wirings/workflow/graph/graph-validation.d.ts +0 -2
- package/dist/wirings/workflow/graph/graph-validation.js +0 -142
- package/dist/wirings/workflow/graph/index.d.ts +1 -1
- package/dist/wirings/workflow/graph/index.js +1 -1
- package/dist/wirings/workflow/index.d.ts +0 -1
- package/dist/wirings/workflow/index.js +0 -2
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +69 -15
- package/dist/wirings/workflow/pikku-workflow-service.js +260 -164
- package/dist/wirings/workflow/workflow.types.d.ts +1 -6
- package/package.json +1 -2
- package/src/function/function-runner.test.ts +75 -0
- package/src/function/function-runner.ts +15 -2
- package/src/gopass-secrets-removed.test.ts +51 -0
- package/src/handle-error.test.ts +108 -1
- package/src/handle-error.ts +10 -2
- package/src/schema.test.ts +103 -0
- package/src/schema.ts +51 -0
- package/src/services/in-memory-queue-service.test.ts +66 -1
- package/src/services/in-memory-queue-service.ts +13 -2
- package/src/services/in-memory-workflow-service.ts +12 -25
- package/src/services/workflow-service.ts +7 -4
- package/src/types/core.types.ts +7 -0
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +0 -16
- package/src/wirings/ai-agent/ai-agent-agui.ts +0 -9
- package/src/wirings/ai-agent/ai-agent-prepare.ts +1 -2
- package/src/wirings/ai-agent/ai-agent.types.ts +0 -7
- package/src/wirings/http/http-runner.ts +4 -1
- package/src/wirings/http/http.types.ts +2 -0
- package/src/wirings/http/index.ts +5 -1
- package/src/wirings/http/pikku-fetch-http-request.test.ts +89 -0
- package/src/wirings/http/pikku-fetch-http-request.ts +85 -3
- package/src/wirings/mcp/mcp-runner.test.ts +40 -0
- package/src/wirings/mcp/mcp-runner.ts +11 -2
- package/src/wirings/workflow/graph/graph-runner.ts +3 -2
- package/src/wirings/workflow/graph/graph-validation.test.ts +1 -144
- package/src/wirings/workflow/graph/graph-validation.ts +0 -196
- package/src/wirings/workflow/graph/index.ts +1 -5
- package/src/wirings/workflow/index.ts +0 -6
- package/src/wirings/workflow/pikku-workflow-service.ts +377 -212
- package/src/wirings/workflow/scenario-expectations.test.ts +153 -0
- package/src/wirings/workflow/scenario-step.test.ts +1 -1
- package/src/wirings/workflow/workflow-dispatch-durability.test.ts +1 -1
- package/src/wirings/workflow/workflow-dispatch-payload.test.ts +59 -0
- package/src/wirings/workflow/workflow-mirror.test.ts +178 -0
- package/src/wirings/workflow/workflow-replay-snapshot.test.ts +139 -0
- package/src/wirings/workflow/workflow-run-context.test.ts +177 -0
- package/src/wirings/workflow/workflow-run-polling.test.ts +132 -0
- package/src/wirings/workflow/workflow-step-ordinal.test.ts +4 -4
- package/src/wirings/workflow/workflow.types.ts +1 -4
- package/tsconfig.tsbuildinfo +1 -1
- package/src/services/gopass-secrets.ts +0 -78
|
@@ -456,6 +456,18 @@ export class InMemoryWorkflowService
|
|
|
456
456
|
return nodeIds.filter((id) => !existingSteps.has(id))
|
|
457
457
|
}
|
|
458
458
|
|
|
459
|
+
protected override async listStepStates(
|
|
460
|
+
runId: string
|
|
461
|
+
): Promise<Array<StepState & { stepName: string }>> {
|
|
462
|
+
const prefix = `${runId}:`
|
|
463
|
+
const steps: Array<StepState & { stepName: string }> = []
|
|
464
|
+
for (const [key, step] of this.steps.entries()) {
|
|
465
|
+
if (!key.startsWith(prefix)) continue
|
|
466
|
+
steps.push({ ...step, stepName: key.substring(prefix.length) })
|
|
467
|
+
}
|
|
468
|
+
return steps
|
|
469
|
+
}
|
|
470
|
+
|
|
459
471
|
async getStepInstances(
|
|
460
472
|
runId: string
|
|
461
473
|
): Promise<
|
|
@@ -560,29 +572,4 @@ export class InMemoryWorkflowService
|
|
|
560
572
|
if (!version) return null
|
|
561
573
|
return { graph: version.graph, source: version.source }
|
|
562
574
|
}
|
|
563
|
-
|
|
564
|
-
async getAIGeneratedWorkflows(
|
|
565
|
-
agentName?: string
|
|
566
|
-
): Promise<Array<{ workflowName: string; graphHash: string; graph: any }>> {
|
|
567
|
-
const results: Array<{
|
|
568
|
-
workflowName: string
|
|
569
|
-
graphHash: string
|
|
570
|
-
graph: any
|
|
571
|
-
}> = []
|
|
572
|
-
const prefix = agentName ? `ai:${agentName}:` : 'ai:'
|
|
573
|
-
for (const [key, value] of this.workflowVersions) {
|
|
574
|
-
if (value.source !== 'ai-agent' || value.status !== 'active') continue
|
|
575
|
-
const separatorIdx = key.lastIndexOf(':')
|
|
576
|
-
const wfName = key.substring(0, separatorIdx)
|
|
577
|
-
const hash = key.substring(separatorIdx + 1)
|
|
578
|
-
if (wfName.startsWith(prefix)) {
|
|
579
|
-
results.push({
|
|
580
|
-
workflowName: wfName,
|
|
581
|
-
graphHash: hash,
|
|
582
|
-
graph: value.graph,
|
|
583
|
-
})
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
return results
|
|
587
|
-
}
|
|
588
575
|
}
|
|
@@ -64,6 +64,13 @@ export interface WorkflowService {
|
|
|
64
64
|
rpcService: any,
|
|
65
65
|
options?: { inline?: boolean; startNode?: string }
|
|
66
66
|
): Promise<{ runId: string }>
|
|
67
|
+
/**
|
|
68
|
+
* Start a run and wait for it to end.
|
|
69
|
+
*
|
|
70
|
+
* `pollIntervalMs` is the ceiling on the wait between reads of the run, not a
|
|
71
|
+
* fixed cadence: polling starts far shorter than this and widens towards it,
|
|
72
|
+
* so a run that finishes quickly is not held for a whole interval.
|
|
73
|
+
*/
|
|
67
74
|
runToCompletion<I>(
|
|
68
75
|
name: string,
|
|
69
76
|
input: I,
|
|
@@ -119,8 +126,4 @@ export interface WorkflowService {
|
|
|
119
126
|
name: string,
|
|
120
127
|
graphHash: string
|
|
121
128
|
): Promise<{ graph: any; source: string } | null>
|
|
122
|
-
|
|
123
|
-
getAIGeneratedWorkflows(
|
|
124
|
-
agentName?: string
|
|
125
|
-
): Promise<Array<{ workflowName: string; graphHash: string; graph: any }>>
|
|
126
129
|
}
|
package/src/types/core.types.ts
CHANGED
|
@@ -139,6 +139,13 @@ export type FunctionRuntimeMeta = {
|
|
|
139
139
|
* may drive a browser or assert against fixtures.
|
|
140
140
|
*/
|
|
141
141
|
scenarioStep?: boolean
|
|
142
|
+
/**
|
|
143
|
+
* The function behind a `pikkuScenario(...)` — a scenario's own body, as
|
|
144
|
+
* opposed to the steps it calls. Marked for the same reason as
|
|
145
|
+
* `scenarioStep`: a scenario is only ever run by `pikku scenario run`, so it
|
|
146
|
+
* has to be held back from the app bootstrap and from every deployed unit.
|
|
147
|
+
*/
|
|
148
|
+
scenario?: boolean
|
|
142
149
|
mcp?: boolean
|
|
143
150
|
readonly?: boolean
|
|
144
151
|
deploy?: 'serverless' | 'server' | 'auto'
|
|
@@ -510,22 +510,6 @@ describe('wrapChannelWithAGUI — Pikku CUSTOM events', () => {
|
|
|
510
510
|
assert.deepEqual(c.value.data, [{ title: 'foo' }])
|
|
511
511
|
})
|
|
512
512
|
|
|
513
|
-
it('emits CUSTOM pikku:workflow-created', () => {
|
|
514
|
-
const { channel, events } = makeChannel()
|
|
515
|
-
const wrapped = wrapChannelWithAGUI(channel)
|
|
516
|
-
|
|
517
|
-
wrapped.send({
|
|
518
|
-
type: 'workflow-created',
|
|
519
|
-
workflowName: 'myFlow',
|
|
520
|
-
graph: { nodes: [] },
|
|
521
|
-
} as AIStreamEvent)
|
|
522
|
-
|
|
523
|
-
const c = find(events, 'CUSTOM', 'pikku:workflow-created')
|
|
524
|
-
assert.ok(c)
|
|
525
|
-
assert.equal(c.value.workflowName, 'myFlow')
|
|
526
|
-
assert.deepEqual(c.value.graph, { nodes: [] })
|
|
527
|
-
})
|
|
528
|
-
|
|
529
513
|
it('emits CUSTOM pikku:agent-call', () => {
|
|
530
514
|
const { channel, events } = makeChannel()
|
|
531
515
|
const wrapped = wrapChannelWithAGUI(channel)
|
|
@@ -333,15 +333,6 @@ export function wrapChannelWithAGUI(
|
|
|
333
333
|
break
|
|
334
334
|
}
|
|
335
335
|
|
|
336
|
-
case 'workflow-created': {
|
|
337
|
-
send({
|
|
338
|
-
type: 'CUSTOM',
|
|
339
|
-
name: 'pikku:workflow-created',
|
|
340
|
-
value: { workflowName: event.workflowName, graph: event.graph },
|
|
341
|
-
})
|
|
342
|
-
break
|
|
343
|
-
}
|
|
344
|
-
|
|
345
336
|
case 'agent-call': {
|
|
346
337
|
send({
|
|
347
338
|
type: 'CUSTOM',
|
|
@@ -462,8 +462,7 @@ export function createScopedChannel(
|
|
|
462
462
|
event.type === 'tool-call' ||
|
|
463
463
|
event.type === 'tool-result' ||
|
|
464
464
|
event.type === 'usage' ||
|
|
465
|
-
event.type === 'error'
|
|
466
|
-
event.type === 'workflow-created'
|
|
465
|
+
event.type === 'error'
|
|
467
466
|
) {
|
|
468
467
|
parent.send({ ...event, agent: agentName, session } as AIStreamEvent)
|
|
469
468
|
} else {
|
|
@@ -350,13 +350,6 @@ export type AIStreamEvent =
|
|
|
350
350
|
session?: string
|
|
351
351
|
}
|
|
352
352
|
| { type: 'audio-done'; agent?: string; session?: string }
|
|
353
|
-
| {
|
|
354
|
-
type: 'workflow-created'
|
|
355
|
-
workflowName: string
|
|
356
|
-
graph: any
|
|
357
|
-
agent?: string
|
|
358
|
-
session?: string
|
|
359
|
-
}
|
|
360
353
|
| {
|
|
361
354
|
type: 'data'
|
|
362
355
|
name: string
|
|
@@ -531,6 +531,7 @@ export const fetchData = async <In, Out>(
|
|
|
531
531
|
exposeErrors = !isProduction(),
|
|
532
532
|
generateRequestId,
|
|
533
533
|
traceId: externalTraceId,
|
|
534
|
+
maxBodySize,
|
|
534
535
|
}: RunHTTPWiringOptions = {}
|
|
535
536
|
): Promise<Out | void> => {
|
|
536
537
|
const singletonServices = getSingletonServices()
|
|
@@ -540,7 +541,9 @@ export const fetchData = async <In, Out>(
|
|
|
540
541
|
|
|
541
542
|
// Combine the request and response into one wire object
|
|
542
543
|
const pikkuRequest =
|
|
543
|
-
request instanceof Request
|
|
544
|
+
request instanceof Request
|
|
545
|
+
? new PikkuFetchHTTPRequest(request, { maxBodySize })
|
|
546
|
+
: request
|
|
544
547
|
|
|
545
548
|
// Resolve traceId: external (e.g. CF-Ray) > x-request-id header > generated
|
|
546
549
|
let requestId: string | null = externalTraceId ?? null
|
|
@@ -38,6 +38,8 @@ export type RunHTTPWiringOptions = Partial<{
|
|
|
38
38
|
generateRequestId: () => string
|
|
39
39
|
/** Pre-resolved trace ID (e.g. CF-Ray). Falls back to x-request-id header or generated ID. */
|
|
40
40
|
traceId: string
|
|
41
|
+
/** Maximum request body size in bytes, applied when pikku wraps a fetch `Request`. */
|
|
42
|
+
maxBodySize: number
|
|
41
43
|
}>
|
|
42
44
|
|
|
43
45
|
/**
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
PikkuFetchHTTPRequest,
|
|
3
|
+
DEFAULT_MAX_BODY_SIZE,
|
|
4
|
+
} from './pikku-fetch-http-request.js'
|
|
5
|
+
export type { PikkuFetchHTTPRequestOptions } from './pikku-fetch-http-request.js'
|
|
2
6
|
export { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js'
|
|
3
7
|
export { logRoutes } from './log-http-routes.js'
|
|
4
8
|
|
|
@@ -221,6 +221,95 @@ test('data() throws on boolean conflict', async () => {
|
|
|
221
221
|
|
|
222
222
|
// --- Safe fallback: only one source
|
|
223
223
|
|
|
224
|
+
// --- Body size limits
|
|
225
|
+
|
|
226
|
+
const streamedRequest = (chunks) =>
|
|
227
|
+
new Request('http://localhost', {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: { 'Content-Type': 'application/json' },
|
|
230
|
+
body: new ReadableStream({
|
|
231
|
+
start(controller) {
|
|
232
|
+
for (const chunk of chunks) {
|
|
233
|
+
controller.enqueue(new TextEncoder().encode(chunk))
|
|
234
|
+
}
|
|
235
|
+
controller.close()
|
|
236
|
+
},
|
|
237
|
+
}),
|
|
238
|
+
duplex: 'half',
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('arrayBuffer() rejects a body larger than the configured limit', async () => {
|
|
242
|
+
const req = new Request('http://localhost', {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
245
|
+
body: 'x'.repeat(64),
|
|
246
|
+
})
|
|
247
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req, { maxBodySize: 16 })
|
|
248
|
+
await assert.rejects(async () => await pikkuReq.arrayBuffer(), {
|
|
249
|
+
name: 'PayloadTooLargeError',
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('arrayBuffer() rejects an oversized body with no content-length header', async () => {
|
|
254
|
+
const req = streamedRequest(['x'.repeat(32), 'y'.repeat(32)])
|
|
255
|
+
assert.equal(req.headers.get('content-length'), null)
|
|
256
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req, { maxBodySize: 16 })
|
|
257
|
+
await assert.rejects(async () => await pikkuReq.arrayBuffer(), {
|
|
258
|
+
name: 'PayloadTooLargeError',
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('arrayBuffer() rejects a body whose content-length under-reports its size', async () => {
|
|
263
|
+
const req = new Request('http://localhost', {
|
|
264
|
+
method: 'POST',
|
|
265
|
+
headers: {
|
|
266
|
+
'Content-Type': 'application/json',
|
|
267
|
+
'Content-Length': '4',
|
|
268
|
+
},
|
|
269
|
+
body: new ReadableStream({
|
|
270
|
+
start(controller) {
|
|
271
|
+
controller.enqueue(new TextEncoder().encode('z'.repeat(128)))
|
|
272
|
+
controller.close()
|
|
273
|
+
},
|
|
274
|
+
}),
|
|
275
|
+
duplex: 'half',
|
|
276
|
+
})
|
|
277
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req, { maxBodySize: 16 })
|
|
278
|
+
await assert.rejects(async () => await pikkuReq.arrayBuffer(), {
|
|
279
|
+
name: 'PayloadTooLargeError',
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('data() surfaces PayloadTooLargeError rather than wrapping it', async () => {
|
|
284
|
+
const req = createRequest(
|
|
285
|
+
'POST',
|
|
286
|
+
'http://localhost',
|
|
287
|
+
{ padding: 'x'.repeat(256) },
|
|
288
|
+
{ 'Content-Type': 'application/json' }
|
|
289
|
+
)
|
|
290
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req, { maxBodySize: 16 })
|
|
291
|
+
await assert.rejects(async () => await pikkuReq.data(), {
|
|
292
|
+
name: 'PayloadTooLargeError',
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
test('data() accepts a body within the configured limit', async () => {
|
|
297
|
+
const req = createRequest(
|
|
298
|
+
'POST',
|
|
299
|
+
'http://localhost',
|
|
300
|
+
{ ok: true },
|
|
301
|
+
{ 'Content-Type': 'application/json' }
|
|
302
|
+
)
|
|
303
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req, { maxBodySize: 1024 })
|
|
304
|
+
assert.deepEqual(await pikkuReq.data(), { ok: true })
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('data() accepts a streamed body within the default limit', async () => {
|
|
308
|
+
const req = streamedRequest(['{"ok"', ':true}'])
|
|
309
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req)
|
|
310
|
+
assert.deepEqual(await pikkuReq.data(), { ok: true })
|
|
311
|
+
})
|
|
312
|
+
|
|
224
313
|
test('data() works when only body has values', async () => {
|
|
225
314
|
const req = createRequest(
|
|
226
315
|
'POST',
|
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import { parse as parseQuery } from 'picoquery'
|
|
2
2
|
import { parse as parseCookie } from 'cookie'
|
|
3
3
|
import type { HTTPMethod, PikkuHTTPRequest, PikkuQuery } from './http.types.js'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
PayloadTooLargeError,
|
|
6
|
+
UnprocessableContentError,
|
|
7
|
+
} from '../../errors/errors.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The largest request body read into memory when no limit is configured. Ample
|
|
11
|
+
* for JSON APIs and typical uploads while keeping a single request's memory
|
|
12
|
+
* footprint bounded.
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024
|
|
15
|
+
|
|
16
|
+
export type PikkuFetchHTTPRequestOptions = Partial<{
|
|
17
|
+
/** Maximum request body size in bytes. Defaults to {@link DEFAULT_MAX_BODY_SIZE}. */
|
|
18
|
+
maxBodySize: number
|
|
19
|
+
}>
|
|
5
20
|
|
|
6
21
|
/**
|
|
7
22
|
* Abstract class representing a pikku request.
|
|
@@ -17,9 +32,14 @@ export class PikkuFetchHTTPRequest<
|
|
|
17
32
|
#rawBodyText: string | undefined
|
|
18
33
|
#rawBodyBuffer: ArrayBuffer | undefined
|
|
19
34
|
#rawBufferPromise: Promise<ArrayBuffer> | undefined
|
|
35
|
+
#maxBodySize: number
|
|
20
36
|
|
|
21
|
-
constructor(
|
|
37
|
+
constructor(
|
|
38
|
+
private request: Request,
|
|
39
|
+
{ maxBodySize = DEFAULT_MAX_BODY_SIZE }: PikkuFetchHTTPRequestOptions = {}
|
|
40
|
+
) {
|
|
22
41
|
this.#url = new URL(request.url)
|
|
42
|
+
this.#maxBodySize = maxBodySize
|
|
23
43
|
}
|
|
24
44
|
|
|
25
45
|
public method(): HTTPMethod {
|
|
@@ -78,13 +98,72 @@ export class PikkuFetchHTTPRequest<
|
|
|
78
98
|
)
|
|
79
99
|
return this.#rawBufferPromise
|
|
80
100
|
}
|
|
81
|
-
this.#rawBufferPromise = this
|
|
101
|
+
this.#rawBufferPromise = this.#readBoundedBuffer().then((buf) => {
|
|
82
102
|
this.#rawBodyBuffer = buf
|
|
83
103
|
return buf
|
|
84
104
|
})
|
|
85
105
|
return this.#rawBufferPromise
|
|
86
106
|
}
|
|
87
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Reads the body while refusing to buffer more than `maxBodySize` bytes. The
|
|
110
|
+
* declared `content-length` is rejected up front so an oversized body is never
|
|
111
|
+
* transferred, and the stream is measured as it arrives because that header is
|
|
112
|
+
* both optional and attacker-controlled.
|
|
113
|
+
*/
|
|
114
|
+
async #readBoundedBuffer(): Promise<ArrayBuffer> {
|
|
115
|
+
const contentLength = this.request.headers.get('content-length')
|
|
116
|
+
if (contentLength !== null) {
|
|
117
|
+
const declaredSize = Number(contentLength)
|
|
118
|
+
if (Number.isFinite(declaredSize) && declaredSize > this.#maxBodySize) {
|
|
119
|
+
throw this.#payloadTooLarge()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const stream = this.request.body
|
|
124
|
+
if (stream === null) {
|
|
125
|
+
const buffer = await this.request.arrayBuffer()
|
|
126
|
+
if (buffer.byteLength > this.#maxBodySize) {
|
|
127
|
+
throw this.#payloadTooLarge()
|
|
128
|
+
}
|
|
129
|
+
return buffer
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const reader = stream.getReader()
|
|
133
|
+
const chunks: Uint8Array[] = []
|
|
134
|
+
let size = 0
|
|
135
|
+
try {
|
|
136
|
+
while (true) {
|
|
137
|
+
const { done, value } = await reader.read()
|
|
138
|
+
if (done) {
|
|
139
|
+
break
|
|
140
|
+
}
|
|
141
|
+
size += value.byteLength
|
|
142
|
+
if (size > this.#maxBodySize) {
|
|
143
|
+
await reader.cancel()
|
|
144
|
+
throw this.#payloadTooLarge()
|
|
145
|
+
}
|
|
146
|
+
chunks.push(value)
|
|
147
|
+
}
|
|
148
|
+
} finally {
|
|
149
|
+
reader.releaseLock()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const body = new Uint8Array(size)
|
|
153
|
+
let offset = 0
|
|
154
|
+
for (const chunk of chunks) {
|
|
155
|
+
body.set(chunk, offset)
|
|
156
|
+
offset += chunk.byteLength
|
|
157
|
+
}
|
|
158
|
+
return body.buffer as ArrayBuffer
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#payloadTooLarge(): PayloadTooLargeError {
|
|
162
|
+
return new PayloadTooLargeError(
|
|
163
|
+
`Request body exceeds the maximum size of ${this.#maxBodySize} bytes`
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
88
167
|
async #readRawText(): Promise<string> {
|
|
89
168
|
if (this.#rawBodyText !== undefined) {
|
|
90
169
|
return this.#rawBodyText
|
|
@@ -214,6 +293,9 @@ export class PikkuFetchHTTPRequest<
|
|
|
214
293
|
)
|
|
215
294
|
}
|
|
216
295
|
} catch (e) {
|
|
296
|
+
if (e instanceof PayloadTooLargeError) {
|
|
297
|
+
throw e
|
|
298
|
+
}
|
|
217
299
|
throw new UnprocessableContentError(`Error parsing body: ${e}`)
|
|
218
300
|
}
|
|
219
301
|
return body
|
|
@@ -329,6 +329,46 @@ describe('runMCPTool', () => {
|
|
|
329
329
|
}
|
|
330
330
|
)
|
|
331
331
|
})
|
|
332
|
+
|
|
333
|
+
test('withholds the error message and stack from internal MCP errors in production', async () => {
|
|
334
|
+
pikkuState(null, 'mcp', 'toolsMeta').prodBoom = {
|
|
335
|
+
name: 'prodBoom',
|
|
336
|
+
title: 'Prod Boom',
|
|
337
|
+
description: 'Prod Boom',
|
|
338
|
+
pikkuFuncId: 'prodBoomFunc',
|
|
339
|
+
inputSchema: null,
|
|
340
|
+
outputSchema: null,
|
|
341
|
+
} as never
|
|
342
|
+
registerFunction('prodBoomFunc', async () => {
|
|
343
|
+
throw new Error('secret internal detail')
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
const originalNodeEnv = process.env.NODE_ENV
|
|
347
|
+
process.env.NODE_ENV = 'production'
|
|
348
|
+
try {
|
|
349
|
+
await assert.rejects(
|
|
350
|
+
() =>
|
|
351
|
+
runMCPTool(
|
|
352
|
+
{ jsonrpc: '2.0', id: 'prod-boom-1', params: {} },
|
|
353
|
+
{ mcp: mcpWire as never },
|
|
354
|
+
'prodBoom'
|
|
355
|
+
),
|
|
356
|
+
(error: unknown) => {
|
|
357
|
+
assert.ok(error instanceof MCPError)
|
|
358
|
+
assert.equal(error.error.code, -32603)
|
|
359
|
+
assert.equal(error.error.message, 'Internal error')
|
|
360
|
+
assert.equal(error.error.data, undefined)
|
|
361
|
+
return true
|
|
362
|
+
}
|
|
363
|
+
)
|
|
364
|
+
} finally {
|
|
365
|
+
if (originalNodeEnv === undefined) {
|
|
366
|
+
delete process.env.NODE_ENV
|
|
367
|
+
} else {
|
|
368
|
+
process.env.NODE_ENV = originalNodeEnv
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
})
|
|
332
372
|
})
|
|
333
373
|
|
|
334
374
|
describe('runMCPPrompt', () => {
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
CorePikkuFunctionSessionless,
|
|
13
13
|
} from '../../function/functions.types.js'
|
|
14
14
|
import { getErrorResponse } from '../../errors/error-handler.js'
|
|
15
|
+
import { isProduction } from '../../env.js'
|
|
15
16
|
import { closeWireServices } from '../../utils.js'
|
|
16
17
|
import {
|
|
17
18
|
pikkuState,
|
|
@@ -35,6 +36,11 @@ export class MCPError extends Error {
|
|
|
35
36
|
|
|
36
37
|
export type RunMCPEndpointParams<Tools extends string = any> = {
|
|
37
38
|
mcp?: PikkuMCP<Tools>
|
|
39
|
+
/**
|
|
40
|
+
* Surface the error message + stack on unexpected internal errors.
|
|
41
|
+
* Defaults to enabled outside of production.
|
|
42
|
+
*/
|
|
43
|
+
exposeErrors?: boolean
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
export type JsonRpcError = {
|
|
@@ -187,7 +193,7 @@ async function runMCPPikkuFunc(
|
|
|
187
193
|
name: string,
|
|
188
194
|
mcp: CoreMCPResource | CoreMCPPrompt | undefined,
|
|
189
195
|
pikkuFuncId: string | undefined,
|
|
190
|
-
{ mcp: mcpWire }: RunMCPEndpointParams
|
|
196
|
+
{ mcp: mcpWire, exposeErrors = !isProduction() }: RunMCPEndpointParams
|
|
191
197
|
): Promise<JsonRpcResponse> {
|
|
192
198
|
const singletonServices = getSingletonServices()
|
|
193
199
|
const createWireServices = getCreateWireServices()
|
|
@@ -289,7 +295,10 @@ async function runMCPPikkuFunc(
|
|
|
289
295
|
id: request.id,
|
|
290
296
|
code: -32603,
|
|
291
297
|
message: 'Internal error',
|
|
292
|
-
data:
|
|
298
|
+
data:
|
|
299
|
+
exposeErrors && !isProduction() && e instanceof Error
|
|
300
|
+
? { message: e.message, stack: e.stack }
|
|
301
|
+
: undefined,
|
|
293
302
|
})
|
|
294
303
|
}
|
|
295
304
|
} finally {
|
|
@@ -538,8 +538,9 @@ export async function continueGraph(
|
|
|
538
538
|
return
|
|
539
539
|
}
|
|
540
540
|
|
|
541
|
-
|
|
542
|
-
|
|
541
|
+
// The same run read a few lines up: nothing between here and there writes it,
|
|
542
|
+
// and `input` is fixed at creation anyway.
|
|
543
|
+
const triggerInput = currentRun?.input
|
|
543
544
|
|
|
544
545
|
for (const fire of plan.toFire) {
|
|
545
546
|
const node = nodes[fire.logical]
|
|
@@ -1,150 +1,7 @@
|
|
|
1
1
|
import { describe, it } from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
computeEntryNodeIds,
|
|
6
|
-
validateWorkflowWiring,
|
|
7
|
-
generateMermaidDiagram,
|
|
8
|
-
} from './graph-validation.js'
|
|
9
|
-
|
|
10
|
-
describe('computeEntryNodeIds', () => {
|
|
11
|
-
it('returns nodes not referenced by any other node', () => {
|
|
12
|
-
const nodes = {
|
|
13
|
-
start: { rpcName: 'a', next: 'middle' },
|
|
14
|
-
middle: { rpcName: 'b', next: 'end' },
|
|
15
|
-
end: { rpcName: 'c' },
|
|
16
|
-
}
|
|
17
|
-
assert.deepEqual(computeEntryNodeIds(nodes), ['start'])
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
it('returns multiple entry nodes for parallel starts', () => {
|
|
21
|
-
const nodes = {
|
|
22
|
-
a: { rpcName: 'x' },
|
|
23
|
-
b: { rpcName: 'y' },
|
|
24
|
-
c: { rpcName: 'z', next: 'a' },
|
|
25
|
-
}
|
|
26
|
-
const entries = computeEntryNodeIds(nodes)
|
|
27
|
-
assert.ok(entries.includes('b'))
|
|
28
|
-
assert.ok(entries.includes('c'))
|
|
29
|
-
assert.ok(!entries.includes('a'))
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
it('returns empty for fully circular graph', () => {
|
|
33
|
-
const nodes = {
|
|
34
|
-
a: { rpcName: 'x', next: 'b' },
|
|
35
|
-
b: { rpcName: 'y', next: 'a' },
|
|
36
|
-
}
|
|
37
|
-
assert.deepEqual(computeEntryNodeIds(nodes), [])
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('handles onError references', () => {
|
|
41
|
-
const nodes = {
|
|
42
|
-
start: { rpcName: 'a', next: 'ok', onError: 'err' },
|
|
43
|
-
ok: { rpcName: 'b' },
|
|
44
|
-
err: { rpcName: 'c' },
|
|
45
|
-
}
|
|
46
|
-
assert.deepEqual(computeEntryNodeIds(nodes), ['start'])
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
it('handles branching next as object', () => {
|
|
50
|
-
const nodes = {
|
|
51
|
-
start: { rpcName: 'a', next: { yes: 'b', no: 'c' } },
|
|
52
|
-
b: { rpcName: 'x' },
|
|
53
|
-
c: { rpcName: 'y' },
|
|
54
|
-
}
|
|
55
|
-
assert.deepEqual(computeEntryNodeIds(nodes), ['start'])
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it('handles parallel next as array', () => {
|
|
59
|
-
const nodes = {
|
|
60
|
-
start: { rpcName: 'a', next: ['b', 'c'] },
|
|
61
|
-
b: { rpcName: 'x' },
|
|
62
|
-
c: { rpcName: 'y' },
|
|
63
|
-
}
|
|
64
|
-
assert.deepEqual(computeEntryNodeIds(nodes), ['start'])
|
|
65
|
-
})
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
describe('validateWorkflowWiring', () => {
|
|
69
|
-
const tools = ['toolA', 'toolB', 'toolC']
|
|
70
|
-
|
|
71
|
-
it('returns no errors for a valid workflow', () => {
|
|
72
|
-
const nodes = {
|
|
73
|
-
step1: { rpcName: 'toolA', input: {}, next: 'step2' },
|
|
74
|
-
step2: { rpcName: 'toolB', input: {} },
|
|
75
|
-
}
|
|
76
|
-
const errors = validateWorkflowWiring(nodes, tools)
|
|
77
|
-
assert.deepEqual(errors, [])
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('errors on missing rpcName', () => {
|
|
81
|
-
const nodes = {
|
|
82
|
-
step1: { input: {} },
|
|
83
|
-
}
|
|
84
|
-
const errors = validateWorkflowWiring(nodes, tools)
|
|
85
|
-
assert.ok(errors.some((e) => e.includes("missing 'rpcName'")))
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it('errors on unknown tool', () => {
|
|
89
|
-
const nodes = {
|
|
90
|
-
step1: { rpcName: 'unknownTool', input: {} },
|
|
91
|
-
}
|
|
92
|
-
const errors = validateWorkflowWiring(nodes, tools)
|
|
93
|
-
assert.ok(errors.some((e) => e.includes('unknown tool')))
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
it('errors on next referencing unknown node', () => {
|
|
97
|
-
const nodes = {
|
|
98
|
-
step1: { rpcName: 'toolA', next: 'nonexistent' },
|
|
99
|
-
}
|
|
100
|
-
const errors = validateWorkflowWiring(nodes, tools)
|
|
101
|
-
assert.ok(errors.some((e) => e.includes("unknown node 'nonexistent'")))
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
it('errors on onError referencing unknown node', () => {
|
|
105
|
-
const nodes = {
|
|
106
|
-
step1: { rpcName: 'toolA', onError: 'missing' },
|
|
107
|
-
}
|
|
108
|
-
const errors = validateWorkflowWiring(nodes, tools)
|
|
109
|
-
assert.ok(errors.some((e) => e.includes("unknown node 'missing'")))
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
it('errors on input referencing unknown node', () => {
|
|
113
|
-
const nodes = {
|
|
114
|
-
step1: {
|
|
115
|
-
rpcName: 'toolA',
|
|
116
|
-
input: { field: { $ref: 'missing', path: 'x' } },
|
|
117
|
-
},
|
|
118
|
-
}
|
|
119
|
-
const errors = validateWorkflowWiring(nodes, tools)
|
|
120
|
-
assert.ok(errors.some((e) => e.includes("unknown node 'missing'")))
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
it('allows trigger refs without errors', () => {
|
|
124
|
-
const nodes = {
|
|
125
|
-
step1: {
|
|
126
|
-
rpcName: 'toolA',
|
|
127
|
-
input: { field: { $ref: 'trigger', path: 'title' } },
|
|
128
|
-
next: 'step2',
|
|
129
|
-
},
|
|
130
|
-
step2: { rpcName: 'toolB', input: {} },
|
|
131
|
-
}
|
|
132
|
-
const errors = validateWorkflowWiring(nodes, tools)
|
|
133
|
-
assert.deepEqual(errors, [])
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
it('allows valid node-to-node refs', () => {
|
|
137
|
-
const nodes = {
|
|
138
|
-
step1: { rpcName: 'toolA', input: {}, next: 'step2' },
|
|
139
|
-
step2: {
|
|
140
|
-
rpcName: 'toolB',
|
|
141
|
-
input: { data: { $ref: 'step1', path: 'result' } },
|
|
142
|
-
},
|
|
143
|
-
}
|
|
144
|
-
const errors = validateWorkflowWiring(nodes, tools)
|
|
145
|
-
assert.deepEqual(errors, [])
|
|
146
|
-
})
|
|
147
|
-
})
|
|
4
|
+
import { generateMermaidDiagram } from './graph-validation.js'
|
|
148
5
|
|
|
149
6
|
describe('generateMermaidDiagram', () => {
|
|
150
7
|
it('generates valid mermaid output', () => {
|