@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.
@@ -0,0 +1,115 @@
1
+ import { describe, test, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { createServer, type Server } from 'node:http'
4
+
5
+ import { createHttpUserFlowActors } from './http-user-flow-actors.js'
6
+
7
+ // Minimal target app: actor sign-in endpoint + exposed RPC endpoint, session
8
+ // via cookie. Mirrors the Better Auth actor plugin's contract.
9
+ const startTarget = async () => {
10
+ let logins = 0
11
+ let expireNext = false
12
+ const server: Server = createServer((req, res) => {
13
+ const chunks: Buffer[] = []
14
+ req.on('data', (c) => chunks.push(c))
15
+ req.on('end', () => {
16
+ const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {}
17
+ if (req.url === '/api/auth/sign-in/actor') {
18
+ if (body.secret !== 'impersonation-secret') {
19
+ res.writeHead(401).end(JSON.stringify({ message: 'bad actor secret' }))
20
+ return
21
+ }
22
+ logins++
23
+ res.setHeader('set-cookie', [
24
+ `session=s${logins}; Path=/; HttpOnly`,
25
+ `csrf=c${logins}; Path=/`,
26
+ ])
27
+ res.writeHead(200).end(JSON.stringify({ ok: true, email: body.email }))
28
+ return
29
+ }
30
+ if (req.url?.startsWith('/api/rpc/')) {
31
+ const cookie = req.headers.cookie ?? ''
32
+ if (!cookie.includes('session=') || expireNext) {
33
+ expireNext = false
34
+ res.writeHead(401).end()
35
+ return
36
+ }
37
+ res.writeHead(200, { 'content-type': 'application/json' }).end(
38
+ JSON.stringify({
39
+ rpcName: req.url.slice('/api/rpc/'.length),
40
+ echoed: body.data,
41
+ cookie,
42
+ })
43
+ )
44
+ return
45
+ }
46
+ res.writeHead(404).end()
47
+ })
48
+ })
49
+ await new Promise<void>((resolve) => server.listen(0, resolve))
50
+ const { port } = server.address() as { port: number }
51
+ return {
52
+ server,
53
+ apiUrl: `http://127.0.0.1:${port}/api`,
54
+ loginCount: () => logins,
55
+ expireSession: () => {
56
+ expireNext = true
57
+ },
58
+ }
59
+ }
60
+
61
+ describe('HttpUserFlowActor', async () => {
62
+ const target = await startTarget()
63
+ after(() => target.server.close())
64
+
65
+ const makeActors = (secret = 'impersonation-secret') =>
66
+ createHttpUserFlowActors({
67
+ apiUrl: target.apiUrl,
68
+ secret,
69
+ actors: {
70
+ customer: { email: 'customer@actors.local', jobTitle: 'Buyer' },
71
+ manager: { email: 'manager@actors.local' },
72
+ },
73
+ })
74
+
75
+ test('builds one lazy actor per config entry', () => {
76
+ const actors = makeActors()
77
+ assert.deepEqual(Object.keys(actors).sort(), ['customer', 'manager'])
78
+ assert.equal(actors.customer!.name, 'customer')
79
+ assert.equal(target.loginCount(), 0, 'no login until first invoke')
80
+ })
81
+
82
+ test('logs in lazily once and replays the session cookie on RPCs', async () => {
83
+ const actors = makeActors()
84
+
85
+ const first = (await actors.customer!.invoke('createTodo', { title: 'x' })) as any
86
+ const second = (await actors.customer!.invoke('listTodos', {})) as any
87
+
88
+ assert.equal(first.rpcName, 'createTodo')
89
+ assert.deepEqual(first.echoed, { title: 'x' })
90
+ assert.match(first.cookie, /session=s1/)
91
+ assert.match(first.cookie, /csrf=c1/)
92
+ assert.match(second.cookie, /session=s1/, 'session is cached, not re-minted')
93
+ assert.equal(target.loginCount(), 1)
94
+ })
95
+
96
+ test('re-logs-in once when the session expires mid-run', async () => {
97
+ const actors = makeActors()
98
+ await actors.manager!.invoke('ping', {})
99
+ const loginsBefore = target.loginCount()
100
+
101
+ target.expireSession()
102
+ const result = (await actors.manager!.invoke('ping', {})) as any
103
+
104
+ assert.equal(target.loginCount(), loginsBefore + 1, 'one re-login')
105
+ assert.match(result.cookie, new RegExp(`session=s${loginsBefore + 1}`))
106
+ })
107
+
108
+ test('a wrong impersonation secret surfaces status and body', async () => {
109
+ const actors = makeActors('wrong-secret')
110
+ await assert.rejects(
111
+ actors.customer!.invoke('ping', {}),
112
+ /actor sign-in failed for 'customer' \(401\).*bad actor secret/
113
+ )
114
+ })
115
+ })
@@ -0,0 +1,132 @@
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
+ get email(): string {
49
+ return this.actorConfig.email
50
+ }
51
+
52
+ async invoke(rpcName: string, data: unknown): Promise<unknown> {
53
+ const cookie = this.cookie ?? (await this.login())
54
+ const res = await this.postRpc(rpcName, data, cookie)
55
+ if (res.status === 401) {
56
+ // Session expired mid-run — re-login once and retry.
57
+ this.cookie = null
58
+ return this.readRpcResponse(rpcName, await this.postRpc(rpcName, data, await this.login()))
59
+ }
60
+ return this.readRpcResponse(rpcName, res)
61
+ }
62
+
63
+ private async postRpc(rpcName: string, data: unknown, cookie: string) {
64
+ const rpcPath = this.config.rpcPath ?? '/rpc'
65
+ return fetch(`${this.config.apiUrl}${rpcPath}/${rpcName}`, {
66
+ method: 'POST',
67
+ headers: {
68
+ 'content-type': 'application/json',
69
+ origin: this.origin,
70
+ cookie,
71
+ },
72
+ body: JSON.stringify({ data }),
73
+ })
74
+ }
75
+
76
+ private async readRpcResponse(rpcName: string, res: Response) {
77
+ if (!res.ok) {
78
+ const body = (await res.text().catch(() => '')).slice(0, 300)
79
+ throw new Error(
80
+ `[user-flow] '${rpcName}' as '${this.name}' returned ${res.status}: ${body}`
81
+ )
82
+ }
83
+ if (res.status === 204) return undefined
84
+ const text = await res.text()
85
+ return text ? JSON.parse(text) : undefined
86
+ }
87
+
88
+ private async login(): Promise<string> {
89
+ const signInPath = this.config.signInPath ?? '/auth/sign-in/actor'
90
+ const res = await fetch(`${this.config.apiUrl}${signInPath}`, {
91
+ method: 'POST',
92
+ headers: { 'content-type': 'application/json', origin: this.origin },
93
+ body: JSON.stringify({
94
+ email: this.actorConfig.email,
95
+ name: this.actorConfig.name ?? this.name,
96
+ secret: this.config.secret,
97
+ }),
98
+ })
99
+ if (!res.ok) {
100
+ const body = (await res.text().catch(() => '')).slice(0, 300)
101
+ throw new Error(
102
+ `[user-flow] actor sign-in failed for '${this.name}' (${res.status}): ${body}`
103
+ )
104
+ }
105
+ const setCookies = res.headers.getSetCookie?.() ?? []
106
+ const cookie = setCookies
107
+ .map((c) => c.split(';')[0]!)
108
+ .filter(Boolean)
109
+ .join('; ')
110
+ if (!cookie) {
111
+ throw new Error(
112
+ `[user-flow] actor sign-in for '${this.name}' returned no session cookie`
113
+ )
114
+ }
115
+ this.cookie = cookie
116
+ return cookie
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Build the injected `actors` service from the config registry: actor name →
122
+ * lazy HTTP actor. Wire the result as the `actors` singleton service.
123
+ */
124
+ export function createHttpUserFlowActors(
125
+ config: HttpUserFlowActorsConfig
126
+ ): UserFlowActors {
127
+ const actors: UserFlowActors = {}
128
+ for (const [name, actorConfig] of Object.entries(config.actors)) {
129
+ actors[name] = new HttpUserFlowActor(name, actorConfig, config)
130
+ }
131
+ return actors
132
+ }
@@ -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,
@@ -12,6 +12,7 @@ import type {
12
12
  MCPPromptMeta,
13
13
  } from '../wirings/mcp/mcp.types.js'
14
14
  import type { WorkflowsMeta } from '../wirings/workflow/workflow.types.js'
15
+ import type { UserFlowActorConfig } from './user-flow-actors-service.js'
15
16
  import type {
16
17
  TriggerMeta,
17
18
  TriggerSourceMeta,
@@ -170,6 +171,7 @@ export interface MetaService {
170
171
  getGatewayMeta(): Promise<GatewaysMeta>
171
172
  getRpcMeta(): Promise<RPCMetaRecord>
172
173
  getWorkflowMeta(): Promise<WorkflowsMeta>
174
+ getUserFlowActorsMeta(): Promise<Record<string, UserFlowActorConfig>>
173
175
  getTriggerMeta(): Promise<TriggerMeta>
174
176
  getTriggerSourceMeta(): Promise<TriggerSourceMeta>
175
177
  getFunctionsMeta(): Promise<FunctionsMeta>
@@ -208,6 +210,8 @@ export class LocalMetaService implements MetaService {
208
210
  private gatewayMetaCache: GatewaysMeta | null = null
209
211
  private rpcMetaCache: RPCMetaRecord | null = null
210
212
  private workflowMetaCache: WorkflowsMeta | null = null
213
+ private userFlowActorsMetaCache: Record<string, UserFlowActorConfig> | null =
214
+ null
211
215
  private triggerMetaCache: TriggerMeta | null = null
212
216
  private triggerSourceMetaCache: TriggerSourceMeta | null = null
213
217
  private functionsMetaCache: FunctionsMeta | null = null
@@ -258,6 +262,7 @@ export class LocalMetaService implements MetaService {
258
262
  this.gatewayMetaCache = null
259
263
  this.rpcMetaCache = null
260
264
  this.workflowMetaCache = null
265
+ this.userFlowActorsMetaCache = null
261
266
  this.triggerMetaCache = null
262
267
  this.triggerSourceMetaCache = null
263
268
  this.functionsMetaCache = null
@@ -424,6 +429,14 @@ export class LocalMetaService implements MetaService {
424
429
  }
425
430
  }
426
431
 
432
+ async getUserFlowActorsMeta(): Promise<Record<string, UserFlowActorConfig>> {
433
+ if (this.userFlowActorsMetaCache) return this.userFlowActorsMetaCache
434
+
435
+ const content = await this.readFile('workflow/user-flow-actors.gen.json')
436
+ this.userFlowActorsMetaCache = content ? JSON.parse(content) : {}
437
+ return this.userFlowActorsMetaCache!
438
+ }
439
+
427
440
  async getTriggerMeta(): Promise<TriggerMeta> {
428
441
  if (this.triggerMetaCache) return this.triggerMetaCache
429
442
 
@@ -0,0 +1,31 @@
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
+ /** The actor's user email — flows use it for invites/lookups. */
13
+ readonly email: string
14
+ /** Invoke an exposed RPC as this actor over the real transport. */
15
+ invoke(rpcName: string, data: unknown): Promise<unknown>
16
+ }
17
+
18
+ /**
19
+ * Display/config metadata for an actor (from pikku.config.json). The email
20
+ * identifies the actor's user row; personality/jobTitle exist for the console
21
+ * screen and for agent-driven flows (the agent plays the persona).
22
+ */
23
+ export interface UserFlowActorConfig {
24
+ email: string
25
+ name?: string
26
+ jobTitle?: string
27
+ personality?: string
28
+ }
29
+
30
+ /** The injected `actors` service: actor name → actor. */
31
+ 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
  */
@@ -90,6 +109,10 @@ export interface RpcStepMeta {
90
109
  inputs?: Record<string, InputSource> | 'passthrough'
91
110
  /** Step options */
92
111
  options?: WorkflowStepOptions
112
+ /** User-flow actor name this step runs as ({ actor: actors.x }) */
113
+ actor?: string
114
+ /** True for workflow.expectEventually polling steps */
115
+ expectEventually?: boolean
93
116
  }
94
117
 
95
118
  /**
@@ -346,6 +369,18 @@ export interface PikkuWorkflowWire {
346
369
  /** Execute a workflow step (overloaded - RPC or inline form) */
347
370
  do: WorkflowWireDoRPC & WorkflowWireDoInline
348
371
 
372
+ /**
373
+ * Durable polling step (user flows): invoke `rpcName` (as an actor when
374
+ * `options.as` is set) until `predicate` passes or `options.within` elapses.
375
+ */
376
+ expectEventually: <TOutput = any, TInput = any>(
377
+ stepName: string,
378
+ rpcName: string,
379
+ data: TInput,
380
+ predicate: (output: TOutput) => boolean,
381
+ options?: WorkflowExpectEventuallyOptions
382
+ ) => Promise<TOutput>
383
+
349
384
  /** Sleep for a duration */
350
385
  sleep: WorkflowWireSleep
351
386
 
@@ -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)