@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/LICENSE +21 -0
  3. package/dist/function/function-runner.js +7 -2
  4. package/dist/handle-error.d.ts +2 -0
  5. package/dist/handle-error.js +8 -2
  6. package/dist/schema.d.ts +24 -0
  7. package/dist/schema.js +46 -0
  8. package/dist/services/in-memory-queue-service.d.ts +6 -0
  9. package/dist/services/in-memory-queue-service.js +8 -1
  10. package/dist/services/in-memory-workflow-service.d.ts +3 -5
  11. package/dist/services/in-memory-workflow-service.js +10 -19
  12. package/dist/services/workflow-service.d.ts +7 -5
  13. package/dist/types/core.types.d.ts +7 -0
  14. package/dist/wirings/ai-agent/ai-agent-agui.js +0 -8
  15. package/dist/wirings/ai-agent/ai-agent-prepare.js +1 -2
  16. package/dist/wirings/ai-agent/ai-agent.types.d.ts +0 -6
  17. package/dist/wirings/http/http-runner.d.ts +1 -1
  18. package/dist/wirings/http/http-runner.js +4 -2
  19. package/dist/wirings/http/http.types.d.ts +2 -0
  20. package/dist/wirings/http/index.d.ts +2 -1
  21. package/dist/wirings/http/index.js +1 -1
  22. package/dist/wirings/http/pikku-fetch-http-request.d.ts +11 -1
  23. package/dist/wirings/http/pikku-fetch-http-request.js +67 -3
  24. package/dist/wirings/mcp/mcp-runner.d.ts +5 -0
  25. package/dist/wirings/mcp/mcp-runner.js +5 -2
  26. package/dist/wirings/workflow/graph/graph-runner.js +3 -2
  27. package/dist/wirings/workflow/graph/graph-validation.d.ts +0 -2
  28. package/dist/wirings/workflow/graph/graph-validation.js +0 -142
  29. package/dist/wirings/workflow/graph/index.d.ts +1 -1
  30. package/dist/wirings/workflow/graph/index.js +1 -1
  31. package/dist/wirings/workflow/index.d.ts +0 -1
  32. package/dist/wirings/workflow/index.js +0 -2
  33. package/dist/wirings/workflow/pikku-workflow-service.d.ts +69 -15
  34. package/dist/wirings/workflow/pikku-workflow-service.js +260 -164
  35. package/dist/wirings/workflow/workflow.types.d.ts +1 -6
  36. package/package.json +1 -2
  37. package/src/function/function-runner.test.ts +75 -0
  38. package/src/function/function-runner.ts +15 -2
  39. package/src/gopass-secrets-removed.test.ts +51 -0
  40. package/src/handle-error.test.ts +108 -1
  41. package/src/handle-error.ts +10 -2
  42. package/src/schema.test.ts +103 -0
  43. package/src/schema.ts +51 -0
  44. package/src/services/in-memory-queue-service.test.ts +66 -1
  45. package/src/services/in-memory-queue-service.ts +13 -2
  46. package/src/services/in-memory-workflow-service.ts +12 -25
  47. package/src/services/workflow-service.ts +7 -4
  48. package/src/types/core.types.ts +7 -0
  49. package/src/wirings/ai-agent/ai-agent-agui.test.ts +0 -16
  50. package/src/wirings/ai-agent/ai-agent-agui.ts +0 -9
  51. package/src/wirings/ai-agent/ai-agent-prepare.ts +1 -2
  52. package/src/wirings/ai-agent/ai-agent.types.ts +0 -7
  53. package/src/wirings/http/http-runner.ts +4 -1
  54. package/src/wirings/http/http.types.ts +2 -0
  55. package/src/wirings/http/index.ts +5 -1
  56. package/src/wirings/http/pikku-fetch-http-request.test.ts +89 -0
  57. package/src/wirings/http/pikku-fetch-http-request.ts +85 -3
  58. package/src/wirings/mcp/mcp-runner.test.ts +40 -0
  59. package/src/wirings/mcp/mcp-runner.ts +11 -2
  60. package/src/wirings/workflow/graph/graph-runner.ts +3 -2
  61. package/src/wirings/workflow/graph/graph-validation.test.ts +1 -144
  62. package/src/wirings/workflow/graph/graph-validation.ts +0 -196
  63. package/src/wirings/workflow/graph/index.ts +1 -5
  64. package/src/wirings/workflow/index.ts +0 -6
  65. package/src/wirings/workflow/pikku-workflow-service.ts +377 -212
  66. package/src/wirings/workflow/scenario-expectations.test.ts +153 -0
  67. package/src/wirings/workflow/scenario-step.test.ts +1 -1
  68. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +1 -1
  69. package/src/wirings/workflow/workflow-dispatch-payload.test.ts +59 -0
  70. package/src/wirings/workflow/workflow-mirror.test.ts +178 -0
  71. package/src/wirings/workflow/workflow-replay-snapshot.test.ts +139 -0
  72. package/src/wirings/workflow/workflow-run-context.test.ts +177 -0
  73. package/src/wirings/workflow/workflow-run-polling.test.ts +132 -0
  74. package/src/wirings/workflow/workflow-step-ordinal.test.ts +4 -4
  75. package/src/wirings/workflow/workflow.types.ts +1 -4
  76. package/tsconfig.tsbuildinfo +1 -1
  77. 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
  }
@@ -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 ? new PikkuFetchHTTPRequest(request) : 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 { PikkuFetchHTTPRequest } from './pikku-fetch-http-request.js'
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 { UnprocessableContentError } from '../../errors/errors.js'
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(private request: Request) {
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.request.arrayBuffer().then((buf) => {
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: { message: e.message, stack: e.stack },
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
- const run = await workflowService.getRun(runId)
542
- const triggerInput = run?.input
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', () => {