@pikku/core 0.12.47 → 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.
@@ -0,0 +1,128 @@
1
+ import type {
2
+ UserFlowActor,
3
+ UserFlowActorConfig,
4
+ UserFlowActors,
5
+ } from './user-flow-actors-service.js'
6
+
7
+ export interface HttpUserFlowActorsConfig {
8
+ /**
9
+ * Base API URL of the target app, INCLUDING the HTTP prefix — e.g.
10
+ * `https://app.example.com/api` or `http://localhost:4000/api`. Actor
11
+ * sign-in is reached at `${apiUrl}${signInPath}` and exposed RPCs at
12
+ * `${apiUrl}${rpcPath}/:rpcName`.
13
+ */
14
+ apiUrl: string
15
+ /**
16
+ * The actor impersonation secret. Sign-in only ever works for user rows
17
+ * flagged `actor: true` — knowing the secret never impersonates real users.
18
+ */
19
+ secret: string
20
+ /** Actor name → config (usually from pikku.config.json's actor registry). */
21
+ actors: Record<string, UserFlowActorConfig>
22
+ /** Sign-in path under apiUrl. Default: the actor plugin's `/auth/sign-in/actor`. */
23
+ signInPath?: string
24
+ /** Exposed-RPC path prefix under apiUrl. Default `/rpc`. */
25
+ rpcPath?: string
26
+ }
27
+
28
+ /**
29
+ * Default HTTP-backed actor. Signs in lazily on first invoke via the Better
30
+ * Auth actor plugin (`POST /auth/sign-in/actor` with `{ email, secret }` —
31
+ * the plugin upserts the actor-flagged user row and mints a session whose
32
+ * `actor` flag flows into audits/analytics). Holds the session cookies for
33
+ * its lifetime; a 401 mid-run re-logs-in once (long health-check runs can
34
+ * outlive a session).
35
+ */
36
+ export class HttpUserFlowActor implements UserFlowActor {
37
+ private cookie: string | null = null
38
+ private origin: string
39
+
40
+ constructor(
41
+ readonly name: string,
42
+ private actorConfig: UserFlowActorConfig,
43
+ private config: HttpUserFlowActorsConfig
44
+ ) {
45
+ this.origin = new URL(config.apiUrl).origin
46
+ }
47
+
48
+ async invoke(rpcName: string, data: unknown): Promise<unknown> {
49
+ const cookie = this.cookie ?? (await this.login())
50
+ const res = await this.postRpc(rpcName, data, cookie)
51
+ if (res.status === 401) {
52
+ // Session expired mid-run — re-login once and retry.
53
+ this.cookie = null
54
+ return this.readRpcResponse(rpcName, await this.postRpc(rpcName, data, await this.login()))
55
+ }
56
+ return this.readRpcResponse(rpcName, res)
57
+ }
58
+
59
+ private async postRpc(rpcName: string, data: unknown, cookie: string) {
60
+ const rpcPath = this.config.rpcPath ?? '/rpc'
61
+ return fetch(`${this.config.apiUrl}${rpcPath}/${rpcName}`, {
62
+ method: 'POST',
63
+ headers: {
64
+ 'content-type': 'application/json',
65
+ origin: this.origin,
66
+ cookie,
67
+ },
68
+ body: JSON.stringify({ data }),
69
+ })
70
+ }
71
+
72
+ private async readRpcResponse(rpcName: string, res: Response) {
73
+ if (!res.ok) {
74
+ const body = (await res.text().catch(() => '')).slice(0, 300)
75
+ throw new Error(
76
+ `[user-flow] '${rpcName}' as '${this.name}' returned ${res.status}: ${body}`
77
+ )
78
+ }
79
+ if (res.status === 204) return undefined
80
+ const text = await res.text()
81
+ return text ? JSON.parse(text) : undefined
82
+ }
83
+
84
+ private async login(): Promise<string> {
85
+ const signInPath = this.config.signInPath ?? '/auth/sign-in/actor'
86
+ const res = await fetch(`${this.config.apiUrl}${signInPath}`, {
87
+ method: 'POST',
88
+ headers: { 'content-type': 'application/json', origin: this.origin },
89
+ body: JSON.stringify({
90
+ email: this.actorConfig.email,
91
+ name: this.actorConfig.name ?? this.name,
92
+ secret: this.config.secret,
93
+ }),
94
+ })
95
+ if (!res.ok) {
96
+ const body = (await res.text().catch(() => '')).slice(0, 300)
97
+ throw new Error(
98
+ `[user-flow] actor sign-in failed for '${this.name}' (${res.status}): ${body}`
99
+ )
100
+ }
101
+ const setCookies = res.headers.getSetCookie?.() ?? []
102
+ const cookie = setCookies
103
+ .map((c) => c.split(';')[0]!)
104
+ .filter(Boolean)
105
+ .join('; ')
106
+ if (!cookie) {
107
+ throw new Error(
108
+ `[user-flow] actor sign-in for '${this.name}' returned no session cookie`
109
+ )
110
+ }
111
+ this.cookie = cookie
112
+ return cookie
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Build the injected `actors` service from the config registry: actor name →
118
+ * lazy HTTP actor. Wire the result as the `actors` singleton service.
119
+ */
120
+ export function createHttpUserFlowActors(
121
+ config: HttpUserFlowActorsConfig
122
+ ): UserFlowActors {
123
+ const actors: UserFlowActors = {}
124
+ for (const [name, actorConfig] of Object.entries(config.actors)) {
125
+ actors[name] = new HttpUserFlowActor(name, actorConfig, config)
126
+ }
127
+ return actors
128
+ }
@@ -36,6 +36,16 @@ export type {
36
36
  WriteFileArgs,
37
37
  CopyFileArgs,
38
38
  } from './content-service.js'
39
+ export type {
40
+ UserFlowActor,
41
+ UserFlowActorConfig,
42
+ UserFlowActors,
43
+ } from './user-flow-actors-service.js'
44
+ export {
45
+ HttpUserFlowActor,
46
+ createHttpUserFlowActors,
47
+ type HttpUserFlowActorsConfig,
48
+ } from './http-user-flow-actors.js'
39
49
  export type { JWTService } from './jwt-service.js'
40
50
  export type {
41
51
  EmailService,
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A user-flow actor: a synthetic user (a normal user row flagged `actor`) that
3
+ * workflow steps can run as. Passed to `workflow.do(step, rpc, data, { actor })`
4
+ * — the step then goes through the actor's authenticated client over the REAL
5
+ * transport (auth middleware, permissions, serialization all exercised),
6
+ * never through internal dispatch. Login is lazy: the first `invoke` signs the
7
+ * actor in and the session is cached for the actor's lifetime.
8
+ */
9
+ export interface UserFlowActor {
10
+ /** Stable actor name (the key in pikku.config.json's actor registry). */
11
+ readonly name: string
12
+ /** Invoke an exposed RPC as this actor over the real transport. */
13
+ invoke(rpcName: string, data: unknown): Promise<unknown>
14
+ }
15
+
16
+ /**
17
+ * Display/config metadata for an actor (from pikku.config.json). The email
18
+ * identifies the actor's user row; personality/jobTitle exist for the console
19
+ * screen and for agent-driven flows (the agent plays the persona).
20
+ */
21
+ export interface UserFlowActorConfig {
22
+ email: string
23
+ name?: string
24
+ jobTitle?: string
25
+ personality?: string
26
+ }
27
+
28
+ /** The injected `actors` service: actor name → actor. */
29
+ export type UserFlowActors = Record<string, UserFlowActor>
@@ -29,6 +29,7 @@ import type { DeploymentService } from '../services/deployment-service.js'
29
29
  import type { AIStorageService } from '../services/ai-storage-service.js'
30
30
 
31
31
  import type { ContentService } from '../services/content-service.js'
32
+ import type { UserFlowActors } from '../services/user-flow-actors-service.js'
32
33
  import type { AIAgentRunnerService } from '../services/ai-agent-runner-service.js'
33
34
  import type { AIRunStateService } from '../services/ai-run-state-service.js'
34
35
  import type { AgentRunService } from '../wirings/ai-agent/ai-agent.types.js'
@@ -243,6 +244,8 @@ export type CoreConfig<Config extends Record<string, unknown> = {}> = {
243
244
  export interface CoreUserSession {
244
245
  userId?: string
245
246
  orgId?: string
247
+ /** True when the session belongs to a synthetic user-flow actor — lets audits/analytics address synthetic traffic */
248
+ actor?: boolean
246
249
  }
247
250
 
248
251
  /**
@@ -336,6 +339,8 @@ export type PikkuWire<
336
339
  queue: PikkuQueue
337
340
  cli: PikkuCLI
338
341
  workflow: TypedWorkflow
342
+ /** User-flow actor registry (user-flow runs only) — pass into workflow.do as `{ actor: actors.x }` */
343
+ actors: UserFlowActors
339
344
  workflowStep: WorkflowStepWire
340
345
  graph: PikkuGraphWire
341
346
  trigger: PikkuTrigger<TriggerOutput>
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { WorkflowRun } from '../workflow.types.js'
7
+ import type { UserFlowActor } from '../../../services/user-flow-actors-service.js'
7
8
 
8
9
  /**
9
10
  * Workflow step options
@@ -15,9 +16,27 @@ export interface WorkflowStepOptions {
15
16
  retries?: number
16
17
  /** Delay between retry attempts (e.g., '1s', '2s', '2min') */
17
18
  retryDelay?: string | number
19
+ /**
20
+ * Run this step as an actor (user flows). The RPC is sent through the
21
+ * actor's authenticated client over the REAL transport — never dispatched
22
+ * internally — so auth middleware and permissions are exercised end-to-end.
23
+ * The step is recorded durably like any RPC step.
24
+ */
25
+ actor?: UserFlowActor
18
26
  // Future: timeout, failFast, priority
19
27
  }
20
28
 
29
+ /**
30
+ * Options for workflow.expectEventually() — a durable polling step used by
31
+ * user flows to await async effects (a notification landing, a job finishing).
32
+ */
33
+ export interface WorkflowExpectEventuallyOptions extends WorkflowStepOptions {
34
+ /** Give up after this long (e.g. '30s'). Default '30s'. */
35
+ within?: string | number
36
+ /** Poll interval (e.g. '1s'). Default '1s'. */
37
+ interval?: string | number
38
+ }
39
+
21
40
  /**
22
41
  * Type signature for workflow.do() RPC form - used by inspector
23
42
  */
@@ -346,6 +365,18 @@ export interface PikkuWorkflowWire {
346
365
  /** Execute a workflow step (overloaded - RPC or inline form) */
347
366
  do: WorkflowWireDoRPC & WorkflowWireDoInline
348
367
 
368
+ /**
369
+ * Durable polling step (user flows): invoke `rpcName` (as an actor when
370
+ * `options.as` is set) until `predicate` passes or `options.within` elapses.
371
+ */
372
+ expectEventually: <TOutput = any, TInput = any>(
373
+ stepName: string,
374
+ rpcName: string,
375
+ data: TInput,
376
+ predicate: (output: TOutput) => boolean,
377
+ options?: WorkflowExpectEventuallyOptions
378
+ ) => Promise<TOutput>
379
+
349
380
  /** Sleep for a duration */
350
381
  sleep: WorkflowWireSleep
351
382
 
@@ -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) */