@pikku/core 0.12.69 → 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 +422 -0
- package/LICENSE +21 -0
- package/README.md +34 -2
- package/dist/function/functions.types.d.ts +27 -0
- package/dist/index.d.ts +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.js +1 -1
- package/dist/pikku-state.js +1 -0
- package/dist/services/http-scenario-actors.d.ts +12 -4
- package/dist/services/http-scenario-actors.js +47 -45
- 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/index.d.ts +2 -1
- package/dist/services/index.js +1 -0
- package/dist/services/meta-service.d.ts +5 -1
- package/dist/services/meta-service.js +44 -18
- package/dist/services/scenario-actors-service.d.ts +108 -2
- package/dist/services/scenario-actors-service.js +40 -1
- package/dist/services/workflow-service.d.ts +7 -5
- package/dist/types/core.types.d.ts +28 -3
- package/dist/types/state.types.d.ts +3 -1
- package/dist/wirings/actor-flow/actor-flow.types.d.ts +1 -1
- package/dist/wirings/actor-flow/index.d.ts +1 -1
- package/dist/wirings/actor-flow/run-conversation.d.ts +10 -10
- package/dist/wirings/actor-flow/run-conversation.js +27 -27
- 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/cli/command-parser.js +11 -1
- package/dist/wirings/rpc/rpc-runner.js +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +52 -3
- package/dist/wirings/workflow/feature.d.ts +28 -0
- package/dist/wirings/workflow/feature.js +57 -0
- 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 +13 -3
- package/dist/wirings/workflow/index.js +15 -2
- package/dist/wirings/workflow/pikku-scenario-service.d.ts +121 -0
- package/dist/wirings/workflow/pikku-scenario-service.js +419 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +170 -23
- package/dist/wirings/workflow/pikku-workflow-service.js +338 -297
- package/dist/wirings/workflow/scenario-cookie-jar.d.ts +29 -0
- package/dist/wirings/workflow/scenario-cookie-jar.js +51 -0
- package/dist/wirings/workflow/scenario-poll.d.ts +20 -0
- package/dist/wirings/workflow/scenario-poll.js +25 -0
- package/dist/wirings/workflow/scenario-prose.d.ts +38 -0
- package/dist/wirings/workflow/scenario-prose.js +45 -0
- package/dist/wirings/workflow/scenario-step-guards.d.ts +16 -0
- package/dist/wirings/workflow/scenario-step-guards.js +29 -0
- package/dist/wirings/workflow/scenario-step.types.d.ts +148 -0
- package/dist/wirings/workflow/scenario-step.types.js +1 -0
- package/dist/wirings/workflow/workflow.types.d.ts +82 -8
- package/package.json +3 -1
- package/src/function/functions.types.ts +32 -0
- package/src/index.ts +1 -0
- package/src/internal.ts +5 -1
- package/src/pikku-state.ts +1 -0
- package/src/services/http-scenario-actors.test.ts +85 -1
- package/src/services/http-scenario-actors.ts +65 -51
- 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/index.ts +5 -0
- package/src/services/meta-service.test.ts +79 -0
- package/src/services/meta-service.ts +61 -26
- package/src/services/scenario-actors-service.ts +157 -2
- package/src/services/workflow-service.ts +7 -4
- package/src/types/core.types.ts +34 -2
- package/src/types/state.types.ts +3 -0
- package/src/wirings/actor-flow/actor-flow.types.ts +1 -1
- package/src/wirings/actor-flow/index.ts +1 -1
- package/src/wirings/actor-flow/run-conversation.test.ts +12 -6
- package/src/wirings/actor-flow/run-conversation.ts +36 -41
- 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/cli/command-parser.test.ts +60 -0
- package/src/wirings/cli/command-parser.ts +12 -1
- package/src/wirings/rpc/rpc-runner.test.ts +28 -5
- package/src/wirings/rpc/rpc-runner.ts +1 -1
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +86 -2
- package/src/wirings/workflow/feature.test.ts +131 -0
- package/src/wirings/workflow/feature.ts +78 -0
- 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 +73 -6
- package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +55 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +572 -419
- package/src/wirings/workflow/scenario-cookie-jar.test.ts +108 -0
- package/src/wirings/workflow/scenario-cookie-jar.ts +65 -0
- package/src/wirings/workflow/scenario-expectations.test.ts +153 -0
- package/src/wirings/workflow/scenario-hooks.test.ts +212 -0
- package/src/wirings/workflow/scenario-poll.test.ts +66 -0
- package/src/wirings/workflow/scenario-poll.ts +36 -0
- package/src/wirings/workflow/scenario-prose.test.ts +152 -0
- package/src/wirings/workflow/scenario-prose.ts +79 -0
- package/src/wirings/workflow/scenario-service.test.ts +155 -0
- package/src/wirings/workflow/scenario-step-guards.ts +43 -0
- package/src/wirings/workflow/scenario-step.test.ts +442 -9
- package/src/wirings/workflow/scenario-step.types.ts +157 -0
- 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 +99 -5
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { resolveFeatureScenarios } from './feature.js'
|
|
5
|
+
import type { CoreFeature, CoreWorkflow } from './workflow.types.js'
|
|
6
|
+
|
|
7
|
+
const scenario = (tags: string[] = []) =>
|
|
8
|
+
({ tags, func: async () => ({}) }) as any
|
|
9
|
+
|
|
10
|
+
const registered = (entries: Array<[string, any]>): Map<string, CoreWorkflow> =>
|
|
11
|
+
new Map(entries.map(([name, func]) => [name, { name, func }]))
|
|
12
|
+
|
|
13
|
+
describe('resolveFeatureScenarios', () => {
|
|
14
|
+
test('resolves imported identifiers to the names they are registered under', () => {
|
|
15
|
+
const lazyLoad = scenario()
|
|
16
|
+
const roundTrip = scenario()
|
|
17
|
+
const features = new Map<string, CoreFeature>([
|
|
18
|
+
[
|
|
19
|
+
'credentialFeature',
|
|
20
|
+
{
|
|
21
|
+
name: 'Credential API',
|
|
22
|
+
scenarios: [lazyLoad, roundTrip],
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
const { entries, unresolved } = resolveFeatureScenarios(
|
|
28
|
+
features,
|
|
29
|
+
registered([
|
|
30
|
+
['credentialLazyLoadScenario', lazyLoad],
|
|
31
|
+
['credentialRoundTripScenario', roundTrip],
|
|
32
|
+
])
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
assert.deepEqual(unresolved, [])
|
|
36
|
+
assert.deepEqual(
|
|
37
|
+
entries.map((e) => e.scenarioName),
|
|
38
|
+
['credentialLazyLoadScenario', 'credentialRoundTripScenario']
|
|
39
|
+
)
|
|
40
|
+
assert.equal(entries[0]!.featureName, 'Credential API')
|
|
41
|
+
assert.equal(entries[0]!.featureId, 'credentialFeature')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('keeps declaration order, including repeats of the same scenario', () => {
|
|
45
|
+
const roundTrip = scenario()
|
|
46
|
+
const features = new Map<string, CoreFeature>([
|
|
47
|
+
[
|
|
48
|
+
'credentialFeature',
|
|
49
|
+
{
|
|
50
|
+
name: 'Credential API',
|
|
51
|
+
scenarios: ['stripe', 'google', 'hmac-key'].map((name) => ({
|
|
52
|
+
scenario: roundTrip,
|
|
53
|
+
data: { name },
|
|
54
|
+
})),
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
const { entries } = resolveFeatureScenarios(
|
|
60
|
+
features,
|
|
61
|
+
registered([['credentialRoundTripScenario', roundTrip]])
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
assert.equal(entries.length, 3, 'a mapped loop is three separate runs')
|
|
65
|
+
assert.deepEqual(
|
|
66
|
+
entries.map((e) => e.data),
|
|
67
|
+
[{ name: 'stripe' }, { name: 'google' }, { name: 'hmac-key' }]
|
|
68
|
+
)
|
|
69
|
+
assert.ok(
|
|
70
|
+
entries.every((e) => e.scenarioName === 'credentialRoundTripScenario')
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('a bare reference carries no data', () => {
|
|
75
|
+
const lazyLoad = scenario()
|
|
76
|
+
const { entries } = resolveFeatureScenarios(
|
|
77
|
+
new Map([['f', { name: 'F', scenarios: [lazyLoad] } as CoreFeature]]),
|
|
78
|
+
registered([['lazyLoadScenario', lazyLoad]])
|
|
79
|
+
)
|
|
80
|
+
assert.equal(entries[0]!.data, undefined)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("a scenario's effective tags union its own with the feature's", () => {
|
|
84
|
+
const lazyLoad = scenario(['scenario', 'credential'])
|
|
85
|
+
const { entries } = resolveFeatureScenarios(
|
|
86
|
+
new Map([
|
|
87
|
+
[
|
|
88
|
+
'f',
|
|
89
|
+
{
|
|
90
|
+
name: 'F',
|
|
91
|
+
tags: ['nightly', 'credential'],
|
|
92
|
+
scenarios: [lazyLoad],
|
|
93
|
+
} as CoreFeature,
|
|
94
|
+
],
|
|
95
|
+
]),
|
|
96
|
+
registered([['lazyLoadScenario', lazyLoad]])
|
|
97
|
+
)
|
|
98
|
+
assert.deepEqual(entries[0]!.tags, ['scenario', 'credential', 'nightly'])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('an unregistered scenario is reported, never silently matched by shape', () => {
|
|
102
|
+
const registeredScenario = scenario()
|
|
103
|
+
const lookalike = scenario()
|
|
104
|
+
const { entries, unresolved } = resolveFeatureScenarios(
|
|
105
|
+
new Map([
|
|
106
|
+
[
|
|
107
|
+
'f',
|
|
108
|
+
{
|
|
109
|
+
name: 'F',
|
|
110
|
+
scenarios: [registeredScenario, lookalike],
|
|
111
|
+
} as CoreFeature,
|
|
112
|
+
],
|
|
113
|
+
]),
|
|
114
|
+
registered([['realScenario', registeredScenario]])
|
|
115
|
+
)
|
|
116
|
+
assert.deepEqual(
|
|
117
|
+
entries.map((e) => e.scenarioName),
|
|
118
|
+
['realScenario']
|
|
119
|
+
)
|
|
120
|
+
assert.deepEqual(unresolved, [{ featureId: 'f', index: 1 }])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('a feature with no scenarios contributes nothing', () => {
|
|
124
|
+
const { entries, unresolved } = resolveFeatureScenarios(
|
|
125
|
+
new Map([['f', { name: 'F', scenarios: [] } as CoreFeature]]),
|
|
126
|
+
registered([])
|
|
127
|
+
)
|
|
128
|
+
assert.deepEqual(entries, [])
|
|
129
|
+
assert.deepEqual(unresolved, [])
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { pikkuState } from '../../pikku-state.js'
|
|
2
|
+
import type {
|
|
3
|
+
CoreFeature,
|
|
4
|
+
CoreWorkflow,
|
|
5
|
+
FeaturePlanEntry,
|
|
6
|
+
} from './workflow.types.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Register a feature under the name it is exported as. Called from generated
|
|
10
|
+
* wiring, the same way `addWorkflow` is.
|
|
11
|
+
*/
|
|
12
|
+
export const addFeature = (
|
|
13
|
+
featureId: string,
|
|
14
|
+
feature: CoreFeature,
|
|
15
|
+
packageName: string | null = null
|
|
16
|
+
) => {
|
|
17
|
+
pikkuState(packageName, 'workflows', 'features').set(featureId, feature)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve every feature's scenario references back to the names their
|
|
22
|
+
* scenarios are registered under.
|
|
23
|
+
*
|
|
24
|
+
* Matching is by **object identity**: `pikkuScenario` returns its config
|
|
25
|
+
* verbatim and `addWorkflow` registers that same object, so a feature holding
|
|
26
|
+
* the imported identifier holds the very object that was registered. Nothing
|
|
27
|
+
* is matched by shape, by name, or by any other guess — which is also why a
|
|
28
|
+
* scenario built inline inside a feature (and therefore never registered)
|
|
29
|
+
* comes back as unresolved rather than silently running as something else.
|
|
30
|
+
*
|
|
31
|
+
* Entries are returned in declaration order, features in registration order.
|
|
32
|
+
* A scenario's effective tags are its own plus the containing feature's, so
|
|
33
|
+
* `--tags credential` selects through the feature.
|
|
34
|
+
*/
|
|
35
|
+
export const resolveFeatureScenarios = (
|
|
36
|
+
features: Map<string, CoreFeature>,
|
|
37
|
+
registrations: Map<string, CoreWorkflow>
|
|
38
|
+
): {
|
|
39
|
+
entries: FeaturePlanEntry[]
|
|
40
|
+
unresolved: Array<{ featureId: string; index: number }>
|
|
41
|
+
} => {
|
|
42
|
+
const nameByConfig = new Map<unknown, string>()
|
|
43
|
+
for (const [name, registration] of registrations) {
|
|
44
|
+
nameByConfig.set(registration.func, name)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const entries: FeaturePlanEntry[] = []
|
|
48
|
+
const unresolved: Array<{ featureId: string; index: number }> = []
|
|
49
|
+
|
|
50
|
+
for (const [featureId, feature] of features) {
|
|
51
|
+
const scenarios = feature.scenarios ?? []
|
|
52
|
+
for (let index = 0; index < scenarios.length; index++) {
|
|
53
|
+
const entry = scenarios[index]!
|
|
54
|
+
const paired =
|
|
55
|
+
typeof entry === 'object' && entry !== null && 'scenario' in entry
|
|
56
|
+
const config = paired ? (entry as any).scenario : entry
|
|
57
|
+
const scenarioName = nameByConfig.get(config)
|
|
58
|
+
if (!scenarioName) {
|
|
59
|
+
unresolved.push({ featureId, index })
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
entries.push({
|
|
63
|
+
featureId,
|
|
64
|
+
featureName: feature.name ?? featureId,
|
|
65
|
+
scenarioName,
|
|
66
|
+
data: paired ? (entry as any).data : undefined,
|
|
67
|
+
tags: [
|
|
68
|
+
...new Set([
|
|
69
|
+
...((registrations.get(scenarioName)?.func as any)?.tags ?? []),
|
|
70
|
+
...(feature.tags ?? []),
|
|
71
|
+
]),
|
|
72
|
+
],
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { entries, unresolved }
|
|
78
|
+
}
|
|
@@ -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'
|
|
@@ -11,6 +11,11 @@ export {
|
|
|
11
11
|
WorkflowApprovalResolvedError,
|
|
12
12
|
DEFAULT_STEP_RETRIES,
|
|
13
13
|
} from './pikku-workflow-service.js'
|
|
14
|
+
export type {
|
|
15
|
+
RunLifecycleContext,
|
|
16
|
+
WorkflowRunEngine,
|
|
17
|
+
WorkflowRunExtension,
|
|
18
|
+
} from './pikku-workflow-service.js'
|
|
14
19
|
export { deriveInvocationId, uuidv5 } from './workflow-invocation-id.js'
|
|
15
20
|
|
|
16
21
|
// Time-travel: reconstruct run state at any point from durable history
|
|
@@ -29,6 +34,7 @@ export type {
|
|
|
29
34
|
|
|
30
35
|
// Internal registration functions (used by generated code)
|
|
31
36
|
export { addWorkflow } from './dsl/workflow-runner.js'
|
|
37
|
+
export { addFeature, resolveFeatureScenarios } from './feature.js'
|
|
32
38
|
|
|
33
39
|
// Graph helpers (template, pikkuWorkflowGraph)
|
|
34
40
|
export { template, type TemplateString } from './graph/template.js'
|
|
@@ -38,12 +44,6 @@ export {
|
|
|
38
44
|
type PikkuWorkflowGraphResult,
|
|
39
45
|
} from './graph/wire-workflow-graph.js'
|
|
40
46
|
|
|
41
|
-
// Graph validation and dynamic workflow utilities
|
|
42
|
-
export {
|
|
43
|
-
validateWorkflowWiring,
|
|
44
|
-
computeEntryNodeIds,
|
|
45
|
-
} from './graph/graph-validation.js'
|
|
46
|
-
|
|
47
47
|
// Queue worker functions (registered by codegen, executed at runtime)
|
|
48
48
|
export {
|
|
49
49
|
pikkuWorkflowWorkerFunc,
|
|
@@ -72,6 +72,12 @@ export type {
|
|
|
72
72
|
WorkflowRunService,
|
|
73
73
|
WorkflowRunMirror,
|
|
74
74
|
CoreWorkflow,
|
|
75
|
+
CoreFeature,
|
|
76
|
+
CoreFeatureScenario,
|
|
77
|
+
FeatureMeta,
|
|
78
|
+
FeatureMetaEntry,
|
|
79
|
+
FeaturesMeta,
|
|
80
|
+
FeaturePlanEntry,
|
|
75
81
|
PikkuWorkflow,
|
|
76
82
|
ContextVariable,
|
|
77
83
|
WorkflowContext,
|
|
@@ -113,8 +119,69 @@ export type {
|
|
|
113
119
|
SwitchStepMeta,
|
|
114
120
|
FilterStepMeta,
|
|
115
121
|
ArrayPredicateStepMeta,
|
|
122
|
+
ScenarioStepInvocation,
|
|
123
|
+
ScenarioStepMeta,
|
|
116
124
|
WorkflowStepMeta,
|
|
117
125
|
WorkflowStepWire,
|
|
118
126
|
PikkuWorkflowWire,
|
|
119
127
|
PikkuScenarioWire,
|
|
120
128
|
} from './workflow.types.js'
|
|
129
|
+
|
|
130
|
+
/* ------------------------------------------------------------------ *
|
|
131
|
+
* Scenarios — writing a step
|
|
132
|
+
*
|
|
133
|
+
* What a step file imports. Everything here is reached from inside a
|
|
134
|
+
* `pikkuScenarioStep` body.
|
|
135
|
+
* ------------------------------------------------------------------ */
|
|
136
|
+
|
|
137
|
+
// The step's own wire, and the shape of what it was given
|
|
138
|
+
export type {
|
|
139
|
+
ScenarioStepPhase,
|
|
140
|
+
ScenarioStepOptions,
|
|
141
|
+
PikkuScenarioStepWire,
|
|
142
|
+
ScenarioEnvironment,
|
|
143
|
+
} from './scenario-step.types.js'
|
|
144
|
+
|
|
145
|
+
// Narrows the optional halves of the step wire, with a message that says what to do
|
|
146
|
+
export { requireActor, requireScenarioEnv } from './scenario-step-guards.js'
|
|
147
|
+
|
|
148
|
+
// Waits for the target to catch up, without every step writing the loop again
|
|
149
|
+
export { pollUntil, type PollOptions } from './scenario-poll.js'
|
|
150
|
+
|
|
151
|
+
// Persists cookies across a step's requests, the way a browser would
|
|
152
|
+
export { createCookieJar } from './scenario-cookie-jar.js'
|
|
153
|
+
export type { ScenarioCookieJar } from './scenario-cookie-jar.js'
|
|
154
|
+
|
|
155
|
+
// What the transport answered — an actor's `invokeRaw`, or a route reached
|
|
156
|
+
// directly — and the one way to POST JSON at a route and keep its status
|
|
157
|
+
export type {
|
|
158
|
+
ScenarioHttpResponse,
|
|
159
|
+
ScenarioJsonRequest,
|
|
160
|
+
} from '../../services/scenario-actors-service.js'
|
|
161
|
+
export {
|
|
162
|
+
readScenarioHttpResponse,
|
|
163
|
+
postScenarioJson,
|
|
164
|
+
} from '../../services/scenario-actors-service.js'
|
|
165
|
+
|
|
166
|
+
/* ------------------------------------------------------------------ *
|
|
167
|
+
* Scenarios — driving a browser
|
|
168
|
+
*
|
|
169
|
+
* How a step names an element, and what a driver package
|
|
170
|
+
* (`@pikku/playwright`, or another) implements.
|
|
171
|
+
* ------------------------------------------------------------------ */
|
|
172
|
+
|
|
173
|
+
export type {
|
|
174
|
+
PikkuBrowserWire,
|
|
175
|
+
TestIdSelector,
|
|
176
|
+
ScenarioBrowserProvider,
|
|
177
|
+
ScenarioBrowserFailure,
|
|
178
|
+
} from './scenario-step.types.js'
|
|
179
|
+
|
|
180
|
+
/* ------------------------------------------------------------------ *
|
|
181
|
+
* Scenarios — reporting a run
|
|
182
|
+
*
|
|
183
|
+
* Used by the CLI reporter and the console, so the two render the same
|
|
184
|
+
* sentence for the same step. A step body needs none of it.
|
|
185
|
+
* ------------------------------------------------------------------ */
|
|
186
|
+
|
|
187
|
+
export { composeStepProse, renderStepTemplate } from './scenario-prose.js'
|