@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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
UserFlowActor,
|
|
3
|
+
UserFlowActorConfig,
|
|
4
|
+
UserFlowActors,
|
|
5
|
+
} from './user-flow-actors-service.js'
|
|
6
|
+
|
|
7
|
+
export interface HttpUserFlowActorsConfig {
|
|
8
|
+
/**
|
|
9
|
+
* Base API URL of the target app, INCLUDING the HTTP prefix — e.g.
|
|
10
|
+
* `https://app.example.com/api` or `http://localhost:4000/api`. Actor
|
|
11
|
+
* sign-in is reached at `${apiUrl}${signInPath}` and exposed RPCs at
|
|
12
|
+
* `${apiUrl}${rpcPath}/:rpcName`.
|
|
13
|
+
*/
|
|
14
|
+
apiUrl: string
|
|
15
|
+
/**
|
|
16
|
+
* The actor impersonation secret. Sign-in only ever works for user rows
|
|
17
|
+
* flagged `actor: true` — knowing the secret never impersonates real users.
|
|
18
|
+
*/
|
|
19
|
+
secret: string
|
|
20
|
+
/** Actor name → config (usually from pikku.config.json's actor registry). */
|
|
21
|
+
actors: Record<string, UserFlowActorConfig>
|
|
22
|
+
/** Sign-in path under apiUrl. Default: the actor plugin's `/auth/sign-in/actor`. */
|
|
23
|
+
signInPath?: string
|
|
24
|
+
/** Exposed-RPC path prefix under apiUrl. Default `/rpc`. */
|
|
25
|
+
rpcPath?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Default HTTP-backed actor. Signs in lazily on first invoke via the Better
|
|
30
|
+
* Auth actor plugin (`POST /auth/sign-in/actor` with `{ email, secret }` —
|
|
31
|
+
* the plugin upserts the actor-flagged user row and mints a session whose
|
|
32
|
+
* `actor` flag flows into audits/analytics). Holds the session cookies for
|
|
33
|
+
* its lifetime; a 401 mid-run re-logs-in once (long health-check runs can
|
|
34
|
+
* outlive a session).
|
|
35
|
+
*/
|
|
36
|
+
export class HttpUserFlowActor implements UserFlowActor {
|
|
37
|
+
private cookie: string | null = null
|
|
38
|
+
private origin: string
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
readonly name: string,
|
|
42
|
+
private actorConfig: UserFlowActorConfig,
|
|
43
|
+
private config: HttpUserFlowActorsConfig
|
|
44
|
+
) {
|
|
45
|
+
this.origin = new URL(config.apiUrl).origin
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async invoke(rpcName: string, data: unknown): Promise<unknown> {
|
|
49
|
+
const cookie = this.cookie ?? (await this.login())
|
|
50
|
+
const res = await this.postRpc(rpcName, data, cookie)
|
|
51
|
+
if (res.status === 401) {
|
|
52
|
+
// Session expired mid-run — re-login once and retry.
|
|
53
|
+
this.cookie = null
|
|
54
|
+
return this.readRpcResponse(rpcName, await this.postRpc(rpcName, data, await this.login()))
|
|
55
|
+
}
|
|
56
|
+
return this.readRpcResponse(rpcName, res)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private async postRpc(rpcName: string, data: unknown, cookie: string) {
|
|
60
|
+
const rpcPath = this.config.rpcPath ?? '/rpc'
|
|
61
|
+
return fetch(`${this.config.apiUrl}${rpcPath}/${rpcName}`, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: {
|
|
64
|
+
'content-type': 'application/json',
|
|
65
|
+
origin: this.origin,
|
|
66
|
+
cookie,
|
|
67
|
+
},
|
|
68
|
+
body: JSON.stringify({ data }),
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private async readRpcResponse(rpcName: string, res: Response) {
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const body = (await res.text().catch(() => '')).slice(0, 300)
|
|
75
|
+
throw new Error(
|
|
76
|
+
`[user-flow] '${rpcName}' as '${this.name}' returned ${res.status}: ${body}`
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
if (res.status === 204) return undefined
|
|
80
|
+
const text = await res.text()
|
|
81
|
+
return text ? JSON.parse(text) : undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private async login(): Promise<string> {
|
|
85
|
+
const signInPath = this.config.signInPath ?? '/auth/sign-in/actor'
|
|
86
|
+
const res = await fetch(`${this.config.apiUrl}${signInPath}`, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'content-type': 'application/json', origin: this.origin },
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
email: this.actorConfig.email,
|
|
91
|
+
name: this.actorConfig.name ?? this.name,
|
|
92
|
+
secret: this.config.secret,
|
|
93
|
+
}),
|
|
94
|
+
})
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
const body = (await res.text().catch(() => '')).slice(0, 300)
|
|
97
|
+
throw new Error(
|
|
98
|
+
`[user-flow] actor sign-in failed for '${this.name}' (${res.status}): ${body}`
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
const setCookies = res.headers.getSetCookie?.() ?? []
|
|
102
|
+
const cookie = setCookies
|
|
103
|
+
.map((c) => c.split(';')[0]!)
|
|
104
|
+
.filter(Boolean)
|
|
105
|
+
.join('; ')
|
|
106
|
+
if (!cookie) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`[user-flow] actor sign-in for '${this.name}' returned no session cookie`
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
this.cookie = cookie
|
|
112
|
+
return cookie
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build the injected `actors` service from the config registry: actor name →
|
|
118
|
+
* lazy HTTP actor. Wire the result as the `actors` singleton service.
|
|
119
|
+
*/
|
|
120
|
+
export function createHttpUserFlowActors(
|
|
121
|
+
config: HttpUserFlowActorsConfig
|
|
122
|
+
): UserFlowActors {
|
|
123
|
+
const actors: UserFlowActors = {}
|
|
124
|
+
for (const [name, actorConfig] of Object.entries(config.actors)) {
|
|
125
|
+
actors[name] = new HttpUserFlowActor(name, actorConfig, config)
|
|
126
|
+
}
|
|
127
|
+
return actors
|
|
128
|
+
}
|
package/src/services/index.ts
CHANGED
|
@@ -36,6 +36,16 @@ export type {
|
|
|
36
36
|
WriteFileArgs,
|
|
37
37
|
CopyFileArgs,
|
|
38
38
|
} from './content-service.js'
|
|
39
|
+
export type {
|
|
40
|
+
UserFlowActor,
|
|
41
|
+
UserFlowActorConfig,
|
|
42
|
+
UserFlowActors,
|
|
43
|
+
} from './user-flow-actors-service.js'
|
|
44
|
+
export {
|
|
45
|
+
HttpUserFlowActor,
|
|
46
|
+
createHttpUserFlowActors,
|
|
47
|
+
type HttpUserFlowActorsConfig,
|
|
48
|
+
} from './http-user-flow-actors.js'
|
|
39
49
|
export type { JWTService } from './jwt-service.js'
|
|
40
50
|
export type {
|
|
41
51
|
EmailService,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A user-flow actor: a synthetic user (a normal user row flagged `actor`) that
|
|
3
|
+
* workflow steps can run as. Passed to `workflow.do(step, rpc, data, { actor })`
|
|
4
|
+
* — the step then goes through the actor's authenticated client over the REAL
|
|
5
|
+
* transport (auth middleware, permissions, serialization all exercised),
|
|
6
|
+
* never through internal dispatch. Login is lazy: the first `invoke` signs the
|
|
7
|
+
* actor in and the session is cached for the actor's lifetime.
|
|
8
|
+
*/
|
|
9
|
+
export interface UserFlowActor {
|
|
10
|
+
/** Stable actor name (the key in pikku.config.json's actor registry). */
|
|
11
|
+
readonly name: string
|
|
12
|
+
/** Invoke an exposed RPC as this actor over the real transport. */
|
|
13
|
+
invoke(rpcName: string, data: unknown): Promise<unknown>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Display/config metadata for an actor (from pikku.config.json). The email
|
|
18
|
+
* identifies the actor's user row; personality/jobTitle exist for the console
|
|
19
|
+
* screen and for agent-driven flows (the agent plays the persona).
|
|
20
|
+
*/
|
|
21
|
+
export interface UserFlowActorConfig {
|
|
22
|
+
email: string
|
|
23
|
+
name?: string
|
|
24
|
+
jobTitle?: string
|
|
25
|
+
personality?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The injected `actors` service: actor name → actor. */
|
|
29
|
+
export type UserFlowActors = Record<string, UserFlowActor>
|
package/src/types/core.types.ts
CHANGED
|
@@ -29,6 +29,7 @@ import type { DeploymentService } from '../services/deployment-service.js'
|
|
|
29
29
|
import type { AIStorageService } from '../services/ai-storage-service.js'
|
|
30
30
|
|
|
31
31
|
import type { ContentService } from '../services/content-service.js'
|
|
32
|
+
import type { UserFlowActors } from '../services/user-flow-actors-service.js'
|
|
32
33
|
import type { AIAgentRunnerService } from '../services/ai-agent-runner-service.js'
|
|
33
34
|
import type { AIRunStateService } from '../services/ai-run-state-service.js'
|
|
34
35
|
import type { AgentRunService } from '../wirings/ai-agent/ai-agent.types.js'
|
|
@@ -243,6 +244,8 @@ export type CoreConfig<Config extends Record<string, unknown> = {}> = {
|
|
|
243
244
|
export interface CoreUserSession {
|
|
244
245
|
userId?: string
|
|
245
246
|
orgId?: string
|
|
247
|
+
/** True when the session belongs to a synthetic user-flow actor — lets audits/analytics address synthetic traffic */
|
|
248
|
+
actor?: boolean
|
|
246
249
|
}
|
|
247
250
|
|
|
248
251
|
/**
|
|
@@ -336,6 +339,8 @@ export type PikkuWire<
|
|
|
336
339
|
queue: PikkuQueue
|
|
337
340
|
cli: PikkuCLI
|
|
338
341
|
workflow: TypedWorkflow
|
|
342
|
+
/** User-flow actor registry (user-flow runs only) — pass into workflow.do as `{ actor: actors.x }` */
|
|
343
|
+
actors: UserFlowActors
|
|
339
344
|
workflowStep: WorkflowStepWire
|
|
340
345
|
graph: PikkuGraphWire
|
|
341
346
|
trigger: PikkuTrigger<TriggerOutput>
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { WorkflowRun } from '../workflow.types.js'
|
|
7
|
+
import type { UserFlowActor } from '../../../services/user-flow-actors-service.js'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Workflow step options
|
|
@@ -15,9 +16,27 @@ export interface WorkflowStepOptions {
|
|
|
15
16
|
retries?: number
|
|
16
17
|
/** Delay between retry attempts (e.g., '1s', '2s', '2min') */
|
|
17
18
|
retryDelay?: string | number
|
|
19
|
+
/**
|
|
20
|
+
* Run this step as an actor (user flows). The RPC is sent through the
|
|
21
|
+
* actor's authenticated client over the REAL transport — never dispatched
|
|
22
|
+
* internally — so auth middleware and permissions are exercised end-to-end.
|
|
23
|
+
* The step is recorded durably like any RPC step.
|
|
24
|
+
*/
|
|
25
|
+
actor?: UserFlowActor
|
|
18
26
|
// Future: timeout, failFast, priority
|
|
19
27
|
}
|
|
20
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Options for workflow.expectEventually() — a durable polling step used by
|
|
31
|
+
* user flows to await async effects (a notification landing, a job finishing).
|
|
32
|
+
*/
|
|
33
|
+
export interface WorkflowExpectEventuallyOptions extends WorkflowStepOptions {
|
|
34
|
+
/** Give up after this long (e.g. '30s'). Default '30s'. */
|
|
35
|
+
within?: string | number
|
|
36
|
+
/** Poll interval (e.g. '1s'). Default '1s'. */
|
|
37
|
+
interval?: string | number
|
|
38
|
+
}
|
|
39
|
+
|
|
21
40
|
/**
|
|
22
41
|
* Type signature for workflow.do() RPC form - used by inspector
|
|
23
42
|
*/
|
|
@@ -346,6 +365,18 @@ export interface PikkuWorkflowWire {
|
|
|
346
365
|
/** Execute a workflow step (overloaded - RPC or inline form) */
|
|
347
366
|
do: WorkflowWireDoRPC & WorkflowWireDoInline
|
|
348
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Durable polling step (user flows): invoke `rpcName` (as an actor when
|
|
370
|
+
* `options.as` is set) until `predicate` passes or `options.within` elapses.
|
|
371
|
+
*/
|
|
372
|
+
expectEventually: <TOutput = any, TInput = any>(
|
|
373
|
+
stepName: string,
|
|
374
|
+
rpcName: string,
|
|
375
|
+
data: TInput,
|
|
376
|
+
predicate: (output: TOutput) => boolean,
|
|
377
|
+
options?: WorkflowExpectEventuallyOptions
|
|
378
|
+
) => Promise<TOutput>
|
|
379
|
+
|
|
349
380
|
/** Sleep for a duration */
|
|
350
381
|
sleep: WorkflowWireSleep
|
|
351
382
|
|
|
@@ -58,6 +58,7 @@ import type {
|
|
|
58
58
|
WorkflowVersionStatus,
|
|
59
59
|
WorkflowServiceConfig,
|
|
60
60
|
WorkflowStepOptions,
|
|
61
|
+
WorkflowExpectEventuallyOptions,
|
|
61
62
|
} from './workflow.types.js'
|
|
62
63
|
import {
|
|
63
64
|
continueGraph,
|
|
@@ -66,6 +67,7 @@ import {
|
|
|
66
67
|
runFromMeta,
|
|
67
68
|
} from './graph/graph-runner.js'
|
|
68
69
|
import type { WorkflowService } from '../../services/workflow-service.js'
|
|
70
|
+
import type { UserFlowActors } from '../../services/user-flow-actors-service.js'
|
|
69
71
|
import {
|
|
70
72
|
PikkuError,
|
|
71
73
|
addError,
|
|
@@ -218,6 +220,9 @@ const WORKFLOW_END_STATES: ReadonlySet<string> = new Set([
|
|
|
218
220
|
*/
|
|
219
221
|
export abstract class PikkuWorkflowService implements WorkflowService {
|
|
220
222
|
private inlineRuns = new Set<string>()
|
|
223
|
+
// User-flow actors per run: live authenticated clients (cookie jars) are
|
|
224
|
+
// process-local by nature, so they ride this map, never the persisted wire.
|
|
225
|
+
private runActors = new Map<string, UserFlowActors>()
|
|
221
226
|
|
|
222
227
|
protected get logger() {
|
|
223
228
|
return getSingletonServices()?.logger
|
|
@@ -1071,7 +1076,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1071
1076
|
input: I,
|
|
1072
1077
|
wire: WorkflowRunWire,
|
|
1073
1078
|
rpcService: any,
|
|
1074
|
-
options?: { inline?: boolean; startNode?: string }
|
|
1079
|
+
options?: { inline?: boolean; startNode?: string; actors?: UserFlowActors }
|
|
1075
1080
|
): Promise<{ runId: string }> {
|
|
1076
1081
|
// Resolve workflow from static meta (root or addon namespace), then dynamic DB
|
|
1077
1082
|
const resolved = resolveWorkflowMeta(name)
|
|
@@ -1135,6 +1140,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1135
1140
|
}
|
|
1136
1141
|
)
|
|
1137
1142
|
|
|
1143
|
+
if (options?.actors) {
|
|
1144
|
+
this.runActors.set(runId, options.actors)
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1138
1147
|
if (shouldInline) {
|
|
1139
1148
|
this.inlineRuns.add(runId)
|
|
1140
1149
|
try {
|
|
@@ -1170,6 +1179,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1170
1179
|
}
|
|
1171
1180
|
} finally {
|
|
1172
1181
|
this.inlineRuns.delete(runId)
|
|
1182
|
+
this.runActors.delete(runId)
|
|
1173
1183
|
}
|
|
1174
1184
|
} else {
|
|
1175
1185
|
await this.resumeWorkflow(runId)
|
|
@@ -1341,8 +1351,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1341
1351
|
const wire: PikkuWire = {
|
|
1342
1352
|
workflow: workflowWire,
|
|
1343
1353
|
pikkuUserId: run.wire?.pikkuUserId,
|
|
1344
|
-
session: rpcService
|
|
1345
|
-
rpc: rpcService
|
|
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
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1823
|
+
// Actor steps never queue: they are outbound HTTP calls made by the
|
|
1824
|
+
// runner itself, and the actor's session lives on this process.
|
|
1825
|
+
const dispatched = resolvedStepOptions.actor
|
|
1826
|
+
? false
|
|
1827
|
+
: await this.dispatchStep(
|
|
1828
|
+
runId,
|
|
1829
|
+
stepName,
|
|
1830
|
+
rpcName,
|
|
1831
|
+
data,
|
|
1832
|
+
resolvedStepOptions,
|
|
1833
|
+
fromStepName
|
|
1834
|
+
)
|
|
1818
1835
|
if (dispatched) {
|
|
1819
1836
|
await this.setStepScheduled(stepState.stepId)
|
|
1820
1837
|
throw new WorkflowAsyncException(runId, stepName)
|
|
@@ -1830,6 +1847,12 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1830
1847
|
retries,
|
|
1831
1848
|
retryDelay,
|
|
1832
1849
|
async (currentStepState) => {
|
|
1850
|
+
// Actor step: send through the actor's authenticated client over the
|
|
1851
|
+
// REAL transport. Never falls back to internal dispatch — that would
|
|
1852
|
+
// bypass auth and fake a green health check.
|
|
1853
|
+
if (resolvedStepOptions.actor) {
|
|
1854
|
+
return resolvedStepOptions.actor.invoke(rpcName, data)
|
|
1855
|
+
}
|
|
1833
1856
|
// Check if the name refers to a workflow
|
|
1834
1857
|
const workflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName]
|
|
1835
1858
|
if (workflowMeta) {
|
|
@@ -2119,6 +2142,48 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
2119
2142
|
}
|
|
2120
2143
|
},
|
|
2121
2144
|
|
|
2145
|
+
// Durable polling step: invoke an RPC (as an actor when options.as is
|
|
2146
|
+
// set) until the predicate passes or `within` elapses. The whole poll is
|
|
2147
|
+
// ONE recorded step, so replay returns the cached outcome.
|
|
2148
|
+
expectEventually: async (
|
|
2149
|
+
stepName: string,
|
|
2150
|
+
rpcName: string,
|
|
2151
|
+
data: any,
|
|
2152
|
+
predicate: (output: any) => boolean,
|
|
2153
|
+
options?: WorkflowExpectEventuallyOptions
|
|
2154
|
+
) => {
|
|
2155
|
+
this.verifyStepName(stepName)
|
|
2156
|
+
const resolvedRpcName =
|
|
2157
|
+
addonNamespace && !rpcName.includes(':')
|
|
2158
|
+
? `${addonNamespace}:${rpcName}`
|
|
2159
|
+
: rpcName
|
|
2160
|
+
const within = getDurationInMilliseconds(options?.within ?? '30s')
|
|
2161
|
+
const interval = getDurationInMilliseconds(options?.interval ?? '1s')
|
|
2162
|
+
return await this.inlineStep(
|
|
2163
|
+
runId,
|
|
2164
|
+
stepName,
|
|
2165
|
+
async () => {
|
|
2166
|
+
const deadline = Date.now() + within
|
|
2167
|
+
let last: any
|
|
2168
|
+
while (true) {
|
|
2169
|
+
last = options?.actor
|
|
2170
|
+
? await options.actor.invoke(resolvedRpcName, data)
|
|
2171
|
+
: await rpcService.rpcWithWire(resolvedRpcName, data, {})
|
|
2172
|
+
if (predicate(last)) return last
|
|
2173
|
+
if (Date.now() + interval > deadline) {
|
|
2174
|
+
throw new Error(
|
|
2175
|
+
`[workflow] expectEventually '${stepName}' ('${resolvedRpcName}'` +
|
|
2176
|
+
`${options?.actor ? ` as '${options.actor.name}'` : ''}) did not pass within ${within}ms; ` +
|
|
2177
|
+
`last result: ${JSON.stringify(last)?.slice(0, 300)}`
|
|
2178
|
+
)
|
|
2179
|
+
}
|
|
2180
|
+
await new Promise((resolve) => setTimeout(resolve, interval))
|
|
2181
|
+
}
|
|
2182
|
+
},
|
|
2183
|
+
options
|
|
2184
|
+
)
|
|
2185
|
+
},
|
|
2186
|
+
|
|
2122
2187
|
// Implement workflow.sleep()
|
|
2123
2188
|
sleep: async (stepName: string, duration: string | number) => {
|
|
2124
2189
|
this.verifyStepName(stepName)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, test, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
|
|
5
|
+
import { pikkuState, resetPikkuState } from '../../pikku-state.js'
|
|
6
|
+
import type { UserFlowActor } from '../../services/user-flow-actors-service.js'
|
|
7
|
+
|
|
8
|
+
const noopLogger = { error() {}, info() {}, warn() {}, debug() {} }
|
|
9
|
+
|
|
10
|
+
const fakeActor = (
|
|
11
|
+
name: string,
|
|
12
|
+
handler: (rpcName: string, data: unknown) => Promise<unknown>
|
|
13
|
+
): UserFlowActor & { calls: Array<{ rpcName: string; data: unknown }> } => {
|
|
14
|
+
const calls: Array<{ rpcName: string; data: unknown }> = []
|
|
15
|
+
return {
|
|
16
|
+
name,
|
|
17
|
+
calls,
|
|
18
|
+
invoke: async (rpcName: string, data: unknown) => {
|
|
19
|
+
calls.push({ rpcName, data })
|
|
20
|
+
return handler(rpcName, data)
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const setup = async (
|
|
26
|
+
ws: InMemoryWorkflowService,
|
|
27
|
+
services: Record<string, unknown> = {}
|
|
28
|
+
) => {
|
|
29
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
30
|
+
logger: noopLogger,
|
|
31
|
+
...services,
|
|
32
|
+
} as any)
|
|
33
|
+
const runId = await ws.createRun('userFlowTest', {}, true, 'hash', {
|
|
34
|
+
type: 'test',
|
|
35
|
+
} as any)
|
|
36
|
+
ws.registerInlineRun(runId)
|
|
37
|
+
return runId
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('user-flow actor steps (workflow.do with `actor`)', () => {
|
|
41
|
+
beforeEach(() => resetPikkuState())
|
|
42
|
+
|
|
43
|
+
test('routes through the actor over the real transport, never internal rpc', async () => {
|
|
44
|
+
const ws = new InMemoryWorkflowService()
|
|
45
|
+
const customer = fakeActor('customer', async () => ({ todoId: 't1' }))
|
|
46
|
+
let internalCalls = 0
|
|
47
|
+
|
|
48
|
+
const runId = await setup(ws)
|
|
49
|
+
const rpc = {
|
|
50
|
+
rpcWithWire: async () => {
|
|
51
|
+
internalCalls++
|
|
52
|
+
return {}
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, rpc)
|
|
57
|
+
const result = await wire.do(
|
|
58
|
+
'customer creates todo',
|
|
59
|
+
'createTodo',
|
|
60
|
+
{ title: 'x' },
|
|
61
|
+
{ actor: customer }
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
assert.deepEqual(result, { todoId: 't1' })
|
|
65
|
+
assert.deepEqual(customer.calls, [
|
|
66
|
+
{ rpcName: 'createTodo', data: { title: 'x' } },
|
|
67
|
+
])
|
|
68
|
+
assert.equal(internalCalls, 0, 'actor steps must NOT dispatch internally')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('step is recorded durably and replay returns the cached result without re-invoking', async () => {
|
|
72
|
+
const ws = new InMemoryWorkflowService()
|
|
73
|
+
let invocations = 0
|
|
74
|
+
const yasser = fakeActor('yasser', async () => ({ n: ++invocations }))
|
|
75
|
+
const runId = await setup(ws)
|
|
76
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, {})
|
|
77
|
+
|
|
78
|
+
const first = await wire.do('step', 'someRpc', {}, { actor: yasser })
|
|
79
|
+
assert.deepEqual(first, { n: 1 })
|
|
80
|
+
|
|
81
|
+
// Simulate replay: reset per-run ordinals so the same logical step name
|
|
82
|
+
// resolves to the same durable step key.
|
|
83
|
+
;(ws as any).resetStepOrdinals(runId)
|
|
84
|
+
const replayWire = ws.createWorkflowWire('userFlowTest', runId, {})
|
|
85
|
+
const replayed = await replayWire.do('step', 'someRpc', {}, { actor: yasser })
|
|
86
|
+
|
|
87
|
+
assert.deepEqual(replayed, { n: 1 }, 'replay must return the cached result')
|
|
88
|
+
assert.equal(invocations, 1, 'the actor must not be re-invoked on replay')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('actor steps never queue, even when the function is queue-eligible', async () => {
|
|
92
|
+
const ws = new InMemoryWorkflowService()
|
|
93
|
+
let queued = 0
|
|
94
|
+
const customer = fakeActor('customer', async () => ({}))
|
|
95
|
+
const runId = await setup(ws, {
|
|
96
|
+
queueService: {
|
|
97
|
+
add: async () => {
|
|
98
|
+
queued++
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
// Function meta says this RPC would normally dispatch via the queue.
|
|
103
|
+
pikkuState(null, 'function', 'meta').queuedRpc = {
|
|
104
|
+
pikkuFuncId: 'queuedRpc',
|
|
105
|
+
workflowQueued: true,
|
|
106
|
+
} as any
|
|
107
|
+
|
|
108
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, {})
|
|
109
|
+
await wire.do('step', 'queuedRpc', {}, { actor: customer })
|
|
110
|
+
|
|
111
|
+
assert.equal(queued, 0, 'actor steps are outbound HTTP — never queued')
|
|
112
|
+
assert.equal(customer.calls.length, 1)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('actor step failure surfaces the actor error and fails after retries', async () => {
|
|
116
|
+
const ws = new InMemoryWorkflowService()
|
|
117
|
+
const broken = fakeActor('broken', async () => {
|
|
118
|
+
throw new Error("[user-flow] 'createTodo' as 'broken' returned 403: nope")
|
|
119
|
+
})
|
|
120
|
+
const runId = await setup(ws)
|
|
121
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, {})
|
|
122
|
+
|
|
123
|
+
await assert.rejects(
|
|
124
|
+
wire.do('step', 'createTodo', {}, { actor: broken, retries: 2 }),
|
|
125
|
+
/returned 403/
|
|
126
|
+
)
|
|
127
|
+
assert.equal(broken.calls.length, 2, 'retries bounds total attempts')
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('workflow.expectEventually', () => {
|
|
132
|
+
beforeEach(() => resetPikkuState())
|
|
133
|
+
|
|
134
|
+
test('polls as the actor until the predicate passes', async () => {
|
|
135
|
+
const ws = new InMemoryWorkflowService()
|
|
136
|
+
let polls = 0
|
|
137
|
+
const sarah = fakeActor('sarah', async () => ({
|
|
138
|
+
notifications: ++polls >= 3 ? ['ping'] : [],
|
|
139
|
+
}))
|
|
140
|
+
const runId = await setup(ws)
|
|
141
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, {})
|
|
142
|
+
|
|
143
|
+
const result = await wire.expectEventually(
|
|
144
|
+
'sarah sees the notification',
|
|
145
|
+
'getNotifications',
|
|
146
|
+
{},
|
|
147
|
+
(out: any) => out.notifications.length > 0,
|
|
148
|
+
{ actor: sarah, within: 2_000, interval: 5 }
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
assert.deepEqual(result, { notifications: ['ping'] })
|
|
152
|
+
assert.equal(polls, 3)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('fails with the last result when the deadline passes', async () => {
|
|
156
|
+
const ws = new InMemoryWorkflowService()
|
|
157
|
+
const sarah = fakeActor('sarah', async () => ({ notifications: [] }))
|
|
158
|
+
const runId = await setup(ws)
|
|
159
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, {})
|
|
160
|
+
|
|
161
|
+
await assert.rejects(
|
|
162
|
+
wire.expectEventually(
|
|
163
|
+
'never arrives',
|
|
164
|
+
'getNotifications',
|
|
165
|
+
{},
|
|
166
|
+
(out: any) => out.notifications.length > 0,
|
|
167
|
+
{ actor: sarah, within: 30, interval: 5, retries: 0 }
|
|
168
|
+
),
|
|
169
|
+
/did not pass within 30ms.*notifications/
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('polls internally (rpcWithWire) when no actor is given', async () => {
|
|
174
|
+
const ws = new InMemoryWorkflowService()
|
|
175
|
+
let polls = 0
|
|
176
|
+
const runId = await setup(ws)
|
|
177
|
+
const rpc = {
|
|
178
|
+
rpcWithWire: async () => ({ ready: ++polls >= 2 }),
|
|
179
|
+
}
|
|
180
|
+
const wire = ws.createWorkflowWire('userFlowTest', runId, rpc)
|
|
181
|
+
|
|
182
|
+
const result = await wire.expectEventually(
|
|
183
|
+
'job finishes',
|
|
184
|
+
'getJob',
|
|
185
|
+
{},
|
|
186
|
+
(out: any) => out.ready,
|
|
187
|
+
{ within: 2_000, interval: 5 }
|
|
188
|
+
)
|
|
189
|
+
assert.deepEqual(result, { ready: true })
|
|
190
|
+
assert.equal(polls, 2)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
@@ -7,6 +7,7 @@ export type { WorkflowService } from '../../services/workflow-service.js'
|
|
|
7
7
|
// Re-export DSL types from dsl module
|
|
8
8
|
export type {
|
|
9
9
|
WorkflowStepOptions,
|
|
10
|
+
WorkflowExpectEventuallyOptions,
|
|
10
11
|
WorkflowWireDoRPC,
|
|
11
12
|
WorkflowWireDoInline,
|
|
12
13
|
WorkflowWireSleep,
|
|
@@ -324,6 +325,10 @@ export type WorkflowsMeta = Record<
|
|
|
324
325
|
context?: WorkflowContext
|
|
325
326
|
dsl?: boolean
|
|
326
327
|
expose?: boolean
|
|
328
|
+
/** True for pikkuUserFlow workflows (complex + actor steps). */
|
|
329
|
+
userFlow?: boolean
|
|
330
|
+
/** Actor names a user flow declares (personas it runs steps as). */
|
|
331
|
+
actors?: string[]
|
|
327
332
|
}
|
|
328
333
|
>
|
|
329
334
|
|
|
@@ -337,12 +342,14 @@ export interface WorkflowRuntimeMeta {
|
|
|
337
342
|
name: string
|
|
338
343
|
/** Pikku function name (for execution) */
|
|
339
344
|
pikkuFuncId: string
|
|
340
|
-
/** Source type: 'dsl' (serializable), 'complex' (has inline steps), 'graph' */
|
|
341
|
-
source: 'dsl' | 'complex' | 'graph' | 'dynamic-workflow'
|
|
345
|
+
/** Source type: 'dsl' (serializable), 'complex' (has inline steps), 'graph', 'user-flow' (complex + actor steps) */
|
|
346
|
+
source: 'dsl' | 'complex' | 'graph' | 'dynamic-workflow' | 'user-flow'
|
|
342
347
|
/** Optional description */
|
|
343
348
|
description?: string
|
|
344
349
|
/** Tags for organization */
|
|
345
350
|
tags?: string[]
|
|
351
|
+
/** Actor names a user flow declares (personas it runs steps as). */
|
|
352
|
+
actors?: string[]
|
|
346
353
|
/** Serialized nodes */
|
|
347
354
|
nodes?: Record<string, any>
|
|
348
355
|
/** Entry node IDs for graph workflows (computed at build time) */
|