@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
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
|
|
5
|
+
import { pikkuState } from '../../pikku-state.js'
|
|
6
|
+
|
|
7
|
+
const silentLogger = { error() {}, info() {}, warn() {}, debug() {} }
|
|
8
|
+
|
|
9
|
+
function service() {
|
|
10
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
11
|
+
queueService: { add: async () => {} },
|
|
12
|
+
logger: silentLogger,
|
|
13
|
+
} as any)
|
|
14
|
+
return new InMemoryWorkflowService() as any
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const newRun = (ws: any, inline = false) =>
|
|
18
|
+
ws.createRun('flow', {}, inline, 'hash', { type: 'test' })
|
|
19
|
+
|
|
20
|
+
const trackDetaches = (ws: any): string[] => {
|
|
21
|
+
const detached: string[] = []
|
|
22
|
+
ws.setRunExtension(() => ({
|
|
23
|
+
attachRunContext: async () => {},
|
|
24
|
+
detachRunContext: (runId: string) => detached.push(runId),
|
|
25
|
+
decorateRunWire: () => {},
|
|
26
|
+
decorateWorkflowWire: () => {},
|
|
27
|
+
onBeforeRunFunc: async () => {},
|
|
28
|
+
onAfterRunFunc: async () => {},
|
|
29
|
+
}))
|
|
30
|
+
return detached
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A long-lived process orchestrates many runs. Anything it keeps per run has to
|
|
35
|
+
* be released when that run stops executing here, or the engine grows for the
|
|
36
|
+
* life of the deployment.
|
|
37
|
+
*/
|
|
38
|
+
describe('per-run state is released when the run stops executing here', () => {
|
|
39
|
+
test('a finished replay leaves nothing behind', async () => {
|
|
40
|
+
const ws = service()
|
|
41
|
+
const runId = await newRun(ws)
|
|
42
|
+
|
|
43
|
+
await ws.beginReplay(runId)
|
|
44
|
+
await ws.inlineStep(runId, 'a', async () => 1)
|
|
45
|
+
ws.endReplay(runId)
|
|
46
|
+
|
|
47
|
+
assert.equal(
|
|
48
|
+
ws.runContexts.size,
|
|
49
|
+
0,
|
|
50
|
+
'the replay held on to its ordinals, lineage or step snapshot'
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('the step the walk last reached does not survive its replay', async () => {
|
|
55
|
+
const ws = service()
|
|
56
|
+
const runId = await newRun(ws)
|
|
57
|
+
|
|
58
|
+
await ws.beginReplay(runId)
|
|
59
|
+
await ws.inlineStep(runId, 'a', async () => 1)
|
|
60
|
+
ws.endReplay(runId)
|
|
61
|
+
|
|
62
|
+
assert.equal(ws.lastStepName(runId), undefined)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('unregistering an inline run releases it', async () => {
|
|
66
|
+
const ws = service()
|
|
67
|
+
const runId = await newRun(ws)
|
|
68
|
+
|
|
69
|
+
ws.registerInlineRun(runId)
|
|
70
|
+
assert.equal(ws.isInline(runId), true)
|
|
71
|
+
|
|
72
|
+
ws.unregisterInlineRun(runId)
|
|
73
|
+
assert.equal(ws.isInline(runId), false)
|
|
74
|
+
assert.equal(ws.runContexts.size, 0)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('a replay inside an inline run does not release the inline flag', async () => {
|
|
78
|
+
const ws = service()
|
|
79
|
+
const runId = await newRun(ws)
|
|
80
|
+
|
|
81
|
+
ws.registerInlineRun(runId)
|
|
82
|
+
await ws.beginReplay(runId)
|
|
83
|
+
ws.endReplay(runId)
|
|
84
|
+
|
|
85
|
+
assert.equal(
|
|
86
|
+
ws.isInline(runId),
|
|
87
|
+
true,
|
|
88
|
+
'ending a replay dropped state the run still needs'
|
|
89
|
+
)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test("a queued run's extension context is released when the run reaches a terminal state", async () => {
|
|
93
|
+
const ws = service()
|
|
94
|
+
const runId = await newRun(ws)
|
|
95
|
+
const detached = trackDetaches(ws)
|
|
96
|
+
|
|
97
|
+
await ws.updateRunStatus(runId, 'completed', null)
|
|
98
|
+
|
|
99
|
+
assert.deepEqual(
|
|
100
|
+
detached,
|
|
101
|
+
[runId],
|
|
102
|
+
'an extension holds live per-run state; a completed run must not keep it'
|
|
103
|
+
)
|
|
104
|
+
assert.equal(ws.runContexts.size, 0)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('a suspended run keeps its extension context, because it can still be resumed', async () => {
|
|
108
|
+
const ws = service()
|
|
109
|
+
const runId = await newRun(ws)
|
|
110
|
+
const detached = trackDetaches(ws)
|
|
111
|
+
|
|
112
|
+
await ws.updateRunStatus(runId, 'suspended')
|
|
113
|
+
|
|
114
|
+
assert.deepEqual(detached, [])
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('a replay reads the run once', () => {
|
|
119
|
+
test('the steps of a replay share one read of the run', async () => {
|
|
120
|
+
const ws = service()
|
|
121
|
+
const runId = await newRun(ws)
|
|
122
|
+
|
|
123
|
+
let reads = 0
|
|
124
|
+
const getRun = ws.getRun.bind(ws)
|
|
125
|
+
ws.getRun = async (id: string) => {
|
|
126
|
+
reads++
|
|
127
|
+
return getRun(id)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await ws.beginReplay(runId)
|
|
131
|
+
await ws.getRunIdentity(runId)
|
|
132
|
+
await ws.getRunIdentity(runId)
|
|
133
|
+
await ws.getRunIdentity(runId)
|
|
134
|
+
ws.endReplay(runId)
|
|
135
|
+
|
|
136
|
+
assert.equal(reads, 1, `the run was read ${reads} times in one replay`)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('the next replay re-reads the run rather than trusting the last one', async () => {
|
|
140
|
+
const ws = service()
|
|
141
|
+
const runId = await newRun(ws)
|
|
142
|
+
|
|
143
|
+
let reads = 0
|
|
144
|
+
const getRun = ws.getRun.bind(ws)
|
|
145
|
+
ws.getRun = async (id: string) => {
|
|
146
|
+
reads++
|
|
147
|
+
return getRun(id)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await ws.beginReplay(runId)
|
|
151
|
+
await ws.getRunIdentity(runId)
|
|
152
|
+
ws.endReplay(runId)
|
|
153
|
+
await ws.beginReplay(runId)
|
|
154
|
+
await ws.getRunIdentity(runId)
|
|
155
|
+
ws.endReplay(runId)
|
|
156
|
+
|
|
157
|
+
assert.equal(reads, 2)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('outside a replay the run is read every time', async () => {
|
|
161
|
+
const ws = service()
|
|
162
|
+
const runId = await newRun(ws)
|
|
163
|
+
|
|
164
|
+
let reads = 0
|
|
165
|
+
const getRun = ws.getRun.bind(ws)
|
|
166
|
+
ws.getRun = async (id: string) => {
|
|
167
|
+
reads++
|
|
168
|
+
return getRun(id)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
await ws.getRunIdentity(runId)
|
|
172
|
+
await ws.getRunIdentity(runId)
|
|
173
|
+
|
|
174
|
+
assert.equal(reads, 2, 'a cached run outside a replay would go stale')
|
|
175
|
+
assert.equal(ws.runContexts.size, 0, 'reading a run must not create state')
|
|
176
|
+
})
|
|
177
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
|
|
5
|
+
import { pikkuState } from '../../pikku-state.js'
|
|
6
|
+
import type { WorkflowRun } from './workflow.types.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A workflow service whose run reads are counted and timestamped, and whose
|
|
10
|
+
* run finishes only once it has been read `endAfter` times.
|
|
11
|
+
*/
|
|
12
|
+
class PollProbe extends InMemoryWorkflowService {
|
|
13
|
+
readonly reads: number[] = []
|
|
14
|
+
/** Every wait the loop asked for, in order — the schedule, not the clock. */
|
|
15
|
+
readonly waits: number[] = []
|
|
16
|
+
endAfter = 1
|
|
17
|
+
|
|
18
|
+
override async waitBeforeNextRead(ms: number): Promise<void> {
|
|
19
|
+
this.waits.push(ms)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
override async startWorkflow(): Promise<{ runId: string }> {
|
|
23
|
+
return { runId: 'run-1' }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
override async getRun(_runId: string): Promise<WorkflowRun | null> {
|
|
27
|
+
this.reads.push(Date.now())
|
|
28
|
+
const done = this.reads.length >= this.endAfter
|
|
29
|
+
return {
|
|
30
|
+
id: 'run-1',
|
|
31
|
+
status: done ? 'completed' : 'running',
|
|
32
|
+
output: 'result',
|
|
33
|
+
} as WorkflowRun
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const probe = () => {
|
|
38
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
39
|
+
logger: { error() {}, info() {}, warn() {}, debug() {} },
|
|
40
|
+
} as any)
|
|
41
|
+
return new PollProbe()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('waiting for a run to finish', () => {
|
|
45
|
+
test('a run that finishes quickly is not held for a whole poll interval', async () => {
|
|
46
|
+
const ws = probe()
|
|
47
|
+
ws.endAfter = 2
|
|
48
|
+
|
|
49
|
+
const output = await ws.runToCompletion('flow', {}, {} as any)
|
|
50
|
+
|
|
51
|
+
assert.equal(output, 'result')
|
|
52
|
+
assert.equal(ws.waits.length, 1)
|
|
53
|
+
assert.ok(
|
|
54
|
+
ws.waits[0]! < 1000,
|
|
55
|
+
`a run done after one poll waited the full ${ws.waits[0]}ms default — a fixed interval makes every fast workflow pay for it`
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('reads spread out as a run keeps running', async () => {
|
|
60
|
+
const ws = probe()
|
|
61
|
+
ws.endAfter = 8
|
|
62
|
+
|
|
63
|
+
await ws.runToCompletion('flow', {}, {} as any)
|
|
64
|
+
|
|
65
|
+
assert.ok(ws.waits.length >= 6)
|
|
66
|
+
assert.ok(
|
|
67
|
+
ws.waits.at(-1)! > ws.waits[0]! * 2,
|
|
68
|
+
`waits stayed flat (${ws.waits.join('ms, ')}ms) — a run that drags on keeps costing the same read rate forever`
|
|
69
|
+
)
|
|
70
|
+
for (let i = 1; i < ws.waits.length; i++) {
|
|
71
|
+
assert.ok(
|
|
72
|
+
ws.waits[i]! >= ws.waits[i - 1]!,
|
|
73
|
+
`wait ${i} shrank (${ws.waits.join('ms, ')}ms)`
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('the wait never grows past the configured interval', async () => {
|
|
79
|
+
const ws = probe()
|
|
80
|
+
ws.endAfter = 12
|
|
81
|
+
|
|
82
|
+
await ws.runToCompletion('flow', {}, {} as any, { pollIntervalMs: 40 })
|
|
83
|
+
|
|
84
|
+
for (const wait of ws.waits) {
|
|
85
|
+
assert.ok(wait <= 40, `a wait of ${wait}ms overshot the 40ms ceiling`)
|
|
86
|
+
}
|
|
87
|
+
assert.equal(
|
|
88
|
+
ws.waits.at(-1),
|
|
89
|
+
40,
|
|
90
|
+
`the ceiling was never reached (${ws.waits.join('ms, ')}ms)`
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('a run that is already finished is returned without any wait', async () => {
|
|
95
|
+
const ws = probe()
|
|
96
|
+
ws.endAfter = 1
|
|
97
|
+
|
|
98
|
+
await ws.runToCompletion('flow', {}, {} as any)
|
|
99
|
+
|
|
100
|
+
assert.equal(ws.reads.length, 1)
|
|
101
|
+
assert.deepEqual(ws.waits, [])
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('a run that ends badly', () => {
|
|
106
|
+
test('a failed run surfaces its error message', async () => {
|
|
107
|
+
const ws = probe()
|
|
108
|
+
ws.getRun = async () =>
|
|
109
|
+
({
|
|
110
|
+
id: 'run-1',
|
|
111
|
+
status: 'failed',
|
|
112
|
+
error: { message: 'step blew up' },
|
|
113
|
+
}) as WorkflowRun
|
|
114
|
+
|
|
115
|
+
await assert.rejects(ws.runToCompletion('flow', {}, {} as any), /blew up/)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('a cancelled run rejects', async () => {
|
|
119
|
+
const ws = probe()
|
|
120
|
+
ws.getRun = async () =>
|
|
121
|
+
({ id: 'run-1', status: 'cancelled' }) as WorkflowRun
|
|
122
|
+
|
|
123
|
+
await assert.rejects(ws.runToCompletion('flow', {}, {} as any))
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('a run that disappears rejects rather than polling forever', async () => {
|
|
127
|
+
const ws = probe()
|
|
128
|
+
ws.getRun = async () => null
|
|
129
|
+
|
|
130
|
+
await assert.rejects(ws.runToCompletion('flow', {}, {} as any), /run-1/)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -19,7 +19,7 @@ describe('same step name invoked multiple times in one run', () => {
|
|
|
19
19
|
test('each reach gets its own row + invocationId (no clobber)', async () => {
|
|
20
20
|
const ws = inlineService()
|
|
21
21
|
const runId = await ws.createRun('flow', {}, true, 'hash', { type: 'test' })
|
|
22
|
-
|
|
22
|
+
ws.registerInlineRun(runId)
|
|
23
23
|
|
|
24
24
|
// Two `do('process', fn)` reaches in the SAME replay (no reset between).
|
|
25
25
|
const r1 = await (ws as any).inlineStep(runId, 'process', async () => 'first')
|
|
@@ -45,7 +45,7 @@ describe('same step name invoked multiple times in one run', () => {
|
|
|
45
45
|
test('ordinal 0 is unchanged: a single call keeps the bare name + its old invocationId', async () => {
|
|
46
46
|
const ws = inlineService()
|
|
47
47
|
const runId = await ws.createRun('flow', {}, true, 'hash', { type: 'test' })
|
|
48
|
-
|
|
48
|
+
ws.registerInlineRun(runId)
|
|
49
49
|
|
|
50
50
|
await (ws as any).inlineStep(runId, 'solo', async () => 'x')
|
|
51
51
|
// Stored under the bare name, and the dedupe key matches the pre-ordinal hash.
|
|
@@ -57,7 +57,7 @@ describe('same step name invoked multiple times in one run', () => {
|
|
|
57
57
|
test('a fresh replay resets ordinals so the same call resolves to the same row', async () => {
|
|
58
58
|
const ws = inlineService()
|
|
59
59
|
const runId = await ws.createRun('flow', {}, true, 'hash', { type: 'test' })
|
|
60
|
-
|
|
60
|
+
ws.registerInlineRun(runId)
|
|
61
61
|
|
|
62
62
|
let runs = 0
|
|
63
63
|
const r1 = await (ws as any).inlineStep(runId, 'once', async () => {
|
|
@@ -66,7 +66,7 @@ describe('same step name invoked multiple times in one run', () => {
|
|
|
66
66
|
})
|
|
67
67
|
// Next replay resets the counter; the same `do('once')` must hit the cached
|
|
68
68
|
// row, not mint 'once#1'.
|
|
69
|
-
|
|
69
|
+
await (ws as any).beginReplay(runId)
|
|
70
70
|
const r2 = await (ws as any).inlineStep(runId, 'once', async () => {
|
|
71
71
|
runs++
|
|
72
72
|
return 'v2'
|
|
@@ -254,9 +254,6 @@ export interface WorkflowRunService {
|
|
|
254
254
|
name: string,
|
|
255
255
|
graphHash: string
|
|
256
256
|
): Promise<{ graph: any; source: string } | null>
|
|
257
|
-
getAIGeneratedWorkflows(
|
|
258
|
-
agentName?: string
|
|
259
|
-
): Promise<Array<{ workflowName: string; graphHash: string; graph: any }>>
|
|
260
257
|
deleteRun(id: string): Promise<boolean>
|
|
261
258
|
}
|
|
262
259
|
|
|
@@ -484,7 +481,7 @@ export interface WorkflowRuntimeMeta {
|
|
|
484
481
|
/** Pikku function name (for execution) */
|
|
485
482
|
pikkuFuncId: string
|
|
486
483
|
/** Source type: 'dsl' (serializable), 'complex' (has inline steps), 'graph', 'scenario' (complex + actor steps) */
|
|
487
|
-
source: 'dsl' | 'complex' | 'graph' | '
|
|
484
|
+
source: 'dsl' | 'complex' | 'graph' | 'scenario'
|
|
488
485
|
/** Optional description */
|
|
489
486
|
description?: string
|
|
490
487
|
/** Tags for organization */
|