@pikku/core 0.12.47 → 0.12.50
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 +52 -0
- package/dist/services/http-user-flow-actors.d.ts +47 -0
- package/dist/services/http-user-flow-actors.js +93 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +1 -0
- package/dist/services/meta-service.d.ts +4 -0
- package/dist/services/meta-service.js +9 -0
- package/dist/services/user-flow-actors-service.d.ts +29 -0
- package/dist/services/user-flow-actors-service.js +1 -0
- package/dist/types/core.types.d.ts +5 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +27 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +3 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +51 -3
- package/dist/wirings/workflow/workflow.types.d.ts +9 -3
- package/package.json +1 -1
- package/src/services/http-user-flow-actors.test.ts +115 -0
- package/src/services/http-user-flow-actors.ts +132 -0
- package/src/services/index.ts +10 -0
- package/src/services/meta-service.ts +13 -0
- package/src/services/user-flow-actors-service.ts +31 -0
- package/src/types/core.types.ts +5 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +35 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +76 -11
- package/src/wirings/workflow/user-flow-step.test.ts +193 -0
- package/src/wirings/workflow/workflow.types.ts +9 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, test, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
|
|
5
|
+
import { pikkuState, resetPikkuState } from '../../pikku-state.js'
|
|
6
|
+
import type { UserFlowActor } from '../../services/user-flow-actors-service.js'
|
|
7
|
+
|
|
8
|
+
const noopLogger = { error() {}, info() {}, warn() {}, debug() {} }
|
|
9
|
+
|
|
10
|
+
const fakeActor = (
|
|
11
|
+
name: string,
|
|
12
|
+
handler: (rpcName: string, data: unknown) => Promise<unknown>
|
|
13
|
+
): UserFlowActor & { calls: Array<{ rpcName: string; data: unknown }> } => {
|
|
14
|
+
const calls: Array<{ rpcName: string; data: unknown }> = []
|
|
15
|
+
return {
|
|
16
|
+
name,
|
|
17
|
+
email: `${name}@actors.local`,
|
|
18
|
+
calls,
|
|
19
|
+
invoke: async (rpcName: string, data: unknown) => {
|
|
20
|
+
calls.push({ rpcName, data })
|
|
21
|
+
return handler(rpcName, data)
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const setup = async (
|
|
27
|
+
ws: InMemoryWorkflowService,
|
|
28
|
+
services: Record<string, unknown> = {}
|
|
29
|
+
) => {
|
|
30
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
31
|
+
logger: noopLogger,
|
|
32
|
+
...services,
|
|
33
|
+
} as any)
|
|
34
|
+
const runId = await ws.createRun('userFlowTest', {}, true, 'hash', {
|
|
35
|
+
type: 'test',
|
|
36
|
+
} as any)
|
|
37
|
+
ws.registerInlineRun(runId)
|
|
38
|
+
return runId
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('user-flow actor steps (workflow.do with `actor`)', () => {
|
|
42
|
+
beforeEach(() => resetPikkuState())
|
|
43
|
+
|
|
44
|
+
test('routes through the actor over the real transport, never internal rpc', async () => {
|
|
45
|
+
const ws = new InMemoryWorkflowService()
|
|
46
|
+
const customer = fakeActor('customer', async () => ({ todoId: 't1' }))
|
|
47
|
+
let internalCalls = 0
|
|
48
|
+
|
|
49
|
+
const runId = await setup(ws)
|
|
50
|
+
const rpc = {
|
|
51
|
+
rpcWithWire: async () => {
|
|
52
|
+
internalCalls++
|
|
53
|
+
return {}
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, rpc)
|
|
58
|
+
const result = await wire.do(
|
|
59
|
+
'customer creates todo',
|
|
60
|
+
'createTodo',
|
|
61
|
+
{ title: 'x' },
|
|
62
|
+
{ actor: customer }
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
assert.deepEqual(result, { todoId: 't1' })
|
|
66
|
+
assert.deepEqual(customer.calls, [
|
|
67
|
+
{ rpcName: 'createTodo', data: { title: 'x' } },
|
|
68
|
+
])
|
|
69
|
+
assert.equal(internalCalls, 0, 'actor steps must NOT dispatch internally')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('step is recorded durably and replay returns the cached result without re-invoking', async () => {
|
|
73
|
+
const ws = new InMemoryWorkflowService()
|
|
74
|
+
let invocations = 0
|
|
75
|
+
const yasser = fakeActor('yasser', async () => ({ n: ++invocations }))
|
|
76
|
+
const runId = await setup(ws)
|
|
77
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, {})
|
|
78
|
+
|
|
79
|
+
const first = await wire.do('step', 'someRpc', {}, { actor: yasser })
|
|
80
|
+
assert.deepEqual(first, { n: 1 })
|
|
81
|
+
|
|
82
|
+
// Simulate replay: reset per-run ordinals so the same logical step name
|
|
83
|
+
// resolves to the same durable step key.
|
|
84
|
+
;(ws as any).resetStepOrdinals(runId)
|
|
85
|
+
const replayWire = ws.createWorkflowWire('userFlowTest', runId, {})
|
|
86
|
+
const replayed = await replayWire.do('step', 'someRpc', {}, { actor: yasser })
|
|
87
|
+
|
|
88
|
+
assert.deepEqual(replayed, { n: 1 }, 'replay must return the cached result')
|
|
89
|
+
assert.equal(invocations, 1, 'the actor must not be re-invoked on replay')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('actor steps never queue, even when the function is queue-eligible', async () => {
|
|
93
|
+
const ws = new InMemoryWorkflowService()
|
|
94
|
+
let queued = 0
|
|
95
|
+
const customer = fakeActor('customer', async () => ({}))
|
|
96
|
+
const runId = await setup(ws, {
|
|
97
|
+
queueService: {
|
|
98
|
+
add: async () => {
|
|
99
|
+
queued++
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
// Function meta says this RPC would normally dispatch via the queue.
|
|
104
|
+
pikkuState(null, 'function', 'meta').queuedRpc = {
|
|
105
|
+
pikkuFuncId: 'queuedRpc',
|
|
106
|
+
workflowQueued: true,
|
|
107
|
+
} as any
|
|
108
|
+
|
|
109
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, {})
|
|
110
|
+
await wire.do('step', 'queuedRpc', {}, { actor: customer })
|
|
111
|
+
|
|
112
|
+
assert.equal(queued, 0, 'actor steps are outbound HTTP — never queued')
|
|
113
|
+
assert.equal(customer.calls.length, 1)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('actor step failure surfaces the actor error and fails after retries', async () => {
|
|
117
|
+
const ws = new InMemoryWorkflowService()
|
|
118
|
+
const broken = fakeActor('broken', async () => {
|
|
119
|
+
throw new Error("[user-flow] 'createTodo' as 'broken' returned 403: nope")
|
|
120
|
+
})
|
|
121
|
+
const runId = await setup(ws)
|
|
122
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, {})
|
|
123
|
+
|
|
124
|
+
await assert.rejects(
|
|
125
|
+
wire.do('step', 'createTodo', {}, { actor: broken, retries: 2 }),
|
|
126
|
+
/returned 403/
|
|
127
|
+
)
|
|
128
|
+
assert.equal(broken.calls.length, 2, 'retries bounds total attempts')
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('workflow.expectEventually', () => {
|
|
133
|
+
beforeEach(() => resetPikkuState())
|
|
134
|
+
|
|
135
|
+
test('polls as the actor until the predicate passes', async () => {
|
|
136
|
+
const ws = new InMemoryWorkflowService()
|
|
137
|
+
let polls = 0
|
|
138
|
+
const sarah = fakeActor('sarah', async () => ({
|
|
139
|
+
notifications: ++polls >= 3 ? ['ping'] : [],
|
|
140
|
+
}))
|
|
141
|
+
const runId = await setup(ws)
|
|
142
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, {})
|
|
143
|
+
|
|
144
|
+
const result = await wire.expectEventually(
|
|
145
|
+
'sarah sees the notification',
|
|
146
|
+
'getNotifications',
|
|
147
|
+
{},
|
|
148
|
+
(out: any) => out.notifications.length > 0,
|
|
149
|
+
{ actor: sarah, within: 2_000, interval: 5 }
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
assert.deepEqual(result, { notifications: ['ping'] })
|
|
153
|
+
assert.equal(polls, 3)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('fails with the last result when the deadline passes', async () => {
|
|
157
|
+
const ws = new InMemoryWorkflowService()
|
|
158
|
+
const sarah = fakeActor('sarah', async () => ({ notifications: [] }))
|
|
159
|
+
const runId = await setup(ws)
|
|
160
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, {})
|
|
161
|
+
|
|
162
|
+
await assert.rejects(
|
|
163
|
+
wire.expectEventually(
|
|
164
|
+
'never arrives',
|
|
165
|
+
'getNotifications',
|
|
166
|
+
{},
|
|
167
|
+
(out: any) => out.notifications.length > 0,
|
|
168
|
+
{ actor: sarah, within: 30, interval: 5, retries: 0 }
|
|
169
|
+
),
|
|
170
|
+
/did not pass within 30ms.*notifications/
|
|
171
|
+
)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('polls internally (rpcWithWire) when no actor is given', async () => {
|
|
175
|
+
const ws = new InMemoryWorkflowService()
|
|
176
|
+
let polls = 0
|
|
177
|
+
const runId = await setup(ws)
|
|
178
|
+
const rpc = {
|
|
179
|
+
rpcWithWire: async () => ({ ready: ++polls >= 2 }),
|
|
180
|
+
}
|
|
181
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, rpc)
|
|
182
|
+
|
|
183
|
+
const result = await wire.expectEventually(
|
|
184
|
+
'job finishes',
|
|
185
|
+
'getJob',
|
|
186
|
+
{},
|
|
187
|
+
(out: any) => out.ready,
|
|
188
|
+
{ within: 2_000, interval: 5 }
|
|
189
|
+
)
|
|
190
|
+
assert.deepEqual(result, { ready: true })
|
|
191
|
+
assert.equal(polls, 2)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
@@ -7,6 +7,7 @@ export type { WorkflowService } from '../../services/workflow-service.js'
|
|
|
7
7
|
// Re-export DSL types from dsl module
|
|
8
8
|
export type {
|
|
9
9
|
WorkflowStepOptions,
|
|
10
|
+
WorkflowExpectEventuallyOptions,
|
|
10
11
|
WorkflowWireDoRPC,
|
|
11
12
|
WorkflowWireDoInline,
|
|
12
13
|
WorkflowWireSleep,
|
|
@@ -324,6 +325,10 @@ export type WorkflowsMeta = Record<
|
|
|
324
325
|
context?: WorkflowContext
|
|
325
326
|
dsl?: boolean
|
|
326
327
|
expose?: boolean
|
|
328
|
+
/** True for pikkuUserFlow workflows (complex + actor steps). */
|
|
329
|
+
userFlow?: boolean
|
|
330
|
+
/** Actor names a user flow declares (personas it runs steps as). */
|
|
331
|
+
actors?: string[]
|
|
327
332
|
}
|
|
328
333
|
>
|
|
329
334
|
|
|
@@ -337,12 +342,14 @@ export interface WorkflowRuntimeMeta {
|
|
|
337
342
|
name: string
|
|
338
343
|
/** Pikku function name (for execution) */
|
|
339
344
|
pikkuFuncId: string
|
|
340
|
-
/** Source type: 'dsl' (serializable), 'complex' (has inline steps), 'graph' */
|
|
341
|
-
source: 'dsl' | 'complex' | 'graph' | 'dynamic-workflow'
|
|
345
|
+
/** Source type: 'dsl' (serializable), 'complex' (has inline steps), 'graph', 'user-flow' (complex + actor steps) */
|
|
346
|
+
source: 'dsl' | 'complex' | 'graph' | 'dynamic-workflow' | 'user-flow'
|
|
342
347
|
/** Optional description */
|
|
343
348
|
description?: string
|
|
344
349
|
/** Tags for organization */
|
|
345
350
|
tags?: string[]
|
|
351
|
+
/** Actor names a user flow declares (personas it runs steps as). */
|
|
352
|
+
actors?: string[]
|
|
346
353
|
/** Serialized nodes */
|
|
347
354
|
nodes?: Record<string, any>
|
|
348
355
|
/** Entry node IDs for graph workflows (computed at build time) */
|