@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.
@@ -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.wire?.session,
1345
- rpc: rpcService.wire?.rpc,
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
- const dispatched = await this.dispatchStep(
1811
- runId,
1812
- stepName,
1813
- rpcName,
1814
- data,
1815
- resolvedStepOptions,
1816
- fromStepName
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) */