@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.
- package/CHANGELOG.md +88 -0
- package/LICENSE +21 -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/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 -1
- 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/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
|
@@ -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' | '
|
|
365
|
+
source: 'dsl' | 'complex' | 'graph' | 'scenario';
|
|
371
366
|
/** Optional description */
|
|
372
367
|
description?: string;
|
|
373
368
|
/** Tags for organization */
|
package/package.json
CHANGED
|
@@ -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] = {
|
|
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(
|
|
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
|
}
|
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
|
|
@@ -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', () => {
|
|
@@ -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'
|