@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,153 @@
|
|
|
1
|
+
import { describe, test, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { createScenarioRunner } from './pikku-scenario-service.js'
|
|
5
|
+
import type { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
|
|
6
|
+
import { pikkuState, resetPikkuState } from '../../pikku-state.js'
|
|
7
|
+
import type { ScenarioActor } from '../../services/scenario-actors-service.js'
|
|
8
|
+
|
|
9
|
+
const noopLogger = { error() {}, info() {}, warn() {}, debug() {} }
|
|
10
|
+
|
|
11
|
+
const fakeActor = (
|
|
12
|
+
name: string,
|
|
13
|
+
handler: (rpcName: string, data: unknown) => Promise<unknown>
|
|
14
|
+
): ScenarioActor => ({
|
|
15
|
+
name,
|
|
16
|
+
email: `${name}@actors.local`,
|
|
17
|
+
invoke: async (rpcName: string, data: unknown) => handler(rpcName, data),
|
|
18
|
+
invokeRaw: async (rpcName: string, data: unknown) => ({
|
|
19
|
+
status: 200,
|
|
20
|
+
ok: true,
|
|
21
|
+
body: await handler(rpcName, data),
|
|
22
|
+
}),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const setup = async (ws: InMemoryWorkflowService) => {
|
|
26
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
27
|
+
logger: noopLogger,
|
|
28
|
+
} as any)
|
|
29
|
+
const runId = await ws.createRun('scenarioTest', {}, true, 'hash', {
|
|
30
|
+
type: 'test',
|
|
31
|
+
} as any)
|
|
32
|
+
ws.registerInlineRun(runId)
|
|
33
|
+
return runId
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The scenario wire, wrapped rather than returned directly: it carries a
|
|
38
|
+
* `then` — the Gherkin phase, not a promise — so awaiting one is awaiting a
|
|
39
|
+
* thenable, and the runtime calls it as a step.
|
|
40
|
+
*/
|
|
41
|
+
const scenarioWire = async (rpcService: any = {}): Promise<{ wire: any }> => {
|
|
42
|
+
const { workflowService } = createScenarioRunner()
|
|
43
|
+
const ws = workflowService as InMemoryWorkflowService
|
|
44
|
+
const runId = await setup(ws)
|
|
45
|
+
return { wire: ws.createWorkflowWire('scenarioTest', runId, rpcService) }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('workflow.expectError', () => {
|
|
49
|
+
beforeEach(() => resetPikkuState())
|
|
50
|
+
|
|
51
|
+
test('returns the message when the call throws as expected', async () => {
|
|
52
|
+
const intruder = fakeActor('intruder', async () => {
|
|
53
|
+
throw new Error('Forbidden: not your todo')
|
|
54
|
+
})
|
|
55
|
+
const { wire } = await scenarioWire()
|
|
56
|
+
|
|
57
|
+
const message = await wire.expectError(
|
|
58
|
+
'intruder cannot delete',
|
|
59
|
+
'deleteTodo',
|
|
60
|
+
{ todoId: 't1' },
|
|
61
|
+
{ actor: intruder }
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
assert.equal(message, 'Forbidden: not your todo')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('fails when the call succeeds instead of throwing', async () => {
|
|
68
|
+
const { wire } = await scenarioWire({
|
|
69
|
+
rpcWithWire: async () => ({ deleted: true }),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
await assert.rejects(
|
|
73
|
+
wire.expectError('should have failed', 'deleteTodo', {}, { retries: 0 }),
|
|
74
|
+
/expected an error but the call succeeded/
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('fails when the error message does not match', async () => {
|
|
79
|
+
const { wire } = await scenarioWire({
|
|
80
|
+
rpcWithWire: async () => {
|
|
81
|
+
throw new Error('database offline')
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
await assert.rejects(
|
|
86
|
+
wire.expectError(
|
|
87
|
+
'wrong reason',
|
|
88
|
+
'deleteTodo',
|
|
89
|
+
{},
|
|
90
|
+
{ matches: /Forbidden/, retries: 0 }
|
|
91
|
+
),
|
|
92
|
+
/did not match .*database offline/
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('workflow.expectService', () => {
|
|
98
|
+
beforeEach(() => resetPikkuState())
|
|
99
|
+
|
|
100
|
+
const stubCalls = (
|
|
101
|
+
calls: Array<{ service: string; method: string; args: unknown[] }>
|
|
102
|
+
) => ({
|
|
103
|
+
rpcWithWire: async (rpcName: string) => {
|
|
104
|
+
assert.equal(rpcName, 'pikkuScenarioGetStubCalls')
|
|
105
|
+
return calls
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const oneEmail = () =>
|
|
110
|
+
stubCalls([{ service: 'email', method: 'send', args: [{ to: 'a@b.c' }] }])
|
|
111
|
+
|
|
112
|
+
test('passes when the service method was called', async () => {
|
|
113
|
+
const { wire } = await scenarioWire(oneEmail())
|
|
114
|
+
|
|
115
|
+
await wire.expectService('an email went out', 'email.send')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('a call count that does not match names what was recorded', async () => {
|
|
119
|
+
const { wire } = await scenarioWire(oneEmail())
|
|
120
|
+
|
|
121
|
+
await assert.rejects(
|
|
122
|
+
wire.expectService('two emails', 'email.send', {
|
|
123
|
+
times: 2,
|
|
124
|
+
retries: 0,
|
|
125
|
+
}),
|
|
126
|
+
/expected 2 call\(s\).*found 1.*email\.send/s
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('calledWith matches on the first argument', async () => {
|
|
131
|
+
const { wire } = await scenarioWire(oneEmail())
|
|
132
|
+
|
|
133
|
+
await wire.expectService('emailed the customer', 'email.send', {
|
|
134
|
+
calledWith: { to: 'a@b.c' },
|
|
135
|
+
})
|
|
136
|
+
await assert.rejects(
|
|
137
|
+
wire.expectService('emailed someone else', 'email.send', {
|
|
138
|
+
calledWith: { to: 'x@y.z' },
|
|
139
|
+
retries: 0,
|
|
140
|
+
}),
|
|
141
|
+
/found 0/
|
|
142
|
+
)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('a name that is not service.method is rejected outright', async () => {
|
|
146
|
+
const { wire } = await scenarioWire(stubCalls([]))
|
|
147
|
+
|
|
148
|
+
await assert.rejects(
|
|
149
|
+
wire.expectService('bad name', 'emailsend'),
|
|
150
|
+
/needs 'service\.method'/
|
|
151
|
+
)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -89,7 +89,7 @@ describe('scenario actor steps (workflow.do with `actor`)', () => {
|
|
|
89
89
|
|
|
90
90
|
// Simulate replay: reset per-run ordinals so the same logical step name
|
|
91
91
|
// resolves to the same durable step key.
|
|
92
|
-
|
|
92
|
+
await (ws as any).beginReplay(runId)
|
|
93
93
|
const replayWire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
94
94
|
const replayed = await replayWire.do(
|
|
95
95
|
'step',
|
|
@@ -58,7 +58,7 @@ describe('dispatch durability: a transient queue failure is recoverable', () =>
|
|
|
58
58
|
// and the step transitions to `scheduled`. A real replay runs through
|
|
59
59
|
// runWorkflowJob which resets the ordinal counter; simulate that so the
|
|
60
60
|
// second reach resolves to the same step key, not the next ordinal.
|
|
61
|
-
|
|
61
|
+
await (ws as any).beginReplay(runId)
|
|
62
62
|
queueUp = true
|
|
63
63
|
await assert.rejects(
|
|
64
64
|
(ws as any).rpcStep(runId, 'thing', 'doThing', {}, {}),
|
|
@@ -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
|
+
})
|