@pikku/core 0.12.37 → 0.12.39
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 +113 -0
- package/dist/errors/error-handler.d.ts +8 -0
- package/dist/errors/error-handler.js +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/services/in-memory-queue-service.d.ts +9 -0
- package/dist/services/in-memory-queue-service.js +42 -11
- package/dist/services/in-memory-workflow-service.d.ts +7 -2
- package/dist/services/in-memory-workflow-service.js +19 -1
- package/dist/services/workflow-service.d.ts +3 -0
- package/dist/testing/service-tests.js +35 -0
- package/dist/types/core.types.d.ts +1 -0
- package/dist/wirings/cli/cli-runner.js +12 -3
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +19 -1
- package/dist/wirings/workflow/graph/graph-runner.js +168 -106
- package/dist/wirings/workflow/index.d.ts +4 -1
- package/dist/wirings/workflow/index.js +4 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +95 -5
- package/dist/wirings/workflow/pikku-workflow-service.js +323 -175
- package/dist/wirings/workflow/run-timeline.d.ts +85 -0
- package/dist/wirings/workflow/run-timeline.js +153 -0
- package/dist/wirings/workflow/workflow-invocation-id.d.ts +20 -0
- package/dist/wirings/workflow/workflow-invocation-id.js +38 -0
- package/dist/wirings/workflow/workflow-queue-workers.d.ts +2 -0
- package/dist/wirings/workflow/workflow.types.d.ts +7 -0
- package/package.json +2 -1
- package/src/dev/hot-reload.test.ts +5 -0
- package/src/errors/error-handler.ts +10 -0
- package/src/index.ts +1 -0
- package/src/services/in-memory-queue-service.test.ts +97 -0
- package/src/services/in-memory-queue-service.ts +51 -16
- package/src/services/in-memory-workflow-service.ts +27 -1
- package/src/services/workflow-service.ts +9 -0
- package/src/testing/service-tests.ts +65 -0
- package/src/types/core.types.ts +4 -0
- package/src/wirings/cli/cli-runner.ts +11 -3
- package/src/wirings/oauth2/oauth2-client.test.ts +25 -23
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +19 -1
- package/src/wirings/workflow/graph/graph-runner.test.ts +159 -3
- package/src/wirings/workflow/graph/graph-runner.ts +253 -142
- package/src/wirings/workflow/index.ts +17 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +447 -213
- package/src/wirings/workflow/run-timeline.test.ts +211 -0
- package/src/wirings/workflow/run-timeline.ts +241 -0
- package/src/wirings/workflow/workflow-dispatch-durability.test.ts +117 -0
- package/src/wirings/workflow/workflow-invocation-id.test.ts +53 -0
- package/src/wirings/workflow/workflow-invocation-id.ts +48 -0
- package/src/wirings/workflow/workflow-queue-workers.ts +2 -0
- package/src/wirings/workflow/workflow-retry-policy.test.ts +111 -0
- package/src/wirings/workflow/workflow-step-ordinal.test.ts +79 -0
- package/src/wirings/workflow/workflow.types.ts +7 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,111 @@
|
|
|
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 { DEFAULT_STEP_RETRIES } from './pikku-workflow-service.js'
|
|
7
|
+
import type { PikkuWorkflowService } from './pikku-workflow-service.js'
|
|
8
|
+
import { deriveInvocationId } from './workflow-invocation-id.js'
|
|
9
|
+
import type { WorkflowStepOptions } from './workflow.types.js'
|
|
10
|
+
|
|
11
|
+
const UUID_RE =
|
|
12
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
|
13
|
+
|
|
14
|
+
// Expose the protected retry-policy resolver for direct assertion.
|
|
15
|
+
class TestWorkflowService extends InMemoryWorkflowService {
|
|
16
|
+
public resolve(options?: WorkflowStepOptions) {
|
|
17
|
+
return (this as PikkuWorkflowService as any).resolveStepJobOptions(options)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('resolveStepJobOptions — workflow owns retry policy', () => {
|
|
22
|
+
const ws = new TestWorkflowService()
|
|
23
|
+
|
|
24
|
+
test('unset retries → default + exponential backoff (rides out outages)', () => {
|
|
25
|
+
assert.deepEqual(ws.resolve(undefined), {
|
|
26
|
+
attempts: DEFAULT_STEP_RETRIES + 1,
|
|
27
|
+
backoff: 'exponential',
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('retries: 0 → attempts: 1 and NO backoff (the non-idempotent opt-out)', () => {
|
|
32
|
+
// The whole point: an explicit 0 must never inherit the queue default and
|
|
33
|
+
// re-run a step the workflow said to run exactly once.
|
|
34
|
+
assert.deepEqual(ws.resolve({ retries: 0 }), { attempts: 1 })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('explicit retries always honored', () => {
|
|
38
|
+
assert.deepEqual(ws.resolve({ retries: 3 }), {
|
|
39
|
+
attempts: 4,
|
|
40
|
+
backoff: 'exponential',
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('numeric retryDelay → fixed backoff', () => {
|
|
45
|
+
assert.deepEqual(ws.resolve({ retries: 3, retryDelay: 250 }), {
|
|
46
|
+
attempts: 4,
|
|
47
|
+
backoff: { type: 'fixed', delay: 250 },
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("retryDelay: 'exponential' → exponential backoff", () => {
|
|
52
|
+
assert.deepEqual(ws.resolve({ retries: 2, retryDelay: 'exponential' }), {
|
|
53
|
+
attempts: 3,
|
|
54
|
+
backoff: 'exponential',
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('executeWorkflowStep surfaces a stable invocationId', () => {
|
|
60
|
+
test('invocationId is stable across a retry; stepId is per-attempt', async () => {
|
|
61
|
+
const ws = new InMemoryWorkflowService()
|
|
62
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
63
|
+
queueService: { add: async () => {} },
|
|
64
|
+
logger: { error() {}, info() {}, warn() {}, debug() {} },
|
|
65
|
+
} as any)
|
|
66
|
+
|
|
67
|
+
const captured: Array<{
|
|
68
|
+
invocationId: string
|
|
69
|
+
stepId: string
|
|
70
|
+
attemptCount: number
|
|
71
|
+
}> = []
|
|
72
|
+
const rpc = {
|
|
73
|
+
rpcWithWire: async (_n: string, _d: any, opts: any) => {
|
|
74
|
+
captured.push(opts.workflowStep)
|
|
75
|
+
if (captured.length === 1) throw new Error('transient boom')
|
|
76
|
+
return { ok: true }
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const runId = await ws.createRun('invflow', {}, false, 'hash', {
|
|
81
|
+
type: 'test',
|
|
82
|
+
})
|
|
83
|
+
await ws.insertStepState(runId, 'updateUser', 'doUpdateUser', {})
|
|
84
|
+
|
|
85
|
+
// First attempt fails (default retries not exhausted → throws, not failed-run).
|
|
86
|
+
await assert.rejects(
|
|
87
|
+
ws.executeWorkflowStep(runId, 'updateUser', 'doUpdateUser', {}, rpc)
|
|
88
|
+
)
|
|
89
|
+
// Retry attempt succeeds.
|
|
90
|
+
await ws.executeWorkflowStep(runId, 'updateUser', 'doUpdateUser', {}, rpc)
|
|
91
|
+
|
|
92
|
+
assert.equal(captured.length, 2, 'step ran twice')
|
|
93
|
+
assert.equal(
|
|
94
|
+
captured[0]!.invocationId,
|
|
95
|
+
captured[1]!.invocationId,
|
|
96
|
+
'invocationId is identical across retries — the dedupe key'
|
|
97
|
+
)
|
|
98
|
+
assert.equal(
|
|
99
|
+
captured[0]!.invocationId,
|
|
100
|
+
deriveInvocationId(runId, 'updateUser')
|
|
101
|
+
)
|
|
102
|
+
assert.match(captured[0]!.invocationId, UUID_RE)
|
|
103
|
+
assert.equal(captured[0]!.attemptCount, 1)
|
|
104
|
+
assert.equal(captured[1]!.attemptCount, 2)
|
|
105
|
+
assert.notEqual(
|
|
106
|
+
captured[0]!.stepId,
|
|
107
|
+
captured[1]!.stepId,
|
|
108
|
+
'stepId is minted per attempt — must NOT be used as the dedupe key'
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
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 { deriveInvocationId } from './workflow-invocation-id.js'
|
|
7
|
+
|
|
8
|
+
const silentLogger = { error() {}, info() {}, warn() {}, debug() {} }
|
|
9
|
+
|
|
10
|
+
function inlineService() {
|
|
11
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
12
|
+
queueService: { add: async () => {} },
|
|
13
|
+
logger: silentLogger,
|
|
14
|
+
} as any)
|
|
15
|
+
return new InMemoryWorkflowService()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('same step name invoked multiple times in one run', () => {
|
|
19
|
+
test('each reach gets its own row + invocationId (no clobber)', async () => {
|
|
20
|
+
const ws = inlineService()
|
|
21
|
+
const runId = await ws.createRun('flow', {}, true, 'hash', { type: 'test' })
|
|
22
|
+
;(ws as any).inlineRuns.add(runId)
|
|
23
|
+
|
|
24
|
+
// Two `do('process', fn)` reaches in the SAME replay (no reset between).
|
|
25
|
+
const r1 = await (ws as any).inlineStep(runId, 'process', async () => 'first')
|
|
26
|
+
const r2 = await (ws as any).inlineStep(runId, 'process', async () => 'second')
|
|
27
|
+
|
|
28
|
+
assert.equal(r1, 'first')
|
|
29
|
+
assert.equal(r2, 'second', 'second call must NOT return the first cached result')
|
|
30
|
+
|
|
31
|
+
// Distinct rows: 'process' (ordinal 0) and 'process#1'.
|
|
32
|
+
const s0 = await ws.getStepState(runId, 'process')
|
|
33
|
+
const s1 = await ws.getStepState(runId, 'process#1')
|
|
34
|
+
assert.notEqual(s0.stepId, s1.stepId, 'distinct step rows')
|
|
35
|
+
assert.equal(s0.result, 'first')
|
|
36
|
+
assert.equal(s1.result, 'second')
|
|
37
|
+
|
|
38
|
+
// Per-invocation dedupe keys differ (not per-name).
|
|
39
|
+
assert.notEqual(
|
|
40
|
+
deriveInvocationId(runId, 'process'),
|
|
41
|
+
deriveInvocationId(runId, 'process#1')
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('ordinal 0 is unchanged: a single call keeps the bare name + its old invocationId', async () => {
|
|
46
|
+
const ws = inlineService()
|
|
47
|
+
const runId = await ws.createRun('flow', {}, true, 'hash', { type: 'test' })
|
|
48
|
+
;(ws as any).inlineRuns.add(runId)
|
|
49
|
+
|
|
50
|
+
await (ws as any).inlineStep(runId, 'solo', async () => 'x')
|
|
51
|
+
// Stored under the bare name, and the dedupe key matches the pre-ordinal hash.
|
|
52
|
+
const s = await ws.getStepState(runId, 'solo')
|
|
53
|
+
assert.equal(s.result, 'x')
|
|
54
|
+
await assert.rejects(ws.getStepState(runId, 'solo#1'), 'no synthetic row for a single call')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('a fresh replay resets ordinals so the same call resolves to the same row', async () => {
|
|
58
|
+
const ws = inlineService()
|
|
59
|
+
const runId = await ws.createRun('flow', {}, true, 'hash', { type: 'test' })
|
|
60
|
+
;(ws as any).inlineRuns.add(runId)
|
|
61
|
+
|
|
62
|
+
let runs = 0
|
|
63
|
+
const r1 = await (ws as any).inlineStep(runId, 'once', async () => {
|
|
64
|
+
runs++
|
|
65
|
+
return 'v'
|
|
66
|
+
})
|
|
67
|
+
// Next replay resets the counter; the same `do('once')` must hit the cached
|
|
68
|
+
// row, not mint 'once#1'.
|
|
69
|
+
;(ws as any).resetStepOrdinals(runId)
|
|
70
|
+
const r2 = await (ws as any).inlineStep(runId, 'once', async () => {
|
|
71
|
+
runs++
|
|
72
|
+
return 'v2'
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
assert.equal(r1, 'v')
|
|
76
|
+
assert.equal(r2, 'v', 'replay returns the cached result of the same step')
|
|
77
|
+
assert.equal(runs, 1, 'step body ran exactly once across replays')
|
|
78
|
+
})
|
|
79
|
+
})
|
|
@@ -134,6 +134,9 @@ export interface StepState {
|
|
|
134
134
|
retries?: number
|
|
135
135
|
/** Delay between retries */
|
|
136
136
|
retryDelay?: string | number
|
|
137
|
+
/** Step name of the predecessor that scheduled this step (the transition/edge
|
|
138
|
+
* walked to reach it); undefined for entry steps. Reconstructs the path. */
|
|
139
|
+
fromStepName?: string
|
|
137
140
|
/** Creation timestamp */
|
|
138
141
|
createdAt: Date
|
|
139
142
|
/** Last update timestamp */
|
|
@@ -161,6 +164,8 @@ export interface WorkflowRunStatus {
|
|
|
161
164
|
name: string
|
|
162
165
|
status: StepStatus
|
|
163
166
|
duration?: number
|
|
167
|
+
/** Number of attempts for this step (1 = first try; > 1 means it retried). */
|
|
168
|
+
attempts?: number
|
|
164
169
|
}>
|
|
165
170
|
output?: unknown
|
|
166
171
|
error?: { message: string }
|
|
@@ -360,6 +365,8 @@ export type WorkflowStepInput = {
|
|
|
360
365
|
stepName: string
|
|
361
366
|
rpcName: string
|
|
362
367
|
data: any
|
|
368
|
+
/** Predecessor step name (the walked transition); undefined for entry steps. */
|
|
369
|
+
fromStepName?: string
|
|
363
370
|
}
|
|
364
371
|
|
|
365
372
|
export type WorkflowOrchestratorInput = {
|