@pikku/core 0.12.70 → 0.12.71

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 (48) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/LICENSE +21 -0
  3. package/dist/services/in-memory-queue-service.d.ts +6 -0
  4. package/dist/services/in-memory-queue-service.js +8 -1
  5. package/dist/services/in-memory-workflow-service.d.ts +3 -5
  6. package/dist/services/in-memory-workflow-service.js +10 -19
  7. package/dist/services/workflow-service.d.ts +7 -5
  8. package/dist/types/core.types.d.ts +7 -0
  9. package/dist/wirings/ai-agent/ai-agent-agui.js +0 -8
  10. package/dist/wirings/ai-agent/ai-agent-prepare.js +1 -2
  11. package/dist/wirings/ai-agent/ai-agent.types.d.ts +0 -6
  12. package/dist/wirings/workflow/graph/graph-runner.js +3 -2
  13. package/dist/wirings/workflow/graph/graph-validation.d.ts +0 -2
  14. package/dist/wirings/workflow/graph/graph-validation.js +0 -142
  15. package/dist/wirings/workflow/graph/index.d.ts +1 -1
  16. package/dist/wirings/workflow/graph/index.js +1 -1
  17. package/dist/wirings/workflow/index.d.ts +0 -1
  18. package/dist/wirings/workflow/index.js +0 -2
  19. package/dist/wirings/workflow/pikku-workflow-service.d.ts +69 -15
  20. package/dist/wirings/workflow/pikku-workflow-service.js +260 -164
  21. package/dist/wirings/workflow/workflow.types.d.ts +1 -6
  22. package/package.json +1 -1
  23. package/src/services/in-memory-queue-service.test.ts +66 -1
  24. package/src/services/in-memory-queue-service.ts +13 -2
  25. package/src/services/in-memory-workflow-service.ts +12 -25
  26. package/src/services/workflow-service.ts +7 -4
  27. package/src/types/core.types.ts +7 -0
  28. package/src/wirings/ai-agent/ai-agent-agui.test.ts +0 -16
  29. package/src/wirings/ai-agent/ai-agent-agui.ts +0 -9
  30. package/src/wirings/ai-agent/ai-agent-prepare.ts +1 -2
  31. package/src/wirings/ai-agent/ai-agent.types.ts +0 -7
  32. package/src/wirings/workflow/graph/graph-runner.ts +3 -2
  33. package/src/wirings/workflow/graph/graph-validation.test.ts +1 -144
  34. package/src/wirings/workflow/graph/graph-validation.ts +0 -196
  35. package/src/wirings/workflow/graph/index.ts +1 -5
  36. package/src/wirings/workflow/index.ts +0 -6
  37. package/src/wirings/workflow/pikku-workflow-service.ts +377 -212
  38. package/src/wirings/workflow/scenario-expectations.test.ts +153 -0
  39. package/src/wirings/workflow/scenario-step.test.ts +1 -1
  40. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +1 -1
  41. package/src/wirings/workflow/workflow-dispatch-payload.test.ts +59 -0
  42. package/src/wirings/workflow/workflow-mirror.test.ts +178 -0
  43. package/src/wirings/workflow/workflow-replay-snapshot.test.ts +139 -0
  44. package/src/wirings/workflow/workflow-run-context.test.ts +177 -0
  45. package/src/wirings/workflow/workflow-run-polling.test.ts +132 -0
  46. package/src/wirings/workflow/workflow-step-ordinal.test.ts +4 -4
  47. package/src/wirings/workflow/workflow.types.ts +1 -4
  48. package/tsconfig.tsbuildinfo +1 -1
@@ -182,11 +182,6 @@ export interface WorkflowRunService {
182
182
  graph: any;
183
183
  source: string;
184
184
  } | null>;
185
- getAIGeneratedWorkflows(agentName?: string): Promise<Array<{
186
- workflowName: string;
187
- graphHash: string;
188
- graph: any;
189
- }>>;
190
185
  deleteRun(id: string): Promise<boolean>;
191
186
  }
192
187
  /**
@@ -367,7 +362,7 @@ export interface WorkflowRuntimeMeta {
367
362
  /** Pikku function name (for execution) */
368
363
  pikkuFuncId: string;
369
364
  /** Source type: 'dsl' (serializable), 'complex' (has inline steps), 'graph', 'scenario' (complex + actor steps) */
370
- source: 'dsl' | 'complex' | 'graph' | 'dynamic-workflow' | 'scenario';
365
+ source: 'dsl' | 'complex' | 'graph' | 'scenario';
371
366
  /** Optional description */
372
367
  description?: string;
373
368
  /** Tags for organization */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.70",
3
+ "version": "0.12.71",
4
4
  "description": "The Pikku runtime — functions, wirings, services, middleware and types",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -23,7 +23,10 @@ const registerWorker = (
23
23
  ) => {
24
24
  const calls = { count: 0 }
25
25
  const funcId = `queue_${queueName}`
26
- pikkuState(null, 'queue', 'meta')[queueName] = { pikkuFuncId: funcId, name: queueName }
26
+ pikkuState(null, 'queue', 'meta')[queueName] = {
27
+ pikkuFuncId: funcId,
28
+ name: queueName,
29
+ }
27
30
  pikkuState(null, 'function', 'meta')[funcId] = {
28
31
  pikkuFuncId: funcId,
29
32
  inputSchemaName: null,
@@ -55,6 +58,68 @@ const waitUntil = async (
55
58
  return predicate()
56
59
  }
57
60
 
61
+ // Register a queue worker that records the payload each delivery carried.
62
+ const registerRecordingWorker = (queueName: string) => {
63
+ const received: any[] = []
64
+ const funcId = `queue_${queueName}`
65
+ pikkuState(null, 'queue', 'meta')[queueName] = {
66
+ pikkuFuncId: funcId,
67
+ name: queueName,
68
+ }
69
+ pikkuState(null, 'function', 'meta')[funcId] = {
70
+ pikkuFuncId: funcId,
71
+ inputSchemaName: null,
72
+ outputSchemaName: null,
73
+ sessionless: true,
74
+ } as any
75
+ wireQueueWorker({
76
+ name: queueName,
77
+ func: {
78
+ auth: false,
79
+ func: async (_services: any, data: any) => {
80
+ received.push(data)
81
+ },
82
+ },
83
+ } as any)
84
+ return received
85
+ }
86
+
87
+ describe('InMemoryQueueService payload isolation', () => {
88
+ test('a job carries the payload as it was when added', async () => {
89
+ const received = registerRecordingWorker('mutated')
90
+ const queue = new InMemoryQueueService()
91
+ const payload = { items: ['a'] }
92
+
93
+ await queue.add('mutated', payload, { delay: 0 })
94
+ payload.items.push('b')
95
+
96
+ assert.equal(await waitUntil(() => received.length === 1), true)
97
+ assert.deepEqual(
98
+ received[0].items,
99
+ ['a'],
100
+ 'the worker saw a mutation made after the job was queued — a real queue would have serialised it away'
101
+ )
102
+ })
103
+
104
+ test('a payload arrives shaped the way a real queue would deliver it', async () => {
105
+ const received = registerRecordingWorker('serialised')
106
+ const queue = new InMemoryQueueService()
107
+
108
+ await queue.add(
109
+ 'serialised',
110
+ { when: new Date('2020-01-01T00:00:00.000Z') },
111
+ { delay: 0 }
112
+ )
113
+
114
+ assert.equal(await waitUntil(() => received.length === 1), true)
115
+ assert.equal(
116
+ received[0].when,
117
+ '2020-01-01T00:00:00.000Z',
118
+ 'a Date survived as a Date, so dev behaviour diverges from every real queue backend'
119
+ )
120
+ })
121
+ })
122
+
58
123
  describe('InMemoryQueueService retry', () => {
59
124
  test('redelivers a transiently-failing job until it succeeds', async () => {
60
125
  const calls = registerWorker('flaky', (count) => {
@@ -11,6 +11,12 @@ import { runQueueJob } from '../wirings/queue/queue-runner.js'
11
11
  * a real queue — and redelivers a failed job up to `options.attempts` times with
12
12
  * backoff, so a transiently-failing workflow step recovers exactly as it would
13
13
  * on pg-boss/bullmq instead of being silently dropped on its first error.
14
+ *
15
+ * Payloads are JSON round-tripped on the way in, because every real backend
16
+ * puts the job on a wire (SQS body, Redis value, jsonb column) and the worker
17
+ * therefore never sees the caller's live object. Doing it here keeps dev
18
+ * behaviour honest, and keeps the callers — who cannot know which backend they
19
+ * are talking to — from having to serialise defensively.
14
20
  */
15
21
  export class InMemoryQueueService implements QueueService {
16
22
  readonly supportsResults = false
@@ -25,13 +31,15 @@ export class InMemoryQueueService implements QueueService {
25
31
  const maxAttempts = Math.max(1, options?.attempts ?? 1)
26
32
  let attemptsMade = 0
27
33
  const createdAt = new Date()
34
+ const payload: T =
35
+ data === undefined ? data : (JSON.parse(JSON.stringify(data)) as T)
28
36
 
29
37
  const runAttempt = async () => {
30
38
  attemptsMade++
31
39
  const job: QueueJob<T> = {
32
40
  id: jobId,
33
41
  queueName,
34
- data,
42
+ data: payload,
35
43
  status: () => 'active',
36
44
  metadata: () => ({ attemptsMade, maxAttempts, createdAt }),
37
45
  pikkuUserId: options?.pikkuUserId,
@@ -41,7 +49,10 @@ export class InMemoryQueueService implements QueueService {
41
49
  } catch (e: any) {
42
50
  if (attemptsMade < maxAttempts) {
43
51
  // Transient failure — redeliver with backoff, mirroring a real queue.
44
- setTimeout(runAttempt, this.backoffDelay(options?.backoff, attemptsMade))
52
+ setTimeout(
53
+ runAttempt,
54
+ this.backoffDelay(options?.backoff, attemptsMade)
55
+ )
45
56
  } else {
46
57
  console.error(
47
58
  `[InMemoryQueue] Job ${jobId} on ${queueName} failed after ${attemptsMade} attempt(s):`,
@@ -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
@@ -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', () => {
@@ -1,6 +1,3 @@
1
- import { pikkuState } from '../../../pikku-state.js'
2
- import { resolveNamespace } from '../../rpc/rpc-runner.js'
3
-
4
1
  function normalizeTargets(value: unknown): string[] {
5
2
  if (!value) return []
6
3
  if (typeof value === 'string') return [value]
@@ -15,199 +12,6 @@ function normalizeTargets(value: unknown): string[] {
15
12
  return []
16
13
  }
17
14
 
18
- function collectRefs(value: unknown, refs: Set<string>): void {
19
- if (
20
- typeof value === 'object' &&
21
- value !== null &&
22
- '$ref' in value &&
23
- typeof (value as any).$ref === 'string'
24
- ) {
25
- refs.add((value as any).$ref)
26
- return
27
- }
28
- if (Array.isArray(value)) {
29
- for (const item of value) collectRefs(item, refs)
30
- return
31
- }
32
- if (typeof value === 'object' && value !== null) {
33
- for (const v of Object.values(value)) collectRefs(v, refs)
34
- }
35
- }
36
-
37
- function resolveToolMeta(toolName: string): {
38
- fnMeta: any
39
- schemas: Map<string, any>
40
- } | null {
41
- const resolved = toolName.includes(':') ? resolveNamespace(toolName) : null
42
-
43
- if (resolved) {
44
- const fnMeta = pikkuState(resolved.package, 'function', 'meta')[
45
- resolved.function
46
- ]
47
- const schemas = pikkuState(resolved.package, 'misc', 'schemas')
48
- return fnMeta ? { fnMeta, schemas } : null
49
- }
50
-
51
- const rpcMeta = pikkuState(null, 'rpc', 'meta')
52
- const pikkuFuncId = rpcMeta[toolName]
53
- if (!pikkuFuncId) return null
54
-
55
- const fnMeta = pikkuState(null, 'function', 'meta')[pikkuFuncId]
56
- const schemas = pikkuState(null, 'misc', 'schemas')
57
- return fnMeta ? { fnMeta, schemas } : null
58
- }
59
-
60
- export function computeEntryNodeIds(nodes: Record<string, any>): string[] {
61
- const referenced = new Set<string>()
62
- for (const node of Object.values(nodes)) {
63
- if (node.next) {
64
- for (const target of normalizeTargets(node.next)) {
65
- referenced.add(target)
66
- }
67
- }
68
- if (node.onError) {
69
- for (const target of normalizeTargets(node.onError)) {
70
- referenced.add(target)
71
- }
72
- }
73
- }
74
- return Object.keys(nodes).filter((id) => !referenced.has(id))
75
- }
76
-
77
- export function validateWorkflowWiring(
78
- nodes: Record<string, any>,
79
- toolNames: string[]
80
- ): string[] {
81
- const errors: string[] = []
82
- const nodeIds = new Set(Object.keys(nodes))
83
- const toolSet = new Set(toolNames)
84
-
85
- for (const [nodeId, node] of Object.entries(nodes)) {
86
- if (!node.rpcName) {
87
- errors.push(`Node '${nodeId}' is missing 'rpcName'`)
88
- continue
89
- }
90
-
91
- if (!toolSet.has(node.rpcName)) {
92
- errors.push(
93
- `Node '${nodeId}' references unknown tool '${node.rpcName}'. Available tools: ${toolNames.join(', ')}`
94
- )
95
- continue
96
- }
97
-
98
- const nextTargets = normalizeTargets(node.next)
99
- for (const target of nextTargets) {
100
- if (!nodeIds.has(target)) {
101
- errors.push(
102
- `Node '${nodeId}' routes to unknown node '${target}' in 'next'`
103
- )
104
- }
105
- }
106
-
107
- const errorTargets = normalizeTargets(node.onError)
108
- for (const target of errorTargets) {
109
- if (!nodeIds.has(target)) {
110
- errors.push(
111
- `Node '${nodeId}' routes to unknown node '${target}' in 'onError'`
112
- )
113
- }
114
- }
115
-
116
- if (!node.input) continue
117
-
118
- const refs = new Set<string>()
119
- collectRefs(node.input, refs)
120
-
121
- for (const ref of refs) {
122
- if (ref === 'trigger' || ref === '$item') {
123
- const targetMeta = resolveToolMeta(node.rpcName)
124
- if (targetMeta?.fnMeta?.inputSchemaName) {
125
- const targetSchema = targetMeta.schemas.get(
126
- targetMeta.fnMeta.inputSchemaName
127
- )
128
- if (targetSchema?.properties) {
129
- for (const [field, fieldValue] of Object.entries(
130
- node.input as Record<string, any>
131
- )) {
132
- if (
133
- typeof fieldValue === 'object' &&
134
- fieldValue !== null &&
135
- fieldValue.$ref === ref &&
136
- !fieldValue.path
137
- ) {
138
- const targetType = targetSchema.properties[field]?.type
139
- if (targetType && targetType !== 'object') {
140
- errors.push(
141
- `Node '${nodeId}' input field '${field}' expects type '${targetType}', but references the whole ${ref} object without a path. Use { $ref: "${ref}", path: "${field}" } to extract the field.`
142
- )
143
- }
144
- }
145
- }
146
- }
147
- }
148
- continue
149
- }
150
-
151
- if (!nodeIds.has(ref)) {
152
- errors.push(
153
- `Node '${nodeId}' references unknown node '${ref}' in input`
154
- )
155
- continue
156
- }
157
-
158
- const sourceNode = nodes[ref]
159
- if (!sourceNode?.rpcName) continue
160
-
161
- const sourceMeta = resolveToolMeta(sourceNode.rpcName)
162
- if (!sourceMeta?.fnMeta?.outputSchemaName) continue
163
-
164
- const sourceSchema = sourceMeta.schemas.get(
165
- sourceMeta.fnMeta.outputSchemaName
166
- )
167
- if (!sourceSchema?.properties) continue
168
-
169
- for (const [field, fieldValue] of Object.entries(
170
- node.input as Record<string, any>
171
- )) {
172
- if (
173
- typeof fieldValue === 'object' &&
174
- fieldValue !== null &&
175
- fieldValue.$ref === ref &&
176
- fieldValue.path
177
- ) {
178
- const pathRoot = fieldValue.path.split('.')[0]
179
- if (
180
- sourceSchema.properties &&
181
- !(pathRoot in sourceSchema.properties)
182
- ) {
183
- errors.push(
184
- `Node '${nodeId}' input field '${field}' references path '${fieldValue.path}' but node '${ref}' (${sourceNode.rpcName}) output has no property '${pathRoot}'`
185
- )
186
- continue
187
- }
188
-
189
- const targetMeta = resolveToolMeta(node.rpcName)
190
- if (!targetMeta?.fnMeta?.inputSchemaName) continue
191
- const targetSchema = targetMeta.schemas.get(
192
- targetMeta.fnMeta.inputSchemaName
193
- )
194
- if (!targetSchema?.properties?.[field]) continue
195
-
196
- const sourceType = sourceSchema.properties[pathRoot]?.type
197
- const targetType = targetSchema.properties[field].type
198
- if (sourceType && targetType && sourceType !== targetType) {
199
- errors.push(
200
- `Node '${nodeId}' input field '${field}' expects type '${targetType}', but node '${ref}' output field '${pathRoot}' is type '${sourceType}'`
201
- )
202
- }
203
- }
204
- }
205
- }
206
- }
207
-
208
- return errors
209
- }
210
-
211
15
  export function generateMermaidDiagram(
212
16
  workflowName: string,
213
17
  nodes: Record<string, any>,
@@ -12,8 +12,4 @@ export {
12
12
  type PikkuWorkflowGraphConfig,
13
13
  type PikkuWorkflowGraphResult,
14
14
  } from './wire-workflow-graph.js'
15
- export {
16
- validateWorkflowWiring,
17
- computeEntryNodeIds,
18
- generateMermaidDiagram,
19
- } from './graph-validation.js'
15
+ export { generateMermaidDiagram } from './graph-validation.js'