@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 +37 -0
- package/dist/services/http-user-flow-actors.d.ts +46 -0
- package/dist/services/http-user-flow-actors.js +90 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +1 -0
- package/dist/services/user-flow-actors-service.d.ts +27 -0
- package/dist/services/user-flow-actors-service.js +1 -0
- package/dist/types/core.types.d.ts +5 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +23 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +3 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +51 -3
- package/dist/wirings/workflow/workflow.types.d.ts +9 -3
- package/package.json +1 -1
- package/src/services/http-user-flow-actors.test.ts +115 -0
- package/src/services/http-user-flow-actors.ts +128 -0
- package/src/services/index.ts +10 -0
- package/src/services/user-flow-actors-service.ts +29 -0
- package/src/types/core.types.ts +5 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +31 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +76 -11
- package/src/wirings/workflow/user-flow-step.test.ts +192 -0
- package/src/wirings/workflow/workflow.types.ts +9 -2
- package/tsconfig.tsbuildinfo +1 -1
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
|
+
}
|
package/dist/services/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/services/index.js
CHANGED
|
@@ -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
|
|
799
|
-
rpc: rpcService
|
|
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
|
-
|
|
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
|
@@ -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
|
+
})
|