@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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,40 @@
1
+ ## 0.12.49
2
+
3
+ ### Patch Changes
4
+
5
+ - 4c17f7e: user flows: actors move onto the workflow wire + `pikku userflow` command
6
+ - Actors are no longer a singleton service: `startWorkflow(..., { actors })`
7
+ registers them per run and they arrive on the wire —
8
+ `func: async ({ logger }, input, { workflow, actors })`.
9
+ - Inspector enforces user flows are pure remote stories (PKU673): a
10
+ pikkuUserFlow func may only destructure `logger`/`config` from services.
11
+ - New `pikku userflow run <environment> [--flows a,b] [--tags x,y]` runs flows
12
+ against `userFlows.environments` from pikku.config.json (secret from
13
+ USER_FLOW_ACTOR_SECRET env), refusing internal (non-actor) steps so runs
14
+ against staging/production never touch local services; non-zero exit on
15
+ failure. `pikku userflow list` prints names, descriptions and tags.
16
+ - Workflow meta now carries `title` (parity with HTTP routes/functions).
17
+
18
+ ## 0.12.48
19
+
20
+ ### Patch Changes
21
+
22
+ - 5f2c566: Better Auth actor plugin for user flows: `actor({ secret })` adds an `actor`
23
+ boolean column on `user` and a `POST /sign-in/actor` endpoint (`{ email,
24
+ secret }`, constant-time compare). Actor rows are auto-created on first
25
+ sign-in; a real (non-actor) user can never be impersonated with the secret.
26
+ The flag propagates into the pikku core session (`CoreUserSession.actor`) via
27
+ both `betterAuthSession` and `betterAuthStatelessSession`, so audits and
28
+ analytics can address synthetic traffic.
29
+ - 8dfddc3: pikkuUserFlow: user flows as workflows. A complex workflow whose steps can run
30
+ as actors over the real transport — `workflow.do(step, rpc, data, { actor:
31
+ actors.yasser })` — plus `workflow.expectEventually(...)` for polling async
32
+ effects. Actor steps never queue and never dispatch internally, so auth
33
+ middleware/permissions are exercised end-to-end; flows double as e2e tests and
34
+ staged/production health checks. Ships UserFlowActor types +
35
+ createHttpUserFlowActors (lazy sign-in via `/auth/sign-in/actor` with a
36
+ server-held secret), inspector source `'user-flow'`, and a console badge.
37
+
1
38
  ## 0.12.47
2
39
 
3
40
  ### Patch Changes
@@ -0,0 +1,46 @@
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
+ invoke(rpcName: string, data: unknown): Promise<unknown>;
38
+ private postRpc;
39
+ private readRpcResponse;
40
+ private login;
41
+ }
42
+ /**
43
+ * Build the injected `actors` service from the config registry: actor name →
44
+ * lazy HTTP actor. Wire the result as the `actors` singleton service.
45
+ */
46
+ export declare function createHttpUserFlowActors(config: HttpUserFlowActorsConfig): UserFlowActors;
@@ -0,0 +1,90 @@
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
+ async invoke(rpcName, data) {
22
+ const cookie = this.cookie ?? (await this.login());
23
+ const res = await this.postRpc(rpcName, data, cookie);
24
+ if (res.status === 401) {
25
+ // Session expired mid-run — re-login once and retry.
26
+ this.cookie = null;
27
+ return this.readRpcResponse(rpcName, await this.postRpc(rpcName, data, await this.login()));
28
+ }
29
+ return this.readRpcResponse(rpcName, res);
30
+ }
31
+ async postRpc(rpcName, data, cookie) {
32
+ const rpcPath = this.config.rpcPath ?? '/rpc';
33
+ return fetch(`${this.config.apiUrl}${rpcPath}/${rpcName}`, {
34
+ method: 'POST',
35
+ headers: {
36
+ 'content-type': 'application/json',
37
+ origin: this.origin,
38
+ cookie,
39
+ },
40
+ body: JSON.stringify({ data }),
41
+ });
42
+ }
43
+ async readRpcResponse(rpcName, res) {
44
+ if (!res.ok) {
45
+ const body = (await res.text().catch(() => '')).slice(0, 300);
46
+ throw new Error(`[user-flow] '${rpcName}' as '${this.name}' returned ${res.status}: ${body}`);
47
+ }
48
+ if (res.status === 204)
49
+ return undefined;
50
+ const text = await res.text();
51
+ return text ? JSON.parse(text) : undefined;
52
+ }
53
+ async login() {
54
+ const signInPath = this.config.signInPath ?? '/auth/sign-in/actor';
55
+ const res = await fetch(`${this.config.apiUrl}${signInPath}`, {
56
+ method: 'POST',
57
+ headers: { 'content-type': 'application/json', origin: this.origin },
58
+ body: JSON.stringify({
59
+ email: this.actorConfig.email,
60
+ name: this.actorConfig.name ?? this.name,
61
+ secret: this.config.secret,
62
+ }),
63
+ });
64
+ if (!res.ok) {
65
+ const body = (await res.text().catch(() => '')).slice(0, 300);
66
+ throw new Error(`[user-flow] actor sign-in failed for '${this.name}' (${res.status}): ${body}`);
67
+ }
68
+ const setCookies = res.headers.getSetCookie?.() ?? [];
69
+ const cookie = setCookies
70
+ .map((c) => c.split(';')[0])
71
+ .filter(Boolean)
72
+ .join('; ');
73
+ if (!cookie) {
74
+ throw new Error(`[user-flow] actor sign-in for '${this.name}' returned no session cookie`);
75
+ }
76
+ this.cookie = cookie;
77
+ return cookie;
78
+ }
79
+ }
80
+ /**
81
+ * Build the injected `actors` service from the config registry: actor name →
82
+ * lazy HTTP actor. Wire the result as the `actors` singleton service.
83
+ */
84
+ export function createHttpUserFlowActors(config) {
85
+ const actors = {};
86
+ for (const [name, actorConfig] of Object.entries(config.actors)) {
87
+ actors[name] = new HttpUserFlowActor(name, actorConfig, config);
88
+ }
89
+ return actors;
90
+ }
@@ -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';
@@ -0,0 +1,27 @@
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
+ * Display/config metadata for an actor (from pikku.config.json). The email
17
+ * identifies the actor's user row; personality/jobTitle exist for the console
18
+ * screen and for agent-driven flows (the agent plays the persona).
19
+ */
20
+ export interface UserFlowActorConfig {
21
+ email: string;
22
+ name?: string;
23
+ jobTitle?: string;
24
+ personality?: string;
25
+ }
26
+ /** The injected `actors` service: actor name → actor. */
27
+ 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
@@ -317,6 +335,11 @@ export interface PikkuWorkflowWire {
317
335
  getRun: () => Promise<WorkflowRun>;
318
336
  /** Execute a workflow step (overloaded - RPC or inline form) */
319
337
  do: WorkflowWireDoRPC & WorkflowWireDoInline;
338
+ /**
339
+ * Durable polling step (user flows): invoke `rpcName` (as an actor when
340
+ * `options.as` is set) until `predicate` passes or `options.within` elapses.
341
+ */
342
+ expectEventually: <TOutput = any, TInput = any>(stepName: string, rpcName: string, data: TInput, predicate: (output: TOutput) => boolean, options?: WorkflowExpectEventuallyOptions) => Promise<TOutput>;
320
343
  /** Sleep for a duration */
321
344
  sleep: WorkflowWireSleep;
322
345
  /** 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.49",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
@@ -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
+ })