@pikku/core 0.12.46 → 0.12.49
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 +43 -0
- package/dist/function/function-runner.js +18 -2
- package/dist/services/audit-service.d.ts +2 -1
- package/dist/services/audit-service.js +15 -8
- package/dist/services/http-user-flow-actors.d.ts +46 -0
- package/dist/services/http-user-flow-actors.js +90 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +1 -0
- package/dist/services/user-flow-actors-service.d.ts +27 -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 +23 -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/function/function-runner.test.ts +108 -0
- package/src/function/function-runner.ts +28 -2
- package/src/services/audit-service.ts +16 -11
- package/src/services/http-user-flow-actors.test.ts +115 -0
- package/src/services/http-user-flow-actors.ts +128 -0
- package/src/services/index.ts +10 -0
- package/src/services/user-flow-actors-service.ts +29 -0
- package/src/types/core.types.ts +5 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +31 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +76 -11
- package/src/wirings/workflow/user-flow-step.test.ts +192 -0
- package/src/wirings/workflow/workflow.types.ts +9 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -58,6 +58,7 @@ import type {
|
|
|
58
58
|
WorkflowVersionStatus,
|
|
59
59
|
WorkflowServiceConfig,
|
|
60
60
|
WorkflowStepOptions,
|
|
61
|
+
WorkflowExpectEventuallyOptions,
|
|
61
62
|
} from './workflow.types.js'
|
|
62
63
|
import {
|
|
63
64
|
continueGraph,
|
|
@@ -66,6 +67,7 @@ import {
|
|
|
66
67
|
runFromMeta,
|
|
67
68
|
} from './graph/graph-runner.js'
|
|
68
69
|
import type { WorkflowService } from '../../services/workflow-service.js'
|
|
70
|
+
import type { UserFlowActors } from '../../services/user-flow-actors-service.js'
|
|
69
71
|
import {
|
|
70
72
|
PikkuError,
|
|
71
73
|
addError,
|
|
@@ -218,6 +220,9 @@ const WORKFLOW_END_STATES: ReadonlySet<string> = new Set([
|
|
|
218
220
|
*/
|
|
219
221
|
export abstract class PikkuWorkflowService implements WorkflowService {
|
|
220
222
|
private inlineRuns = new Set<string>()
|
|
223
|
+
// User-flow actors per run: live authenticated clients (cookie jars) are
|
|
224
|
+
// process-local by nature, so they ride this map, never the persisted wire.
|
|
225
|
+
private runActors = new Map<string, UserFlowActors>()
|
|
221
226
|
|
|
222
227
|
protected get logger() {
|
|
223
228
|
return getSingletonServices()?.logger
|
|
@@ -1071,7 +1076,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1071
1076
|
input: I,
|
|
1072
1077
|
wire: WorkflowRunWire,
|
|
1073
1078
|
rpcService: any,
|
|
1074
|
-
options?: { inline?: boolean; startNode?: string }
|
|
1079
|
+
options?: { inline?: boolean; startNode?: string; actors?: UserFlowActors }
|
|
1075
1080
|
): Promise<{ runId: string }> {
|
|
1076
1081
|
// Resolve workflow from static meta (root or addon namespace), then dynamic DB
|
|
1077
1082
|
const resolved = resolveWorkflowMeta(name)
|
|
@@ -1135,6 +1140,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1135
1140
|
}
|
|
1136
1141
|
)
|
|
1137
1142
|
|
|
1143
|
+
if (options?.actors) {
|
|
1144
|
+
this.runActors.set(runId, options.actors)
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1138
1147
|
if (shouldInline) {
|
|
1139
1148
|
this.inlineRuns.add(runId)
|
|
1140
1149
|
try {
|
|
@@ -1170,6 +1179,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1170
1179
|
}
|
|
1171
1180
|
} finally {
|
|
1172
1181
|
this.inlineRuns.delete(runId)
|
|
1182
|
+
this.runActors.delete(runId)
|
|
1173
1183
|
}
|
|
1174
1184
|
} else {
|
|
1175
1185
|
await this.resumeWorkflow(runId)
|
|
@@ -1341,8 +1351,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1341
1351
|
const wire: PikkuWire = {
|
|
1342
1352
|
workflow: workflowWire,
|
|
1343
1353
|
pikkuUserId: run.wire?.pikkuUserId,
|
|
1344
|
-
session: rpcService
|
|
1345
|
-
rpc: rpcService
|
|
1354
|
+
session: rpcService?.wire?.session,
|
|
1355
|
+
rpc: rpcService?.wire?.rpc,
|
|
1356
|
+
// User-flow actors registered for this run (see startWorkflow options)
|
|
1357
|
+
actors: this.runActors.get(runId),
|
|
1346
1358
|
}
|
|
1347
1359
|
try {
|
|
1348
1360
|
const result = await runPikkuFunc(
|
|
@@ -1760,6 +1772,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1760
1772
|
const resolvedStepOptions: WorkflowStepOptions = {
|
|
1761
1773
|
retries: stepOptions?.retries ?? DEFAULT_STEP_RETRIES,
|
|
1762
1774
|
retryDelay: stepOptions?.retryDelay,
|
|
1775
|
+
actor: stepOptions?.actor,
|
|
1763
1776
|
}
|
|
1764
1777
|
// Check if step already exists
|
|
1765
1778
|
let stepState: StepState
|
|
@@ -1807,14 +1820,18 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1807
1820
|
// so the orchestrator's next replay re-dispatches it. Marking `scheduled`
|
|
1808
1821
|
// first would strand the step (replay sees `scheduled`, pauses, never
|
|
1809
1822
|
// re-enqueues the job that was never created).
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1823
|
+
// Actor steps never queue: they are outbound HTTP calls made by the
|
|
1824
|
+
// runner itself, and the actor's session lives on this process.
|
|
1825
|
+
const dispatched = resolvedStepOptions.actor
|
|
1826
|
+
? false
|
|
1827
|
+
: await this.dispatchStep(
|
|
1828
|
+
runId,
|
|
1829
|
+
stepName,
|
|
1830
|
+
rpcName,
|
|
1831
|
+
data,
|
|
1832
|
+
resolvedStepOptions,
|
|
1833
|
+
fromStepName
|
|
1834
|
+
)
|
|
1818
1835
|
if (dispatched) {
|
|
1819
1836
|
await this.setStepScheduled(stepState.stepId)
|
|
1820
1837
|
throw new WorkflowAsyncException(runId, stepName)
|
|
@@ -1830,6 +1847,12 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1830
1847
|
retries,
|
|
1831
1848
|
retryDelay,
|
|
1832
1849
|
async (currentStepState) => {
|
|
1850
|
+
// Actor step: send through the actor's authenticated client over the
|
|
1851
|
+
// REAL transport. Never falls back to internal dispatch — that would
|
|
1852
|
+
// bypass auth and fake a green health check.
|
|
1853
|
+
if (resolvedStepOptions.actor) {
|
|
1854
|
+
return resolvedStepOptions.actor.invoke(rpcName, data)
|
|
1855
|
+
}
|
|
1833
1856
|
// Check if the name refers to a workflow
|
|
1834
1857
|
const workflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName]
|
|
1835
1858
|
if (workflowMeta) {
|
|
@@ -2119,6 +2142,48 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
2119
2142
|
}
|
|
2120
2143
|
},
|
|
2121
2144
|
|
|
2145
|
+
// Durable polling step: invoke an RPC (as an actor when options.as is
|
|
2146
|
+
// set) until the predicate passes or `within` elapses. The whole poll is
|
|
2147
|
+
// ONE recorded step, so replay returns the cached outcome.
|
|
2148
|
+
expectEventually: async (
|
|
2149
|
+
stepName: string,
|
|
2150
|
+
rpcName: string,
|
|
2151
|
+
data: any,
|
|
2152
|
+
predicate: (output: any) => boolean,
|
|
2153
|
+
options?: WorkflowExpectEventuallyOptions
|
|
2154
|
+
) => {
|
|
2155
|
+
this.verifyStepName(stepName)
|
|
2156
|
+
const resolvedRpcName =
|
|
2157
|
+
addonNamespace && !rpcName.includes(':')
|
|
2158
|
+
? `${addonNamespace}:${rpcName}`
|
|
2159
|
+
: rpcName
|
|
2160
|
+
const within = getDurationInMilliseconds(options?.within ?? '30s')
|
|
2161
|
+
const interval = getDurationInMilliseconds(options?.interval ?? '1s')
|
|
2162
|
+
return await this.inlineStep(
|
|
2163
|
+
runId,
|
|
2164
|
+
stepName,
|
|
2165
|
+
async () => {
|
|
2166
|
+
const deadline = Date.now() + within
|
|
2167
|
+
let last: any
|
|
2168
|
+
while (true) {
|
|
2169
|
+
last = options?.actor
|
|
2170
|
+
? await options.actor.invoke(resolvedRpcName, data)
|
|
2171
|
+
: await rpcService.rpcWithWire(resolvedRpcName, data, {})
|
|
2172
|
+
if (predicate(last)) return last
|
|
2173
|
+
if (Date.now() + interval > deadline) {
|
|
2174
|
+
throw new Error(
|
|
2175
|
+
`[workflow] expectEventually '${stepName}' ('${resolvedRpcName}'` +
|
|
2176
|
+
`${options?.actor ? ` as '${options.actor.name}'` : ''}) did not pass within ${within}ms; ` +
|
|
2177
|
+
`last result: ${JSON.stringify(last)?.slice(0, 300)}`
|
|
2178
|
+
)
|
|
2179
|
+
}
|
|
2180
|
+
await new Promise((resolve) => setTimeout(resolve, interval))
|
|
2181
|
+
}
|
|
2182
|
+
},
|
|
2183
|
+
options
|
|
2184
|
+
)
|
|
2185
|
+
},
|
|
2186
|
+
|
|
2122
2187
|
// Implement workflow.sleep()
|
|
2123
2188
|
sleep: async (stepName: string, duration: string | number) => {
|
|
2124
2189
|
this.verifyStepName(stepName)
|
|
@@ -0,0 +1,192 @@
|
|
|
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
|
+
calls,
|
|
18
|
+
invoke: async (rpcName: string, data: unknown) => {
|
|
19
|
+
calls.push({ rpcName, data })
|
|
20
|
+
return handler(rpcName, data)
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const setup = async (
|
|
26
|
+
ws: InMemoryWorkflowService,
|
|
27
|
+
services: Record<string, unknown> = {}
|
|
28
|
+
) => {
|
|
29
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
30
|
+
logger: noopLogger,
|
|
31
|
+
...services,
|
|
32
|
+
} as any)
|
|
33
|
+
const runId = await ws.createRun('userFlowTest', {}, true, 'hash', {
|
|
34
|
+
type: 'test',
|
|
35
|
+
} as any)
|
|
36
|
+
ws.registerInlineRun(runId)
|
|
37
|
+
return runId
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('user-flow actor steps (workflow.do with `actor`)', () => {
|
|
41
|
+
beforeEach(() => resetPikkuState())
|
|
42
|
+
|
|
43
|
+
test('routes through the actor over the real transport, never internal rpc', async () => {
|
|
44
|
+
const ws = new InMemoryWorkflowService()
|
|
45
|
+
const customer = fakeActor('customer', async () => ({ todoId: 't1' }))
|
|
46
|
+
let internalCalls = 0
|
|
47
|
+
|
|
48
|
+
const runId = await setup(ws)
|
|
49
|
+
const rpc = {
|
|
50
|
+
rpcWithWire: async () => {
|
|
51
|
+
internalCalls++
|
|
52
|
+
return {}
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, rpc)
|
|
57
|
+
const result = await wire.do(
|
|
58
|
+
'customer creates todo',
|
|
59
|
+
'createTodo',
|
|
60
|
+
{ title: 'x' },
|
|
61
|
+
{ actor: customer }
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
assert.deepEqual(result, { todoId: 't1' })
|
|
65
|
+
assert.deepEqual(customer.calls, [
|
|
66
|
+
{ rpcName: 'createTodo', data: { title: 'x' } },
|
|
67
|
+
])
|
|
68
|
+
assert.equal(internalCalls, 0, 'actor steps must NOT dispatch internally')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('step is recorded durably and replay returns the cached result without re-invoking', async () => {
|
|
72
|
+
const ws = new InMemoryWorkflowService()
|
|
73
|
+
let invocations = 0
|
|
74
|
+
const yasser = fakeActor('yasser', async () => ({ n: ++invocations }))
|
|
75
|
+
const runId = await setup(ws)
|
|
76
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, {})
|
|
77
|
+
|
|
78
|
+
const first = await wire.do('step', 'someRpc', {}, { actor: yasser })
|
|
79
|
+
assert.deepEqual(first, { n: 1 })
|
|
80
|
+
|
|
81
|
+
// Simulate replay: reset per-run ordinals so the same logical step name
|
|
82
|
+
// resolves to the same durable step key.
|
|
83
|
+
;(ws as any).resetStepOrdinals(runId)
|
|
84
|
+
const replayWire = ws.createWorkflowWire('userFlowTest', runId, {})
|
|
85
|
+
const replayed = await replayWire.do('step', 'someRpc', {}, { actor: yasser })
|
|
86
|
+
|
|
87
|
+
assert.deepEqual(replayed, { n: 1 }, 'replay must return the cached result')
|
|
88
|
+
assert.equal(invocations, 1, 'the actor must not be re-invoked on replay')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('actor steps never queue, even when the function is queue-eligible', async () => {
|
|
92
|
+
const ws = new InMemoryWorkflowService()
|
|
93
|
+
let queued = 0
|
|
94
|
+
const customer = fakeActor('customer', async () => ({}))
|
|
95
|
+
const runId = await setup(ws, {
|
|
96
|
+
queueService: {
|
|
97
|
+
add: async () => {
|
|
98
|
+
queued++
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
// Function meta says this RPC would normally dispatch via the queue.
|
|
103
|
+
pikkuState(null, 'function', 'meta').queuedRpc = {
|
|
104
|
+
pikkuFuncId: 'queuedRpc',
|
|
105
|
+
workflowQueued: true,
|
|
106
|
+
} as any
|
|
107
|
+
|
|
108
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, {})
|
|
109
|
+
await wire.do('step', 'queuedRpc', {}, { actor: customer })
|
|
110
|
+
|
|
111
|
+
assert.equal(queued, 0, 'actor steps are outbound HTTP — never queued')
|
|
112
|
+
assert.equal(customer.calls.length, 1)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('actor step failure surfaces the actor error and fails after retries', async () => {
|
|
116
|
+
const ws = new InMemoryWorkflowService()
|
|
117
|
+
const broken = fakeActor('broken', async () => {
|
|
118
|
+
throw new Error("[user-flow] 'createTodo' as 'broken' returned 403: nope")
|
|
119
|
+
})
|
|
120
|
+
const runId = await setup(ws)
|
|
121
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, {})
|
|
122
|
+
|
|
123
|
+
await assert.rejects(
|
|
124
|
+
wire.do('step', 'createTodo', {}, { actor: broken, retries: 2 }),
|
|
125
|
+
/returned 403/
|
|
126
|
+
)
|
|
127
|
+
assert.equal(broken.calls.length, 2, 'retries bounds total attempts')
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('workflow.expectEventually', () => {
|
|
132
|
+
beforeEach(() => resetPikkuState())
|
|
133
|
+
|
|
134
|
+
test('polls as the actor until the predicate passes', async () => {
|
|
135
|
+
const ws = new InMemoryWorkflowService()
|
|
136
|
+
let polls = 0
|
|
137
|
+
const sarah = fakeActor('sarah', async () => ({
|
|
138
|
+
notifications: ++polls >= 3 ? ['ping'] : [],
|
|
139
|
+
}))
|
|
140
|
+
const runId = await setup(ws)
|
|
141
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, {})
|
|
142
|
+
|
|
143
|
+
const result = await wire.expectEventually(
|
|
144
|
+
'sarah sees the notification',
|
|
145
|
+
'getNotifications',
|
|
146
|
+
{},
|
|
147
|
+
(out: any) => out.notifications.length > 0,
|
|
148
|
+
{ actor: sarah, within: 2_000, interval: 5 }
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
assert.deepEqual(result, { notifications: ['ping'] })
|
|
152
|
+
assert.equal(polls, 3)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('fails with the last result when the deadline passes', async () => {
|
|
156
|
+
const ws = new InMemoryWorkflowService()
|
|
157
|
+
const sarah = fakeActor('sarah', async () => ({ notifications: [] }))
|
|
158
|
+
const runId = await setup(ws)
|
|
159
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, {})
|
|
160
|
+
|
|
161
|
+
await assert.rejects(
|
|
162
|
+
wire.expectEventually(
|
|
163
|
+
'never arrives',
|
|
164
|
+
'getNotifications',
|
|
165
|
+
{},
|
|
166
|
+
(out: any) => out.notifications.length > 0,
|
|
167
|
+
{ actor: sarah, within: 30, interval: 5, retries: 0 }
|
|
168
|
+
),
|
|
169
|
+
/did not pass within 30ms.*notifications/
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('polls internally (rpcWithWire) when no actor is given', async () => {
|
|
174
|
+
const ws = new InMemoryWorkflowService()
|
|
175
|
+
let polls = 0
|
|
176
|
+
const runId = await setup(ws)
|
|
177
|
+
const rpc = {
|
|
178
|
+
rpcWithWire: async () => ({ ready: ++polls >= 2 }),
|
|
179
|
+
}
|
|
180
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, rpc)
|
|
181
|
+
|
|
182
|
+
const result = await wire.expectEventually(
|
|
183
|
+
'job finishes',
|
|
184
|
+
'getJob',
|
|
185
|
+
{},
|
|
186
|
+
(out: any) => out.ready,
|
|
187
|
+
{ within: 2_000, interval: 5 }
|
|
188
|
+
)
|
|
189
|
+
assert.deepEqual(result, { ready: true })
|
|
190
|
+
assert.equal(polls, 2)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
@@ -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) */
|