@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 CHANGED
@@ -1,3 +1,55 @@
1
+ ## 0.12.50
2
+
3
+ ### Patch Changes
4
+
5
+ - 35a9bab: UserFlowActor exposes the actor's `email` so flows can use it for
6
+ invites/lookups instead of hardcoding the config value.
7
+ - 92bd643: User flows in the console: workflow graph extraction now captures
8
+ `workflow.expectEventually` steps and per-step actor names (`{ actor:
9
+ actors.x }`), workflow meta carries `actors`/`title` into the serialized
10
+ graph, the CLI emits `user-flow-actors.gen.json` for the new
11
+ `MetaService.getUserFlowActorsMeta()`, and the console Workflows page gains a
12
+ Workflows / User Flows / Personas toggle. Also fixes complex-workflow graphs
13
+ being clobbered by a duplicate basic-extraction pass after successful DSL
14
+ extraction.
15
+
16
+ ## 0.12.49
17
+
18
+ ### Patch Changes
19
+
20
+ - 4c17f7e: user flows: actors move onto the workflow wire + `pikku userflow` command
21
+ - Actors are no longer a singleton service: `startWorkflow(..., { actors })`
22
+ registers them per run and they arrive on the wire —
23
+ `func: async ({ logger }, input, { workflow, actors })`.
24
+ - Inspector enforces user flows are pure remote stories (PKU673): a
25
+ pikkuUserFlow func may only destructure `logger`/`config` from services.
26
+ - New `pikku userflow run <environment> [--flows a,b] [--tags x,y]` runs flows
27
+ against `userFlows.environments` from pikku.config.json (secret from
28
+ USER_FLOW_ACTOR_SECRET env), refusing internal (non-actor) steps so runs
29
+ against staging/production never touch local services; non-zero exit on
30
+ failure. `pikku userflow list` prints names, descriptions and tags.
31
+ - Workflow meta now carries `title` (parity with HTTP routes/functions).
32
+
33
+ ## 0.12.48
34
+
35
+ ### Patch Changes
36
+
37
+ - 5f2c566: Better Auth actor plugin for user flows: `actor({ secret })` adds an `actor`
38
+ boolean column on `user` and a `POST /sign-in/actor` endpoint (`{ email,
39
+ secret }`, constant-time compare). Actor rows are auto-created on first
40
+ sign-in; a real (non-actor) user can never be impersonated with the secret.
41
+ The flag propagates into the pikku core session (`CoreUserSession.actor`) via
42
+ both `betterAuthSession` and `betterAuthStatelessSession`, so audits and
43
+ analytics can address synthetic traffic.
44
+ - 8dfddc3: pikkuUserFlow: user flows as workflows. A complex workflow whose steps can run
45
+ as actors over the real transport — `workflow.do(step, rpc, data, { actor:
46
+ actors.yasser })` — plus `workflow.expectEventually(...)` for polling async
47
+ effects. Actor steps never queue and never dispatch internally, so auth
48
+ middleware/permissions are exercised end-to-end; flows double as e2e tests and
49
+ staged/production health checks. Ships UserFlowActor types +
50
+ createHttpUserFlowActors (lazy sign-in via `/auth/sign-in/actor` with a
51
+ server-held secret), inspector source `'user-flow'`, and a console badge.
52
+
1
53
  ## 0.12.47
2
54
 
3
55
  ### Patch Changes
@@ -0,0 +1,47 @@
1
+ import type { UserFlowActor, UserFlowActorConfig, UserFlowActors } from './user-flow-actors-service.js';
2
+ export interface HttpUserFlowActorsConfig {
3
+ /**
4
+ * Base API URL of the target app, INCLUDING the HTTP prefix — e.g.
5
+ * `https://app.example.com/api` or `http://localhost:4000/api`. Actor
6
+ * sign-in is reached at `${apiUrl}${signInPath}` and exposed RPCs at
7
+ * `${apiUrl}${rpcPath}/:rpcName`.
8
+ */
9
+ apiUrl: string;
10
+ /**
11
+ * The actor impersonation secret. Sign-in only ever works for user rows
12
+ * flagged `actor: true` — knowing the secret never impersonates real users.
13
+ */
14
+ secret: string;
15
+ /** Actor name → config (usually from pikku.config.json's actor registry). */
16
+ actors: Record<string, UserFlowActorConfig>;
17
+ /** Sign-in path under apiUrl. Default: the actor plugin's `/auth/sign-in/actor`. */
18
+ signInPath?: string;
19
+ /** Exposed-RPC path prefix under apiUrl. Default `/rpc`. */
20
+ rpcPath?: string;
21
+ }
22
+ /**
23
+ * Default HTTP-backed actor. Signs in lazily on first invoke via the Better
24
+ * Auth actor plugin (`POST /auth/sign-in/actor` with `{ email, secret }` —
25
+ * the plugin upserts the actor-flagged user row and mints a session whose
26
+ * `actor` flag flows into audits/analytics). Holds the session cookies for
27
+ * its lifetime; a 401 mid-run re-logs-in once (long health-check runs can
28
+ * outlive a session).
29
+ */
30
+ export declare class HttpUserFlowActor implements UserFlowActor {
31
+ readonly name: string;
32
+ private actorConfig;
33
+ private config;
34
+ private cookie;
35
+ private origin;
36
+ constructor(name: string, actorConfig: UserFlowActorConfig, config: HttpUserFlowActorsConfig);
37
+ get email(): string;
38
+ invoke(rpcName: string, data: unknown): Promise<unknown>;
39
+ private postRpc;
40
+ private readRpcResponse;
41
+ private login;
42
+ }
43
+ /**
44
+ * Build the injected `actors` service from the config registry: actor name →
45
+ * lazy HTTP actor. Wire the result as the `actors` singleton service.
46
+ */
47
+ export declare function createHttpUserFlowActors(config: HttpUserFlowActorsConfig): UserFlowActors;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Default HTTP-backed actor. Signs in lazily on first invoke via the Better
3
+ * Auth actor plugin (`POST /auth/sign-in/actor` with `{ email, secret }` —
4
+ * the plugin upserts the actor-flagged user row and mints a session whose
5
+ * `actor` flag flows into audits/analytics). Holds the session cookies for
6
+ * its lifetime; a 401 mid-run re-logs-in once (long health-check runs can
7
+ * outlive a session).
8
+ */
9
+ export class HttpUserFlowActor {
10
+ name;
11
+ actorConfig;
12
+ config;
13
+ cookie = null;
14
+ origin;
15
+ constructor(name, actorConfig, config) {
16
+ this.name = name;
17
+ this.actorConfig = actorConfig;
18
+ this.config = config;
19
+ this.origin = new URL(config.apiUrl).origin;
20
+ }
21
+ get email() {
22
+ return this.actorConfig.email;
23
+ }
24
+ async invoke(rpcName, data) {
25
+ const cookie = this.cookie ?? (await this.login());
26
+ const res = await this.postRpc(rpcName, data, cookie);
27
+ if (res.status === 401) {
28
+ // Session expired mid-run — re-login once and retry.
29
+ this.cookie = null;
30
+ return this.readRpcResponse(rpcName, await this.postRpc(rpcName, data, await this.login()));
31
+ }
32
+ return this.readRpcResponse(rpcName, res);
33
+ }
34
+ async postRpc(rpcName, data, cookie) {
35
+ const rpcPath = this.config.rpcPath ?? '/rpc';
36
+ return fetch(`${this.config.apiUrl}${rpcPath}/${rpcName}`, {
37
+ method: 'POST',
38
+ headers: {
39
+ 'content-type': 'application/json',
40
+ origin: this.origin,
41
+ cookie,
42
+ },
43
+ body: JSON.stringify({ data }),
44
+ });
45
+ }
46
+ async readRpcResponse(rpcName, res) {
47
+ if (!res.ok) {
48
+ const body = (await res.text().catch(() => '')).slice(0, 300);
49
+ throw new Error(`[user-flow] '${rpcName}' as '${this.name}' returned ${res.status}: ${body}`);
50
+ }
51
+ if (res.status === 204)
52
+ return undefined;
53
+ const text = await res.text();
54
+ return text ? JSON.parse(text) : undefined;
55
+ }
56
+ async login() {
57
+ const signInPath = this.config.signInPath ?? '/auth/sign-in/actor';
58
+ const res = await fetch(`${this.config.apiUrl}${signInPath}`, {
59
+ method: 'POST',
60
+ headers: { 'content-type': 'application/json', origin: this.origin },
61
+ body: JSON.stringify({
62
+ email: this.actorConfig.email,
63
+ name: this.actorConfig.name ?? this.name,
64
+ secret: this.config.secret,
65
+ }),
66
+ });
67
+ if (!res.ok) {
68
+ const body = (await res.text().catch(() => '')).slice(0, 300);
69
+ throw new Error(`[user-flow] actor sign-in failed for '${this.name}' (${res.status}): ${body}`);
70
+ }
71
+ const setCookies = res.headers.getSetCookie?.() ?? [];
72
+ const cookie = setCookies
73
+ .map((c) => c.split(';')[0])
74
+ .filter(Boolean)
75
+ .join('; ');
76
+ if (!cookie) {
77
+ throw new Error(`[user-flow] actor sign-in for '${this.name}' returned no session cookie`);
78
+ }
79
+ this.cookie = cookie;
80
+ return cookie;
81
+ }
82
+ }
83
+ /**
84
+ * Build the injected `actors` service from the config registry: actor name →
85
+ * lazy HTTP actor. Wire the result as the `actors` singleton service.
86
+ */
87
+ export function createHttpUserFlowActors(config) {
88
+ const actors = {};
89
+ for (const [name, actorConfig] of Object.entries(config.actors)) {
90
+ actors[name] = new HttpUserFlowActor(name, actorConfig, config);
91
+ }
92
+ return actors;
93
+ }
@@ -18,6 +18,8 @@ export { InMemoryTriggerService } from './in-memory-trigger-service.js';
18
18
  export { InMemoryAIRunStateService } from './in-memory-ai-run-state-service.js';
19
19
  export { LocalGatewayService } from './local-gateway-service.js';
20
20
  export type { ContentService, SignContentKeyArgs, SignURLArgs, GetUploadURLArgs, UploadURLResult, BucketKeyArgs, WriteFileArgs, CopyFileArgs, } from './content-service.js';
21
+ export type { UserFlowActor, UserFlowActorConfig, UserFlowActors, } from './user-flow-actors-service.js';
22
+ export { HttpUserFlowActor, createHttpUserFlowActors, type HttpUserFlowActorsConfig, } from './http-user-flow-actors.js';
21
23
  export type { JWTService } from './jwt-service.js';
22
24
  export type { EmailService, EmailTemplateReference, SendEmailInput, SendEmailResult, SendHTMLEmailInput, SendTemplateEmailInput, SendTextEmailInput, } from './email-service.js';
23
25
  export type { Logger } from './logger.js';
@@ -17,6 +17,7 @@ export { InMemoryQueueService } from './in-memory-queue-service.js';
17
17
  export { InMemoryTriggerService } from './in-memory-trigger-service.js';
18
18
  export { InMemoryAIRunStateService } from './in-memory-ai-run-state-service.js';
19
19
  export { LocalGatewayService } from './local-gateway-service.js';
20
+ export { HttpUserFlowActor, createHttpUserFlowActors, } from './http-user-flow-actors.js';
20
21
  export { TypedCredentialService } from './typed-credential-service.js';
21
22
  export { NoopAuditService, createInvocationAudit, resolveAuditActorFromWire, resolveAuditConfig, } from './audit-service.js';
22
23
  export { InMemorySessionStore } from './in-memory-session-store.js';
@@ -6,6 +6,7 @@ import type { QueueWorkersMeta } from '../wirings/queue/queue.types.js';
6
6
  import type { CLIMeta } from '../wirings/cli/cli.types.js';
7
7
  import type { MCPResourceMeta, MCPToolMeta, MCPPromptMeta } from '../wirings/mcp/mcp.types.js';
8
8
  import type { WorkflowsMeta } from '../wirings/workflow/workflow.types.js';
9
+ import type { UserFlowActorConfig } from './user-flow-actors-service.js';
9
10
  import type { TriggerMeta, TriggerSourceMeta } from '../wirings/trigger/trigger.types.js';
10
11
  import type { SecretDefinitionsMeta } from '../wirings/secret/secret.types.js';
11
12
  import type { CredentialDefinitionsMeta } from '../wirings/credential/credential.types.js';
@@ -140,6 +141,7 @@ export interface MetaService {
140
141
  getGatewayMeta(): Promise<GatewaysMeta>;
141
142
  getRpcMeta(): Promise<RPCMetaRecord>;
142
143
  getWorkflowMeta(): Promise<WorkflowsMeta>;
144
+ getUserFlowActorsMeta(): Promise<Record<string, UserFlowActorConfig>>;
143
145
  getTriggerMeta(): Promise<TriggerMeta>;
144
146
  getTriggerSourceMeta(): Promise<TriggerSourceMeta>;
145
147
  getFunctionsMeta(): Promise<FunctionsMeta>;
@@ -171,6 +173,7 @@ export declare class LocalMetaService implements MetaService {
171
173
  private gatewayMetaCache;
172
174
  private rpcMetaCache;
173
175
  private workflowMetaCache;
176
+ private userFlowActorsMetaCache;
174
177
  private triggerMetaCache;
175
178
  private triggerSourceMetaCache;
176
179
  private functionsMetaCache;
@@ -197,6 +200,7 @@ export declare class LocalMetaService implements MetaService {
197
200
  getGatewayMeta(): Promise<GatewaysMeta>;
198
201
  getRpcMeta(): Promise<RPCMetaRecord>;
199
202
  getWorkflowMeta(): Promise<WorkflowsMeta>;
203
+ getUserFlowActorsMeta(): Promise<Record<string, UserFlowActorConfig>>;
200
204
  getTriggerMeta(): Promise<TriggerMeta>;
201
205
  getTriggerSourceMeta(): Promise<TriggerSourceMeta>;
202
206
  getFunctionsMeta(): Promise<FunctionsMeta>;
@@ -15,6 +15,7 @@ export class LocalMetaService {
15
15
  gatewayMetaCache = null;
16
16
  rpcMetaCache = null;
17
17
  workflowMetaCache = null;
18
+ userFlowActorsMetaCache = null;
18
19
  triggerMetaCache = null;
19
20
  triggerSourceMetaCache = null;
20
21
  functionsMetaCache = null;
@@ -63,6 +64,7 @@ export class LocalMetaService {
63
64
  this.gatewayMetaCache = null;
64
65
  this.rpcMetaCache = null;
65
66
  this.workflowMetaCache = null;
67
+ this.userFlowActorsMetaCache = null;
66
68
  this.triggerMetaCache = null;
67
69
  this.triggerSourceMetaCache = null;
68
70
  this.functionsMetaCache = null;
@@ -201,6 +203,13 @@ export class LocalMetaService {
201
203
  return this.workflowMetaCache;
202
204
  }
203
205
  }
206
+ async getUserFlowActorsMeta() {
207
+ if (this.userFlowActorsMetaCache)
208
+ return this.userFlowActorsMetaCache;
209
+ const content = await this.readFile('workflow/user-flow-actors.gen.json');
210
+ this.userFlowActorsMetaCache = content ? JSON.parse(content) : {};
211
+ return this.userFlowActorsMetaCache;
212
+ }
204
213
  async getTriggerMeta() {
205
214
  if (this.triggerMetaCache)
206
215
  return this.triggerMetaCache;
@@ -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
+ /** 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
+ * Display/config metadata for an actor (from pikku.config.json). The email
19
+ * identifies the actor's user row; personality/jobTitle exist for the console
20
+ * screen and for agent-driven flows (the agent plays the persona).
21
+ */
22
+ export interface UserFlowActorConfig {
23
+ email: string;
24
+ name?: string;
25
+ jobTitle?: string;
26
+ personality?: string;
27
+ }
28
+ /** The injected `actors` service: actor name → actor. */
29
+ export type UserFlowActors = Record<string, UserFlowActor>;
@@ -0,0 +1 @@
1
+ export {};
@@ -19,6 +19,7 @@ import type { SchedulerService } from '../services/scheduler-service.js';
19
19
  import type { DeploymentService } from '../services/deployment-service.js';
20
20
  import type { AIStorageService } from '../services/ai-storage-service.js';
21
21
  import type { ContentService } from '../services/content-service.js';
22
+ import type { UserFlowActors } from '../services/user-flow-actors-service.js';
22
23
  import type { AIAgentRunnerService } from '../services/ai-agent-runner-service.js';
23
24
  import type { AIRunStateService } from '../services/ai-run-state-service.js';
24
25
  import type { AgentRunService } from '../wirings/ai-agent/ai-agent.types.js';
@@ -185,6 +186,8 @@ export type CoreConfig<Config extends Record<string, unknown> = {}> = {
185
186
  export interface CoreUserSession {
186
187
  userId?: string;
187
188
  orgId?: string;
189
+ /** True when the session belongs to a synthetic user-flow actor — lets audits/analytics address synthetic traffic */
190
+ actor?: boolean;
188
191
  }
189
192
  /**
190
193
  * Interface for core singleton services provided by Pikku.
@@ -263,6 +266,8 @@ export type PikkuWire<In = unknown, Out = unknown, HasInitialSession extends boo
263
266
  queue: PikkuQueue;
264
267
  cli: PikkuCLI;
265
268
  workflow: TypedWorkflow;
269
+ /** User-flow actor registry (user-flow runs only) — pass into workflow.do as `{ actor: actors.x }` */
270
+ actors: UserFlowActors;
266
271
  workflowStep: WorkflowStepWire;
267
272
  graph: PikkuGraphWire;
268
273
  trigger: PikkuTrigger<TriggerOutput>;
@@ -3,6 +3,7 @@
3
3
  * These types define the step-based workflow format extracted by the inspector
4
4
  */
5
5
  import type { WorkflowRun } from '../workflow.types.js';
6
+ import type { UserFlowActor } from '../../../services/user-flow-actors-service.js';
6
7
  /**
7
8
  * Workflow step options
8
9
  */
@@ -13,6 +14,23 @@ export interface WorkflowStepOptions {
13
14
  retries?: number;
14
15
  /** Delay between retry attempts (e.g., '1s', '2s', '2min') */
15
16
  retryDelay?: string | number;
17
+ /**
18
+ * Run this step as an actor (user flows). The RPC is sent through the
19
+ * actor's authenticated client over the REAL transport — never dispatched
20
+ * internally — so auth middleware and permissions are exercised end-to-end.
21
+ * The step is recorded durably like any RPC step.
22
+ */
23
+ actor?: UserFlowActor;
24
+ }
25
+ /**
26
+ * Options for workflow.expectEventually() — a durable polling step used by
27
+ * user flows to await async effects (a notification landing, a job finishing).
28
+ */
29
+ export interface WorkflowExpectEventuallyOptions extends WorkflowStepOptions {
30
+ /** Give up after this long (e.g. '30s'). Default '30s'. */
31
+ within?: string | number;
32
+ /** Poll interval (e.g. '1s'). Default '1s'. */
33
+ interval?: string | number;
16
34
  }
17
35
  /**
18
36
  * Type signature for workflow.do() RPC form - used by inspector
@@ -92,6 +110,10 @@ export interface RpcStepMeta {
92
110
  inputs?: Record<string, InputSource> | 'passthrough';
93
111
  /** Step options */
94
112
  options?: WorkflowStepOptions;
113
+ /** User-flow actor name this step runs as ({ actor: actors.x }) */
114
+ actor?: string;
115
+ /** True for workflow.expectEventually polling steps */
116
+ expectEventually?: boolean;
95
117
  }
96
118
  /**
97
119
  * Simple condition expression (leaf node)
@@ -317,6 +339,11 @@ export interface PikkuWorkflowWire {
317
339
  getRun: () => Promise<WorkflowRun>;
318
340
  /** Execute a workflow step (overloaded - RPC or inline form) */
319
341
  do: WorkflowWireDoRPC & WorkflowWireDoInline;
342
+ /**
343
+ * Durable polling step (user flows): invoke `rpcName` (as an actor when
344
+ * `options.as` is set) until `predicate` passes or `options.within` elapses.
345
+ */
346
+ expectEventually: <TOutput = any, TInput = any>(stepName: string, rpcName: string, data: TInput, predicate: (output: TOutput) => boolean, options?: WorkflowExpectEventuallyOptions) => Promise<TOutput>;
320
347
  /** Sleep for a duration */
321
348
  sleep: WorkflowWireSleep;
322
349
  /** Suspend workflow until explicitly resumed */
@@ -1,6 +1,7 @@
1
1
  import type { SerializedError } from '../../types/core.types.js';
2
2
  import type { PikkuWorkflowWire, StepState, StepStatus, WorkflowPlannedStep, WorkflowRun, WorkflowRunMirror, WorkflowRunStatus, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from './workflow.types.js';
3
3
  import type { WorkflowService } from '../../services/workflow-service.js';
4
+ import type { UserFlowActors } from '../../services/user-flow-actors-service.js';
4
5
  import { PikkuError } from '../../errors/error-handler.js';
5
6
  import { type RunTimeline, type ReconstructedRunState } from './run-timeline.js';
6
7
  import type { JobOptions } from '../queue/queue.types.js';
@@ -81,6 +82,7 @@ export declare class WorkflowStepNameNotString extends Error {
81
82
  */
82
83
  export declare abstract class PikkuWorkflowService implements WorkflowService {
83
84
  private inlineRuns;
85
+ private runActors;
84
86
  protected get logger(): import("../../services/logger.js").Logger;
85
87
  protected mirror?: WorkflowRunMirror;
86
88
  constructor(options?: {
@@ -373,6 +375,7 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
373
375
  startWorkflow<I>(name: string, input: I, wire: WorkflowRunWire, rpcService: any, options?: {
374
376
  inline?: boolean;
375
377
  startNode?: string;
378
+ actors?: UserFlowActors;
376
379
  }): Promise<{
377
380
  runId: string;
378
381
  }>;
@@ -160,6 +160,9 @@ const WORKFLOW_END_STATES = new Set([
160
160
  */
161
161
  export class PikkuWorkflowService {
162
162
  inlineRuns = new Set();
163
+ // User-flow actors per run: live authenticated clients (cookie jars) are
164
+ // process-local by nature, so they ride this map, never the persisted wire.
165
+ runActors = new Map();
163
166
  get logger() {
164
167
  return getSingletonServices()?.logger;
165
168
  }
@@ -638,6 +641,9 @@ export class PikkuWorkflowService {
638
641
  deterministic: workflowMeta.deterministic,
639
642
  plannedSteps: workflowMeta.plannedSteps,
640
643
  });
644
+ if (options?.actors) {
645
+ this.runActors.set(runId, options.actors);
646
+ }
641
647
  if (shouldInline) {
642
648
  this.inlineRuns.add(runId);
643
649
  try {
@@ -670,6 +676,7 @@ export class PikkuWorkflowService {
670
676
  }
671
677
  finally {
672
678
  this.inlineRuns.delete(runId);
679
+ this.runActors.delete(runId);
673
680
  }
674
681
  }
675
682
  else {
@@ -795,8 +802,10 @@ export class PikkuWorkflowService {
795
802
  const wire = {
796
803
  workflow: workflowWire,
797
804
  pikkuUserId: run.wire?.pikkuUserId,
798
- session: rpcService.wire?.session,
799
- rpc: rpcService.wire?.rpc,
805
+ session: rpcService?.wire?.session,
806
+ rpc: rpcService?.wire?.rpc,
807
+ // User-flow actors registered for this run (see startWorkflow options)
808
+ actors: this.runActors.get(runId),
800
809
  };
801
810
  try {
802
811
  const result = await runPikkuFunc('workflow', workflowMeta.name, workflowMeta.pikkuFuncId, {
@@ -1101,6 +1110,7 @@ export class PikkuWorkflowService {
1101
1110
  const resolvedStepOptions = {
1102
1111
  retries: stepOptions?.retries ?? DEFAULT_STEP_RETRIES,
1103
1112
  retryDelay: stepOptions?.retryDelay,
1113
+ actor: stepOptions?.actor,
1104
1114
  };
1105
1115
  // Check if step already exists
1106
1116
  let stepState;
@@ -1136,7 +1146,11 @@ export class PikkuWorkflowService {
1136
1146
  // so the orchestrator's next replay re-dispatches it. Marking `scheduled`
1137
1147
  // first would strand the step (replay sees `scheduled`, pauses, never
1138
1148
  // re-enqueues the job that was never created).
1139
- const dispatched = await this.dispatchStep(runId, stepName, rpcName, data, resolvedStepOptions, fromStepName);
1149
+ // Actor steps never queue: they are outbound HTTP calls made by the
1150
+ // runner itself, and the actor's session lives on this process.
1151
+ const dispatched = resolvedStepOptions.actor
1152
+ ? false
1153
+ : await this.dispatchStep(runId, stepName, rpcName, data, resolvedStepOptions, fromStepName);
1140
1154
  if (dispatched) {
1141
1155
  await this.setStepScheduled(stepState.stepId);
1142
1156
  throw new WorkflowAsyncException(runId, stepName);
@@ -1146,6 +1160,12 @@ export class PikkuWorkflowService {
1146
1160
  const retries = resolvedStepOptions.retries ?? this.getConfig().retries;
1147
1161
  const retryDelay = resolvedStepOptions.retryDelay;
1148
1162
  return this.runInlineRetryLoop(stepState, retries, retryDelay, async (currentStepState) => {
1163
+ // Actor step: send through the actor's authenticated client over the
1164
+ // REAL transport. Never falls back to internal dispatch — that would
1165
+ // bypass auth and fake a green health check.
1166
+ if (resolvedStepOptions.actor) {
1167
+ return resolvedStepOptions.actor.invoke(rpcName, data);
1168
+ }
1149
1169
  // Check if the name refers to a workflow
1150
1170
  const workflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName];
1151
1171
  if (workflowMeta) {
@@ -1339,6 +1359,34 @@ export class PikkuWorkflowService {
1339
1359
  return await this.inlineStep(runId, stepName, rpcNameOrFn, dataOrOptions);
1340
1360
  }
1341
1361
  },
1362
+ // Durable polling step: invoke an RPC (as an actor when options.as is
1363
+ // set) until the predicate passes or `within` elapses. The whole poll is
1364
+ // ONE recorded step, so replay returns the cached outcome.
1365
+ expectEventually: async (stepName, rpcName, data, predicate, options) => {
1366
+ this.verifyStepName(stepName);
1367
+ const resolvedRpcName = addonNamespace && !rpcName.includes(':')
1368
+ ? `${addonNamespace}:${rpcName}`
1369
+ : rpcName;
1370
+ const within = getDurationInMilliseconds(options?.within ?? '30s');
1371
+ const interval = getDurationInMilliseconds(options?.interval ?? '1s');
1372
+ return await this.inlineStep(runId, stepName, async () => {
1373
+ const deadline = Date.now() + within;
1374
+ let last;
1375
+ while (true) {
1376
+ last = options?.actor
1377
+ ? await options.actor.invoke(resolvedRpcName, data)
1378
+ : await rpcService.rpcWithWire(resolvedRpcName, data, {});
1379
+ if (predicate(last))
1380
+ return last;
1381
+ if (Date.now() + interval > deadline) {
1382
+ throw new Error(`[workflow] expectEventually '${stepName}' ('${resolvedRpcName}'` +
1383
+ `${options?.actor ? ` as '${options.actor.name}'` : ''}) did not pass within ${within}ms; ` +
1384
+ `last result: ${JSON.stringify(last)?.slice(0, 300)}`);
1385
+ }
1386
+ await new Promise((resolve) => setTimeout(resolve, interval));
1387
+ }
1388
+ }, options);
1389
+ },
1342
1390
  // Implement workflow.sleep()
1343
1391
  sleep: async (stepName, duration) => {
1344
1392
  this.verifyStepName(stepName);
@@ -1,7 +1,7 @@
1
1
  import type { SerializedError, CommonWireMeta } from '../../types/core.types.js';
2
2
  import type { CorePikkuFunctionConfig } from '../../function/functions.types.js';
3
3
  export type { WorkflowService } from '../../services/workflow-service.js';
4
- export type { WorkflowStepOptions, WorkflowWireDoRPC, WorkflowWireDoInline, WorkflowWireSleep, WorkflowWireSuspend, InputSource, OutputBinding, RpcStepMeta, SimpleCondition, Condition, BranchCase, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, } from './dsl/workflow-dsl.types.js';
4
+ export type { WorkflowStepOptions, WorkflowExpectEventuallyOptions, WorkflowWireDoRPC, WorkflowWireDoInline, WorkflowWireSleep, WorkflowWireSuspend, InputSource, OutputBinding, RpcStepMeta, SimpleCondition, Condition, BranchCase, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, } from './dsl/workflow-dsl.types.js';
5
5
  import type { WorkflowStepMeta } from './dsl/workflow-dsl.types.js';
6
6
  export interface WorkflowRunWire {
7
7
  type: string;
@@ -236,6 +236,10 @@ export type WorkflowsMeta = Record<string, CommonWireMeta & {
236
236
  context?: WorkflowContext;
237
237
  dsl?: boolean;
238
238
  expose?: boolean;
239
+ /** True for pikkuUserFlow workflows (complex + actor steps). */
240
+ userFlow?: boolean;
241
+ /** Actor names a user flow declares (personas it runs steps as). */
242
+ actors?: string[];
239
243
  }>;
240
244
  /**
241
245
  * Unified workflow runtime meta (used by runtime to execute workflows)
@@ -247,12 +251,14 @@ export interface WorkflowRuntimeMeta {
247
251
  name: string;
248
252
  /** Pikku function name (for execution) */
249
253
  pikkuFuncId: string;
250
- /** Source type: 'dsl' (serializable), 'complex' (has inline steps), 'graph' */
251
- source: 'dsl' | 'complex' | 'graph' | 'dynamic-workflow';
254
+ /** Source type: 'dsl' (serializable), 'complex' (has inline steps), 'graph', 'user-flow' (complex + actor steps) */
255
+ source: 'dsl' | 'complex' | 'graph' | 'dynamic-workflow' | 'user-flow';
252
256
  /** Optional description */
253
257
  description?: string;
254
258
  /** Tags for organization */
255
259
  tags?: string[];
260
+ /** Actor names a user flow declares (personas it runs steps as). */
261
+ actors?: string[];
256
262
  /** Serialized nodes */
257
263
  nodes?: Record<string, any>;
258
264
  /** Entry node IDs for graph workflows (computed at build time) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.47",
3
+ "version": "0.12.50",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",