@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,59 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* Captures what the workflow service hands the queue, so a test can tell
|
|
11
|
+
* whether core serialised the payload on the way past.
|
|
12
|
+
*/
|
|
13
|
+
function serviceWithCapturingQueue() {
|
|
14
|
+
const added: Array<{ queueName: string; data: any }> = []
|
|
15
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
16
|
+
queueService: {
|
|
17
|
+
add: async (queueName: string, data: any) => {
|
|
18
|
+
added.push({ queueName, data })
|
|
19
|
+
return 'job-1'
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
logger: silentLogger,
|
|
23
|
+
} as any)
|
|
24
|
+
return { ws: new InMemoryWorkflowService() as any, added }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('dispatching a step does not serialise the payload twice', () => {
|
|
28
|
+
test('the queue receives the payload core was given', async () => {
|
|
29
|
+
const { ws, added } = serviceWithCapturingQueue()
|
|
30
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
31
|
+
type: 'test',
|
|
32
|
+
})
|
|
33
|
+
const payload = { when: new Date('2020-01-01T00:00:00.000Z'), n: 1 }
|
|
34
|
+
|
|
35
|
+
await ws.queueStepWorker(runId, 'step-1', 'rpc.fn', payload)
|
|
36
|
+
|
|
37
|
+
assert.equal(added.length, 1)
|
|
38
|
+
assert.ok(
|
|
39
|
+
added[0]!.data.data.when instanceof Date,
|
|
40
|
+
'core round-tripped the payload through JSON before handing it to a queue that serialises it anyway'
|
|
41
|
+
)
|
|
42
|
+
assert.equal(added[0]!.data.data.n, 1)
|
|
43
|
+
assert.equal(added[0]!.data.runId, runId)
|
|
44
|
+
assert.equal(added[0]!.data.stepName, 'step-1')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('a payload the queue cannot serialise is still the queue’s problem, not a silent drop', async () => {
|
|
48
|
+
const { ws, added } = serviceWithCapturingQueue()
|
|
49
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
50
|
+
type: 'test',
|
|
51
|
+
})
|
|
52
|
+
const circular: any = { name: 'loop' }
|
|
53
|
+
circular.self = circular
|
|
54
|
+
|
|
55
|
+
await ws.queueStepWorker(runId, 'step-1', 'rpc.fn', circular)
|
|
56
|
+
|
|
57
|
+
assert.equal(added[0]!.data.data.self, added[0]!.data.data)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,178 @@
|
|
|
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 { WorkflowRunMirror } from './workflow.types.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Every method on `WorkflowRunMirror`. A mirror method that is added to the
|
|
10
|
+
* interface but never wired into the service writes nothing at runtime, and
|
|
11
|
+
* nothing else would catch that — so the list is spelled out here.
|
|
12
|
+
*/
|
|
13
|
+
const MIRROR_METHODS = [
|
|
14
|
+
'createRun',
|
|
15
|
+
'updateRunStatus',
|
|
16
|
+
'insertStepState',
|
|
17
|
+
'setStepRunning',
|
|
18
|
+
'setStepScheduled',
|
|
19
|
+
'setStepResult',
|
|
20
|
+
'setStepChildRunId',
|
|
21
|
+
'setStepError',
|
|
22
|
+
'createRetryAttempt',
|
|
23
|
+
'setBranchTaken',
|
|
24
|
+
'updateRunState',
|
|
25
|
+
'upsertWorkflowVersion',
|
|
26
|
+
'updateWorkflowVersionStatus',
|
|
27
|
+
] as const
|
|
28
|
+
|
|
29
|
+
type Call = { method: string; args: any[] }
|
|
30
|
+
|
|
31
|
+
/** A mirror that records every call, and optionally throws on all of them. */
|
|
32
|
+
const recordingMirror = (
|
|
33
|
+
onCall?: (method: string) => void
|
|
34
|
+
): { mirror: WorkflowRunMirror; calls: Call[] } => {
|
|
35
|
+
const calls: Call[] = []
|
|
36
|
+
const mirror = {} as any
|
|
37
|
+
for (const method of MIRROR_METHODS) {
|
|
38
|
+
mirror[method] = async (...args: any[]) => {
|
|
39
|
+
calls.push({ method, args })
|
|
40
|
+
onCall?.(method)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { mirror, calls }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const service = (mirror?: WorkflowRunMirror) => {
|
|
47
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
48
|
+
logger: { error() {}, info() {}, warn() {}, debug() {} },
|
|
49
|
+
} as any)
|
|
50
|
+
const ws = new InMemoryWorkflowService() as any
|
|
51
|
+
ws.mirror = mirror
|
|
52
|
+
return ws
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Drive every write the service mirrors, in dependency order. */
|
|
56
|
+
const driveEveryWrite = async (ws: any) => {
|
|
57
|
+
const runId = await ws.createRun('flow', { a: 1 }, false, 'hash', {
|
|
58
|
+
type: 'test',
|
|
59
|
+
})
|
|
60
|
+
const step = await ws.insertStepState(runId, 'step-1', 'rpc.fn', { x: 1 })
|
|
61
|
+
await ws.setStepRunning(step.stepId)
|
|
62
|
+
await ws.setStepScheduled(step.stepId)
|
|
63
|
+
await ws.setStepResult(step.stepId, 'ok')
|
|
64
|
+
await ws.setStepChildRunId(step.stepId, 'child-1')
|
|
65
|
+
await ws.setStepError(step.stepId, new Error('boom'))
|
|
66
|
+
await ws.createRetryAttempt(step.stepId, 'pending')
|
|
67
|
+
await ws.setBranchTaken(step.stepId, 'left')
|
|
68
|
+
await ws.updateRunState(runId, 'total', 42)
|
|
69
|
+
await ws.upsertWorkflowVersion('flow', 'hash', { nodes: {} }, 'code')
|
|
70
|
+
await ws.updateWorkflowVersionStatus('flow', 'hash', 'archived')
|
|
71
|
+
await ws.updateRunStatus(runId, 'completed', 'output')
|
|
72
|
+
return { runId, step }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('mirroring workflow state', () => {
|
|
76
|
+
test('every mirror method is wired to the write it shadows', async () => {
|
|
77
|
+
const { mirror, calls } = recordingMirror()
|
|
78
|
+
await driveEveryWrite(service(mirror))
|
|
79
|
+
|
|
80
|
+
assert.deepEqual(
|
|
81
|
+
[...new Set(calls.map((c) => c.method))].sort(),
|
|
82
|
+
[...MIRROR_METHODS].sort()
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('a mirror write carries the same identifiers as the write it shadows', async () => {
|
|
87
|
+
const { mirror, calls } = recordingMirror()
|
|
88
|
+
const ws = service(mirror)
|
|
89
|
+
const { runId, step } = await driveEveryWrite(ws)
|
|
90
|
+
|
|
91
|
+
const call = (method: string) => calls.find((c) => c.method === method)!
|
|
92
|
+
assert.equal(call('createRun').args[0], runId)
|
|
93
|
+
assert.equal(call('createRun').args[1], 'flow')
|
|
94
|
+
assert.equal(call('insertStepState').args[0], runId)
|
|
95
|
+
assert.equal(call('insertStepState').args[1].stepId, step.stepId)
|
|
96
|
+
assert.equal(call('insertStepState').args[1].stepName, 'step-1')
|
|
97
|
+
assert.equal(call('insertStepState').args[1].rpcName, 'rpc.fn')
|
|
98
|
+
assert.equal(call('setStepResult').args[1], 'ok')
|
|
99
|
+
assert.equal(call('setStepChildRunId').args[1], 'child-1')
|
|
100
|
+
assert.equal(call('setBranchTaken').args[1], 'left')
|
|
101
|
+
assert.equal(call('updateRunState').args[2], 42)
|
|
102
|
+
assert.equal(call('updateRunStatus').args[1], 'completed')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('a step error reaches the mirror serialised, not as a live Error', async () => {
|
|
106
|
+
const { mirror, calls } = recordingMirror()
|
|
107
|
+
await driveEveryWrite(service(mirror))
|
|
108
|
+
|
|
109
|
+
const serialised = calls.find((c) => c.method === 'setStepError')!.args[1]
|
|
110
|
+
assert.equal(serialised instanceof Error, false)
|
|
111
|
+
assert.equal(serialised.message, 'boom')
|
|
112
|
+
assert.equal(typeof serialised.stack, 'string')
|
|
113
|
+
assert.equal(typeof serialised.expected, 'boolean')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('the mirror only ever sees a write that already landed', async () => {
|
|
117
|
+
const seen: Array<string | undefined> = []
|
|
118
|
+
const { mirror } = recordingMirror()
|
|
119
|
+
const ws = service(mirror)
|
|
120
|
+
;(mirror as any).updateRunStatus = async () => {
|
|
121
|
+
seen.push((await ws.getRun(runIdRef.id))?.status)
|
|
122
|
+
}
|
|
123
|
+
const runIdRef = { id: '' }
|
|
124
|
+
|
|
125
|
+
runIdRef.id = await ws.createRun('flow', {}, false, 'hash', {
|
|
126
|
+
type: 'test',
|
|
127
|
+
})
|
|
128
|
+
await ws.updateRunStatus(runIdRef.id, 'completed', 'output')
|
|
129
|
+
|
|
130
|
+
assert.deepEqual(
|
|
131
|
+
seen,
|
|
132
|
+
['completed'],
|
|
133
|
+
'the mirror ran before the write it was mirroring'
|
|
134
|
+
)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('a write that fails is not mirrored', async () => {
|
|
138
|
+
const { mirror, calls } = recordingMirror()
|
|
139
|
+
const ws = service(mirror)
|
|
140
|
+
ws.updateRunStatusImpl = async () => {
|
|
141
|
+
throw new Error('database down')
|
|
142
|
+
}
|
|
143
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
144
|
+
type: 'test',
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
await assert.rejects(
|
|
148
|
+
ws.updateRunStatus(runId, 'completed'),
|
|
149
|
+
/database down/
|
|
150
|
+
)
|
|
151
|
+
assert.equal(
|
|
152
|
+
calls.some((c) => c.method === 'updateRunStatus'),
|
|
153
|
+
false,
|
|
154
|
+
'a status the store rejected was reported to the mirror as if it had happened'
|
|
155
|
+
)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe('a mirror that is broken', () => {
|
|
160
|
+
test('no write fails because the mirror did', async () => {
|
|
161
|
+
const { mirror } = recordingMirror((method) => {
|
|
162
|
+
throw new Error(`mirror down on ${method}`)
|
|
163
|
+
})
|
|
164
|
+
const ws = service(mirror)
|
|
165
|
+
|
|
166
|
+
const { runId } = await driveEveryWrite(ws)
|
|
167
|
+
|
|
168
|
+
assert.equal((await ws.getRun(runId))?.status, 'completed')
|
|
169
|
+
assert.equal((await ws.getRun(runId))?.output, 'output')
|
|
170
|
+
assert.deepEqual(await ws.getRunState(runId), { total: 42 })
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('no mirror configured is not an error path', async () => {
|
|
174
|
+
const ws = service(undefined)
|
|
175
|
+
const { runId } = await driveEveryWrite(ws)
|
|
176
|
+
assert.equal((await ws.getRun(runId))?.status, 'completed')
|
|
177
|
+
})
|
|
178
|
+
})
|
|
@@ -0,0 +1,139 @@
|
|
|
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 inlineService() {
|
|
10
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
11
|
+
queueService: { add: async () => {} },
|
|
12
|
+
logger: silentLogger,
|
|
13
|
+
} as any)
|
|
14
|
+
return new InMemoryWorkflowService()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Every per-step read the service issues. In a real backend each one is its own
|
|
19
|
+
* round-trip, so a replay that walks N completed steps costs N of them — and
|
|
20
|
+
* over the life of a run that is O(N^2).
|
|
21
|
+
*/
|
|
22
|
+
function countStepReads(ws: any) {
|
|
23
|
+
const counter = { reads: 0 }
|
|
24
|
+
const original = ws.getStepState.bind(ws)
|
|
25
|
+
ws.getStepState = async (...args: any[]) => {
|
|
26
|
+
counter.reads++
|
|
27
|
+
return original(...args)
|
|
28
|
+
}
|
|
29
|
+
return counter
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function seedRun(ws: any, steps: number) {
|
|
33
|
+
const runId = await ws.createRun('flow', {}, true, 'hash', { type: 'test' })
|
|
34
|
+
ws.registerInlineRun(runId)
|
|
35
|
+
await ws.beginReplay(runId)
|
|
36
|
+
for (let i = 0; i < steps; i++) {
|
|
37
|
+
await ws.inlineStep(runId, `step-${i}`, async () => i)
|
|
38
|
+
}
|
|
39
|
+
ws.endReplay(runId)
|
|
40
|
+
return runId
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('replay reads the run steps once, not once per step', () => {
|
|
44
|
+
test('a replay over completed steps issues no per-step reads', async () => {
|
|
45
|
+
const ws: any = inlineService()
|
|
46
|
+
const runId = await seedRun(ws, 8)
|
|
47
|
+
|
|
48
|
+
const counter = countStepReads(ws)
|
|
49
|
+
await ws.beginReplay(runId)
|
|
50
|
+
for (let i = 0; i < 8; i++) {
|
|
51
|
+
await ws.inlineStep(runId, `step-${i}`, async () => {
|
|
52
|
+
throw new Error(`step-${i} re-ran instead of replaying its cached result`)
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
ws.endReplay(runId)
|
|
56
|
+
|
|
57
|
+
assert.equal(
|
|
58
|
+
counter.reads,
|
|
59
|
+
0,
|
|
60
|
+
`the replay still read each step individually (${counter.reads} reads for 8 steps)`
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('the snapshot still returns each step its own cached result', async () => {
|
|
65
|
+
const ws: any = inlineService()
|
|
66
|
+
const runId = await seedRun(ws, 4)
|
|
67
|
+
|
|
68
|
+
await ws.beginReplay(runId)
|
|
69
|
+
const replayed: unknown[] = []
|
|
70
|
+
for (let i = 0; i < 4; i++) {
|
|
71
|
+
replayed.push(
|
|
72
|
+
await ws.inlineStep(runId, `step-${i}`, async () => 'must not run')
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
ws.endReplay(runId)
|
|
76
|
+
|
|
77
|
+
assert.deepEqual(replayed, [0, 1, 2, 3])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('a step reached for the first time mid-replay is created, not mistaken for a miss', async () => {
|
|
81
|
+
const ws: any = inlineService()
|
|
82
|
+
const runId = await seedRun(ws, 2)
|
|
83
|
+
|
|
84
|
+
await ws.beginReplay(runId)
|
|
85
|
+
await ws.inlineStep(runId, 'step-0', async () => 'cached')
|
|
86
|
+
await ws.inlineStep(runId, 'step-1', async () => 'cached')
|
|
87
|
+
const fresh = await ws.inlineStep(runId, 'step-new', async () => 'ran')
|
|
88
|
+
ws.endReplay(runId)
|
|
89
|
+
|
|
90
|
+
assert.equal(fresh, 'ran')
|
|
91
|
+
assert.equal((await ws.getStepState(runId, 'step-new')).result, 'ran')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('a second replay sees what the previous one wrote', async () => {
|
|
95
|
+
const ws: any = inlineService()
|
|
96
|
+
const runId = await seedRun(ws, 1)
|
|
97
|
+
|
|
98
|
+
await ws.beginReplay(runId)
|
|
99
|
+
await ws.inlineStep(runId, 'step-0', async () => 'must not run')
|
|
100
|
+
await ws.inlineStep(runId, 'late', async () => 'first')
|
|
101
|
+
ws.endReplay(runId)
|
|
102
|
+
|
|
103
|
+
await ws.beginReplay(runId)
|
|
104
|
+
await ws.inlineStep(runId, 'step-0', async () => 'must not run')
|
|
105
|
+
const late = await ws.inlineStep(runId, 'late', async () => 'must not run')
|
|
106
|
+
ws.endReplay(runId)
|
|
107
|
+
|
|
108
|
+
assert.equal(late, 'first', 'the second replay re-ran a step the first one completed')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('outside a replay, steps are still read one at a time', async () => {
|
|
112
|
+
const ws: any = inlineService()
|
|
113
|
+
const runId = await seedRun(ws, 3)
|
|
114
|
+
|
|
115
|
+
const counter = countStepReads(ws)
|
|
116
|
+
await ws.inlineStep(runId, 'step-0', async () => 'must not run')
|
|
117
|
+
|
|
118
|
+
assert.equal(
|
|
119
|
+
counter.reads,
|
|
120
|
+
1,
|
|
121
|
+
'a call outside a replay must read the step directly rather than trust a stale snapshot'
|
|
122
|
+
)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('a backend with no bulk read falls back to per-step reads', async () => {
|
|
126
|
+
const ws: any = inlineService()
|
|
127
|
+
const runId = await seedRun(ws, 3)
|
|
128
|
+
ws.listStepStates = async () => null
|
|
129
|
+
|
|
130
|
+
const counter = countStepReads(ws)
|
|
131
|
+
await ws.beginReplay(runId)
|
|
132
|
+
for (let i = 0; i < 3; i++) {
|
|
133
|
+
await ws.inlineStep(runId, `step-${i}`, async () => 'must not run')
|
|
134
|
+
}
|
|
135
|
+
ws.endReplay(runId)
|
|
136
|
+
|
|
137
|
+
assert.equal(counter.reads, 3)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
@@ -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
|
+
})
|