@pikku/core 0.12.94 → 0.12.96
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 +179 -0
- package/dist/dev/hot-reload.js +24 -4
- package/dist/dev/module-runner.d.ts +20 -3
- package/dist/dev/module-runner.js +17 -4
- package/dist/services/email-template.d.ts +43 -0
- package/dist/services/email-template.js +139 -0
- package/dist/services/http-personas.d.ts +6 -1
- package/dist/services/http-personas.js +4 -1
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/wirings/agent/agent-prepare.d.ts +14 -0
- package/dist/wirings/agent/agent-prepare.js +24 -0
- package/dist/wirings/agent/index.d.ts +1 -1
- package/dist/wirings/agent/index.js +1 -1
- package/dist/wirings/scheduler/scheduler-runner.js +0 -1
- package/dist/wirings/virtual-user/index.d.ts +1 -0
- package/dist/wirings/virtual-user/index.js +1 -0
- package/dist/wirings/virtual-user/virtual-user-derive.js +9 -0
- package/dist/wirings/virtual-user/virtual-user-scaffold.d.ts +267 -0
- package/dist/wirings/virtual-user/virtual-user-scaffold.js +400 -0
- package/dist/wirings/workflow/index.d.ts +1 -0
- package/dist/wirings/workflow/index.js +1 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +3 -9
- package/dist/wirings/workflow/scenario-prose.d.ts +23 -1
- package/dist/wirings/workflow/scenario-prose.js +12 -3
- package/dist/wirings/workflow/scenario-run.types.d.ts +7 -0
- package/dist/wirings/workflow/workflow-queue-routing.d.ts +18 -0
- package/dist/wirings/workflow/workflow-queue-routing.js +35 -0
- package/dist/wirings/workflow/workflow-status-stream.d.ts +28 -0
- package/dist/wirings/workflow/workflow-status-stream.js +105 -0
- package/package.json +1 -1
- package/src/dev/hot-reload.test.ts +42 -0
- package/src/dev/hot-reload.ts +30 -4
- package/src/dev/module-runner.test.ts +56 -13
- package/src/dev/module-runner.ts +32 -10
- package/src/public-surface.json +17 -1
- package/src/services/email-template.test.ts +311 -0
- package/src/services/email-template.ts +254 -0
- package/src/services/http-personas.ts +10 -2
- package/src/services/index.ts +8 -0
- package/src/services/persona-sign-in.test.ts +22 -0
- package/src/wirings/agent/agent-helpers.test.ts +63 -0
- package/src/wirings/agent/agent-prepare.ts +25 -0
- package/src/wirings/agent/index.ts +1 -0
- package/src/wirings/scheduler/scheduler-runner.test.ts +178 -0
- package/src/wirings/scheduler/scheduler-runner.ts +0 -1
- package/src/wirings/virtual-user/index.ts +20 -0
- package/src/wirings/virtual-user/virtual-user-derive.test.ts +33 -5
- package/src/wirings/virtual-user/virtual-user-derive.ts +9 -0
- package/src/wirings/virtual-user/virtual-user-scaffold.test.ts +795 -0
- package/src/wirings/virtual-user/virtual-user-scaffold.ts +634 -0
- package/src/wirings/workflow/index.ts +4 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +71 -2
- package/src/wirings/workflow/pikku-workflow-service.ts +5 -11
- package/src/wirings/workflow/scenario-prose.test.ts +134 -9
- package/src/wirings/workflow/scenario-prose.ts +37 -2
- package/src/wirings/workflow/scenario-run.types.ts +7 -0
- package/src/wirings/workflow/workflow-child-run-session.test.ts +79 -0
- package/src/wirings/workflow/workflow-queue-routing.ts +44 -0
- package/src/wirings/workflow/workflow-status-stream.test.ts +354 -0
- package/src/wirings/workflow/workflow-status-stream.ts +144 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import type { Logger } from '../../services/logger.js';
|
|
2
|
+
import type { MetaService } from '../../services/meta-service.js';
|
|
3
|
+
import type { VariablesService } from '../../services/variables-service.js';
|
|
4
|
+
import type { AgentRunnerService } from '../../services/agent-runner-service.js';
|
|
5
|
+
import type { HttpPersonasConfig } from '../../services/http-personas.js';
|
|
6
|
+
import type { ResolvedPersona, ScenarioPersonas } from '../../services/personas-service.js';
|
|
7
|
+
import type { StepRecord, VirtualUserDisposition } from './virtual-user.types.js';
|
|
8
|
+
import type { VirtualUserRunRecord, VirtualUserRunStore } from './virtual-user-run-store.js';
|
|
9
|
+
import type { VirtualUserScheduleRecord, VirtualUserScheduleStore } from './virtual-user-schedule-store.js';
|
|
10
|
+
import type { VirtualUserTickResult } from './virtual-user-schedule.js';
|
|
11
|
+
/**
|
|
12
|
+
* The bodies behind the scaffolded virtual-user RPCs.
|
|
13
|
+
*
|
|
14
|
+
* The scaffold emits the *wirings* — the `pikkuFunc` shells whose `input`,
|
|
15
|
+
* `output` and `scopes` the CLI reads back by AST, and the `rpc.invoke` calls
|
|
16
|
+
* typed off the app's own RPC map. None of the work inside them varies by
|
|
17
|
+
* application, so it lives here instead of inside a template string: type
|
|
18
|
+
* checked when core builds, unit tested next to the engine it drives, and fixed
|
|
19
|
+
* once rather than in every generated copy of it.
|
|
20
|
+
*
|
|
21
|
+
* What an application does supply arrives as a parameter — its declared
|
|
22
|
+
* personas, its `createPersonas`, its config — because those are the only
|
|
23
|
+
* things codegen knows that this cannot.
|
|
24
|
+
*/
|
|
25
|
+
/** Persona id → the declaration, which is what `personaConfigs` is. */
|
|
26
|
+
export type ScaffoldPersonas = Record<string, ResolvedPersona>;
|
|
27
|
+
/**
|
|
28
|
+
* The variables a scaffolded run reads.
|
|
29
|
+
*
|
|
30
|
+
* Names rather than values, and read through `VariablesService` at run time, so
|
|
31
|
+
* a stage says where it lives without anything being baked into generated code.
|
|
32
|
+
*/
|
|
33
|
+
export declare const VIRTUAL_USER_VARIABLES: {
|
|
34
|
+
/**
|
|
35
|
+
* Where the virtual user signs in. Its own variable rather than a guess at
|
|
36
|
+
* the host's origin: a run drives real traffic through the real front door,
|
|
37
|
+
* and a server that cannot name its own public URL would be signing in
|
|
38
|
+
* somewhere it only assumed was itself.
|
|
39
|
+
*/
|
|
40
|
+
readonly apiUrl: "VIRTUAL_USER_API_URL";
|
|
41
|
+
readonly secret: "SCENARIO_ACTOR_SECRET";
|
|
42
|
+
readonly model: "VIRTUAL_USER_MODEL";
|
|
43
|
+
/**
|
|
44
|
+
* The same two variables a scenario run reads, because a virtual user signs
|
|
45
|
+
* in and calls through exactly the doors a scenario does. An app that mounts
|
|
46
|
+
* auth somewhere other than the root — `/api/auth` is the common one — has no
|
|
47
|
+
* other way to say so, and without them the run signs in against a 404 and
|
|
48
|
+
* spends its whole budget thinking about why nothing works.
|
|
49
|
+
*/
|
|
50
|
+
readonly signInPath: "SCENARIO_SIGN_IN_PATH";
|
|
51
|
+
readonly rpcPath: "SCENARIO_RPC_PATH";
|
|
52
|
+
/**
|
|
53
|
+
* The deployed way in. A Fabric operator token is asymmetric — a stage can
|
|
54
|
+
* verify one and can never mint one — so unlike the actor secret it is safe
|
|
55
|
+
* for a run against a real environment. Read from the environment only as the
|
|
56
|
+
* fallback for a run nobody handed a token to, which is what a schedule is.
|
|
57
|
+
*/
|
|
58
|
+
readonly operatorToken: "FABRIC_OPERATOR_TOKEN";
|
|
59
|
+
readonly createMissing: "PIKKU_PERSONA_CREATE_MISSING";
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Which door under the auth mount, given the credential in hand.
|
|
63
|
+
*
|
|
64
|
+
* Both are better-auth plugins mounted side by side, so `SCENARIO_SIGN_IN_PATH`
|
|
65
|
+
* names the mount and the last segment is ours to pick — an app that moved auth
|
|
66
|
+
* to `/api/auth` says so once and both paths follow. A path that names neither
|
|
67
|
+
* plugin is left alone, since it was configured deliberately.
|
|
68
|
+
*/
|
|
69
|
+
export declare const signInPathFor: (configured: string | undefined, plugin: "actor" | "fabric") => string | undefined;
|
|
70
|
+
/**
|
|
71
|
+
* The declared persona behind an id, refused unless it is one a run may be.
|
|
72
|
+
*
|
|
73
|
+
* An acted-upon persona has no session of its own, and running one would race
|
|
74
|
+
* whatever scenario acts on it.
|
|
75
|
+
*/
|
|
76
|
+
export declare const runnablePersona: (personas: ScaffoldPersonas, personaId: string) => ResolvedPersona;
|
|
77
|
+
/** The store, or the error naming the one to wire. */
|
|
78
|
+
export declare const requireVirtualUserRunStore: (store: VirtualUserRunStore | undefined, reading?: boolean) => VirtualUserRunStore;
|
|
79
|
+
export declare const requireVirtualUserScheduleStore: (store: VirtualUserScheduleStore | undefined) => VirtualUserScheduleStore;
|
|
80
|
+
/** What a caller asked for, before the declaration fills in what it left out. */
|
|
81
|
+
export interface StartVirtualUserRunParams {
|
|
82
|
+
store: VirtualUserRunStore | undefined;
|
|
83
|
+
personas: ScaffoldPersonas;
|
|
84
|
+
/**
|
|
85
|
+
* The app's config, read only for `nodeEnv` — structural because an
|
|
86
|
+
* application's Config is its own interface and need not declare it at all.
|
|
87
|
+
*/
|
|
88
|
+
config: {
|
|
89
|
+
nodeEnv?: string;
|
|
90
|
+
} | undefined;
|
|
91
|
+
persona: string;
|
|
92
|
+
disposition?: string;
|
|
93
|
+
seed?: number;
|
|
94
|
+
goals?: string[];
|
|
95
|
+
memory?: Record<string, string>;
|
|
96
|
+
/** Whoever the session says, which for a scheduled tick is the platform user. */
|
|
97
|
+
startedBy?: string | null;
|
|
98
|
+
}
|
|
99
|
+
/** The recorded run, and the values the dispatch has to carry unchanged. */
|
|
100
|
+
export interface StartedVirtualUserRun {
|
|
101
|
+
runId: string;
|
|
102
|
+
persona: string;
|
|
103
|
+
disposition: VirtualUserDisposition;
|
|
104
|
+
seed: number;
|
|
105
|
+
goals: string[];
|
|
106
|
+
memory: Record<string, string>;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Resolves a request against the declaration and records the run.
|
|
110
|
+
*
|
|
111
|
+
* Everything up to the point a run exists, which is everything a caller and a
|
|
112
|
+
* scheduled tick have in common. The dispatch that follows is typed off the
|
|
113
|
+
* app's RPC map, so it stays in the generated wiring.
|
|
114
|
+
*/
|
|
115
|
+
export declare const startVirtualUserRun: ({ store, personas, config, persona: personaId, disposition: requested, seed: requestedSeed, goals, memory, startedBy, }: StartVirtualUserRunParams) => Promise<StartedVirtualUserRun>;
|
|
116
|
+
/**
|
|
117
|
+
* One run on the wire.
|
|
118
|
+
*
|
|
119
|
+
* Findings and intents are free-form by design — the engine records what it
|
|
120
|
+
* noticed, not a fixed row shape — so they cross as the schema's open objects
|
|
121
|
+
* rather than being narrowed to whatever kinds exist today.
|
|
122
|
+
*/
|
|
123
|
+
export declare const serializeVirtualUserRun: (run: VirtualUserRunRecord) => {
|
|
124
|
+
runId: string;
|
|
125
|
+
persona: string;
|
|
126
|
+
disposition: VirtualUserDisposition;
|
|
127
|
+
seed: number;
|
|
128
|
+
status: "failed" | "running" | "completed";
|
|
129
|
+
goals: string[];
|
|
130
|
+
memory: Record<string, string>;
|
|
131
|
+
findings: {
|
|
132
|
+
kind: string;
|
|
133
|
+
detail: string;
|
|
134
|
+
rpcName: string | undefined;
|
|
135
|
+
status: number | undefined;
|
|
136
|
+
intentId: string | undefined;
|
|
137
|
+
step: number;
|
|
138
|
+
}[];
|
|
139
|
+
intents: {
|
|
140
|
+
id: string;
|
|
141
|
+
sourceId: string;
|
|
142
|
+
title: string;
|
|
143
|
+
status: string;
|
|
144
|
+
steps: number[];
|
|
145
|
+
suspensions: number;
|
|
146
|
+
summary: string | undefined;
|
|
147
|
+
}[];
|
|
148
|
+
tally: Record<string, unknown> | null;
|
|
149
|
+
stoppedBy: string | null;
|
|
150
|
+
error: string | null;
|
|
151
|
+
createdAt: string;
|
|
152
|
+
finishedAt: string | null;
|
|
153
|
+
};
|
|
154
|
+
/** One run's turns on the wire. */
|
|
155
|
+
export declare const serializeVirtualUserSteps: (steps: readonly StepRecord[]) => {
|
|
156
|
+
index: number;
|
|
157
|
+
intentId: string | undefined;
|
|
158
|
+
action: Record<string, unknown>;
|
|
159
|
+
status: number | undefined;
|
|
160
|
+
ok: boolean | undefined;
|
|
161
|
+
response: string | undefined;
|
|
162
|
+
findingKinds: string[] | undefined;
|
|
163
|
+
tokensIn: number;
|
|
164
|
+
tokensOut: number;
|
|
165
|
+
}[];
|
|
166
|
+
/**
|
|
167
|
+
* One schedule on the wire.
|
|
168
|
+
*
|
|
169
|
+
* The budget crosses as `durationMs` because that is what every other call here
|
|
170
|
+
* takes; the engine's own duration also accepts `'30m'`, which nothing on this
|
|
171
|
+
* side ever writes.
|
|
172
|
+
*/
|
|
173
|
+
export declare const serializeVirtualUserSchedule: (schedule: VirtualUserScheduleRecord, personas: ScaffoldPersonas) => {
|
|
174
|
+
persona: string;
|
|
175
|
+
enabled: boolean;
|
|
176
|
+
disposition: VirtualUserDisposition;
|
|
177
|
+
goals: string[];
|
|
178
|
+
budget: {
|
|
179
|
+
steps: number | undefined;
|
|
180
|
+
mutations: number | undefined;
|
|
181
|
+
durationMs: number | undefined;
|
|
182
|
+
} | null;
|
|
183
|
+
minIntervalMs: number;
|
|
184
|
+
maxIntervalMs: number;
|
|
185
|
+
nextRunAt: string;
|
|
186
|
+
lastRunId: string | null;
|
|
187
|
+
lastRunAt: string | null;
|
|
188
|
+
declared: {
|
|
189
|
+
disposition: VirtualUserDisposition;
|
|
190
|
+
goals: string[];
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
export interface WriteVirtualUserScheduleParams {
|
|
194
|
+
store: VirtualUserScheduleStore | undefined;
|
|
195
|
+
personas: ScaffoldPersonas;
|
|
196
|
+
persona: string;
|
|
197
|
+
enabled?: boolean;
|
|
198
|
+
disposition?: string;
|
|
199
|
+
goals?: string[];
|
|
200
|
+
budget?: {
|
|
201
|
+
steps?: number;
|
|
202
|
+
mutations?: number;
|
|
203
|
+
durationMs?: number;
|
|
204
|
+
} | null;
|
|
205
|
+
minIntervalMs?: number;
|
|
206
|
+
maxIntervalMs?: number;
|
|
207
|
+
nextRunAt?: string;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Writes a persona's cadence.
|
|
211
|
+
*
|
|
212
|
+
* Applies the same rule `startVirtualUserRun` enforces, at the point the row is
|
|
213
|
+
* written rather than every hour afterwards: an acted-upon persona has no
|
|
214
|
+
* session, so a cadence for one is a tick that can only ever fail to start.
|
|
215
|
+
*/
|
|
216
|
+
export declare const writeVirtualUserSchedule: ({ store, personas, persona, enabled, disposition, goals, budget, minIntervalMs, maxIntervalMs, nextRunAt, }: WriteVirtualUserScheduleParams) => Promise<VirtualUserScheduleRecord>;
|
|
217
|
+
/** What a due schedule asks `runVirtualUser` for. */
|
|
218
|
+
export declare const virtualUserScheduleRunInput: (schedule: VirtualUserScheduleRecord) => {
|
|
219
|
+
persona: string;
|
|
220
|
+
disposition: VirtualUserDisposition;
|
|
221
|
+
goals: string[];
|
|
222
|
+
budget: {
|
|
223
|
+
steps: number | undefined;
|
|
224
|
+
mutations: number | undefined;
|
|
225
|
+
durationMs: number | undefined;
|
|
226
|
+
} | undefined;
|
|
227
|
+
};
|
|
228
|
+
/**
|
|
229
|
+
* What a tick did.
|
|
230
|
+
*
|
|
231
|
+
* Logged rather than returned: the caller is a cron, and a run this started is
|
|
232
|
+
* otherwise the only trace that a persona is still out there working.
|
|
233
|
+
*/
|
|
234
|
+
export declare const logVirtualUserTick: (logger: Logger, result: VirtualUserTickResult) => void;
|
|
235
|
+
export interface ExecuteVirtualUserRunParams {
|
|
236
|
+
runStore: VirtualUserRunStore | undefined;
|
|
237
|
+
metaService: MetaService | undefined;
|
|
238
|
+
agentRunner: AgentRunnerService | undefined;
|
|
239
|
+
variables: VariablesService;
|
|
240
|
+
logger: Logger;
|
|
241
|
+
personas: ScaffoldPersonas;
|
|
242
|
+
/** The app's generated `createPersonas`, which knows its own persona ids. */
|
|
243
|
+
createPersonas: (options: Omit<HttpPersonasConfig, 'personas'>) => ScenarioPersonas;
|
|
244
|
+
runId: string;
|
|
245
|
+
persona: string;
|
|
246
|
+
disposition: string;
|
|
247
|
+
goals: string[];
|
|
248
|
+
memory: Record<string, string>;
|
|
249
|
+
seed: number;
|
|
250
|
+
budget?: {
|
|
251
|
+
steps?: number;
|
|
252
|
+
mutations?: number;
|
|
253
|
+
durationMs?: number;
|
|
254
|
+
};
|
|
255
|
+
operatorToken?: string;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* The run itself.
|
|
259
|
+
*
|
|
260
|
+
* Everything it needs is derived through `metaService` and the generated
|
|
261
|
+
* personas — the same public surface any consumer has. Nothing reaches into
|
|
262
|
+
* pikku's internals, because an app could not, and a feature built on what only
|
|
263
|
+
* the framework can see would not be this feature.
|
|
264
|
+
*/
|
|
265
|
+
export declare const executeVirtualUserRun: ({ runStore, metaService, agentRunner, variables, logger, personas, createPersonas, runId, persona: personaId, disposition, goals, memory, seed, budget, operatorToken, }: ExecuteVirtualUserRunParams) => Promise<{
|
|
266
|
+
findings: number;
|
|
267
|
+
}>;
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { prepareVirtualUserRun } from './prepare-virtual-user-run.js';
|
|
2
|
+
import { runVirtualUser as runVirtualUserEngine } from './run-virtual-user.js';
|
|
3
|
+
import { personaVirtualUserTarget } from './virtual-user-target.js';
|
|
4
|
+
import { PRODUCTION_DISPOSITION } from './virtual-user.types.js';
|
|
5
|
+
/**
|
|
6
|
+
* The variables a scaffolded run reads.
|
|
7
|
+
*
|
|
8
|
+
* Names rather than values, and read through `VariablesService` at run time, so
|
|
9
|
+
* a stage says where it lives without anything being baked into generated code.
|
|
10
|
+
*/
|
|
11
|
+
export const VIRTUAL_USER_VARIABLES = {
|
|
12
|
+
/**
|
|
13
|
+
* Where the virtual user signs in. Its own variable rather than a guess at
|
|
14
|
+
* the host's origin: a run drives real traffic through the real front door,
|
|
15
|
+
* and a server that cannot name its own public URL would be signing in
|
|
16
|
+
* somewhere it only assumed was itself.
|
|
17
|
+
*/
|
|
18
|
+
apiUrl: 'VIRTUAL_USER_API_URL',
|
|
19
|
+
secret: 'SCENARIO_ACTOR_SECRET',
|
|
20
|
+
model: 'VIRTUAL_USER_MODEL',
|
|
21
|
+
/**
|
|
22
|
+
* The same two variables a scenario run reads, because a virtual user signs
|
|
23
|
+
* in and calls through exactly the doors a scenario does. An app that mounts
|
|
24
|
+
* auth somewhere other than the root — `/api/auth` is the common one — has no
|
|
25
|
+
* other way to say so, and without them the run signs in against a 404 and
|
|
26
|
+
* spends its whole budget thinking about why nothing works.
|
|
27
|
+
*/
|
|
28
|
+
signInPath: 'SCENARIO_SIGN_IN_PATH',
|
|
29
|
+
rpcPath: 'SCENARIO_RPC_PATH',
|
|
30
|
+
/**
|
|
31
|
+
* The deployed way in. A Fabric operator token is asymmetric — a stage can
|
|
32
|
+
* verify one and can never mint one — so unlike the actor secret it is safe
|
|
33
|
+
* for a run against a real environment. Read from the environment only as the
|
|
34
|
+
* fallback for a run nobody handed a token to, which is what a schedule is.
|
|
35
|
+
*/
|
|
36
|
+
operatorToken: 'FABRIC_OPERATOR_TOKEN',
|
|
37
|
+
createMissing: 'PIKKU_PERSONA_CREATE_MISSING',
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Which door under the auth mount, given the credential in hand.
|
|
41
|
+
*
|
|
42
|
+
* Both are better-auth plugins mounted side by side, so `SCENARIO_SIGN_IN_PATH`
|
|
43
|
+
* names the mount and the last segment is ours to pick — an app that moved auth
|
|
44
|
+
* to `/api/auth` says so once and both paths follow. A path that names neither
|
|
45
|
+
* plugin is left alone, since it was configured deliberately.
|
|
46
|
+
*/
|
|
47
|
+
export const signInPathFor = (configured, plugin) => {
|
|
48
|
+
if (!configured) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const mount = configured.replace(/\/sign-in\/(actor|fabric)$/, '');
|
|
52
|
+
return mount === configured ? configured : `${mount}/sign-in/${plugin}`;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* The declared persona behind an id, refused unless it is one a run may be.
|
|
56
|
+
*
|
|
57
|
+
* An acted-upon persona has no session of its own, and running one would race
|
|
58
|
+
* whatever scenario acts on it.
|
|
59
|
+
*/
|
|
60
|
+
export const runnablePersona = (personas, personaId) => {
|
|
61
|
+
const persona = personas[personaId];
|
|
62
|
+
if (!persona) {
|
|
63
|
+
throw new Error(`Unknown persona "${personaId}" — declare it with definePersonas()`);
|
|
64
|
+
}
|
|
65
|
+
if (!persona.runnable) {
|
|
66
|
+
throw new Error(`Persona "${personaId}" is declared as acted upon, never run`);
|
|
67
|
+
}
|
|
68
|
+
return persona;
|
|
69
|
+
};
|
|
70
|
+
const MISSING_RUN_STORE = 'No virtualUserRunStore is wired — a run has nowhere to be recorded. ' +
|
|
71
|
+
'Wire KyselyVirtualUserRunStore from @pikku/kysely, or your own implementation of VirtualUserRunStore.';
|
|
72
|
+
const MISSING_RUN_STORE_READ = 'No virtualUserRunStore is wired — there are no runs to read.';
|
|
73
|
+
const MISSING_SCHEDULE_STORE = 'No virtualUserScheduleStore is wired — a cadence has nowhere to live. ' +
|
|
74
|
+
'Wire KyselyVirtualUserScheduleStore from @pikku/kysely, or your own implementation of VirtualUserScheduleStore.';
|
|
75
|
+
/** The store, or the error naming the one to wire. */
|
|
76
|
+
export const requireVirtualUserRunStore = (store, reading = false) => {
|
|
77
|
+
if (!store) {
|
|
78
|
+
throw new Error(reading ? MISSING_RUN_STORE_READ : MISSING_RUN_STORE);
|
|
79
|
+
}
|
|
80
|
+
return store;
|
|
81
|
+
};
|
|
82
|
+
export const requireVirtualUserScheduleStore = (store) => {
|
|
83
|
+
if (!store) {
|
|
84
|
+
throw new Error(MISSING_SCHEDULE_STORE);
|
|
85
|
+
}
|
|
86
|
+
return store;
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Resolves a request against the declaration and records the run.
|
|
90
|
+
*
|
|
91
|
+
* Everything up to the point a run exists, which is everything a caller and a
|
|
92
|
+
* scheduled tick have in common. The dispatch that follows is typed off the
|
|
93
|
+
* app's RPC map, so it stays in the generated wiring.
|
|
94
|
+
*/
|
|
95
|
+
export const startVirtualUserRun = async ({ store, personas, config, persona: personaId, disposition: requested, seed: requestedSeed, goals, memory, startedBy, }) => {
|
|
96
|
+
const runStore = requireVirtualUserRunStore(store);
|
|
97
|
+
const persona = runnablePersona(personas, personaId);
|
|
98
|
+
const disposition = (requested ??
|
|
99
|
+
persona.disposition ??
|
|
100
|
+
'realistic');
|
|
101
|
+
// Every disposition other than this one exists to find out what the product
|
|
102
|
+
// does wrong, which is not a thing to do to real customers' data. Checked
|
|
103
|
+
// against the effective disposition, so an override cannot smuggle one in.
|
|
104
|
+
if (config?.nodeEnv === 'production' &&
|
|
105
|
+
disposition !== PRODUCTION_DISPOSITION) {
|
|
106
|
+
throw new Error(`Only the '${PRODUCTION_DISPOSITION}' disposition may run against production; "${personaId}" is ${disposition}`);
|
|
107
|
+
}
|
|
108
|
+
// Seeded here rather than inside the engine so the record carries the seed
|
|
109
|
+
// even if the run dies before returning — an unreproducible crash costs the
|
|
110
|
+
// most.
|
|
111
|
+
const seed = requestedSeed ?? Math.floor(Math.random() * 2_147_483_647);
|
|
112
|
+
const resolvedGoals = goals ?? [];
|
|
113
|
+
const resolvedMemory = memory ?? {};
|
|
114
|
+
const runId = await runStore.start({
|
|
115
|
+
persona: persona.id,
|
|
116
|
+
disposition,
|
|
117
|
+
seed,
|
|
118
|
+
goals: resolvedGoals,
|
|
119
|
+
memory: resolvedMemory,
|
|
120
|
+
startedBy: startedBy ?? null,
|
|
121
|
+
});
|
|
122
|
+
return {
|
|
123
|
+
runId,
|
|
124
|
+
persona: persona.id,
|
|
125
|
+
disposition,
|
|
126
|
+
seed,
|
|
127
|
+
goals: resolvedGoals,
|
|
128
|
+
memory: resolvedMemory,
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* One run on the wire.
|
|
133
|
+
*
|
|
134
|
+
* Findings and intents are free-form by design — the engine records what it
|
|
135
|
+
* noticed, not a fixed row shape — so they cross as the schema's open objects
|
|
136
|
+
* rather than being narrowed to whatever kinds exist today.
|
|
137
|
+
*/
|
|
138
|
+
export const serializeVirtualUserRun = (run) => ({
|
|
139
|
+
runId: run.runId,
|
|
140
|
+
persona: run.persona,
|
|
141
|
+
disposition: run.disposition,
|
|
142
|
+
seed: run.seed,
|
|
143
|
+
status: run.status,
|
|
144
|
+
goals: run.goals,
|
|
145
|
+
memory: run.memory,
|
|
146
|
+
findings: run.findings.map((finding) => ({
|
|
147
|
+
kind: finding.kind,
|
|
148
|
+
detail: finding.detail,
|
|
149
|
+
rpcName: finding.rpcName,
|
|
150
|
+
status: finding.status,
|
|
151
|
+
intentId: finding.intentId,
|
|
152
|
+
step: finding.step,
|
|
153
|
+
})),
|
|
154
|
+
intents: run.intents.map((intent) => ({
|
|
155
|
+
id: intent.id,
|
|
156
|
+
sourceId: intent.sourceId,
|
|
157
|
+
title: intent.title,
|
|
158
|
+
status: intent.status,
|
|
159
|
+
steps: intent.steps,
|
|
160
|
+
suspensions: intent.suspensions,
|
|
161
|
+
summary: intent.summary,
|
|
162
|
+
})),
|
|
163
|
+
tally: (run.tally ?? null),
|
|
164
|
+
stoppedBy: run.stoppedBy,
|
|
165
|
+
error: run.error,
|
|
166
|
+
createdAt: run.createdAt.toISOString(),
|
|
167
|
+
finishedAt: run.finishedAt ? run.finishedAt.toISOString() : null,
|
|
168
|
+
});
|
|
169
|
+
/** One run's turns on the wire. */
|
|
170
|
+
export const serializeVirtualUserSteps = (steps) => steps.map((step) => ({
|
|
171
|
+
index: step.index,
|
|
172
|
+
intentId: step.intentId,
|
|
173
|
+
action: step.action,
|
|
174
|
+
status: step.status,
|
|
175
|
+
ok: step.ok,
|
|
176
|
+
response: step.response,
|
|
177
|
+
findingKinds: step.findingKinds,
|
|
178
|
+
tokensIn: step.tokensIn,
|
|
179
|
+
tokensOut: step.tokensOut,
|
|
180
|
+
}));
|
|
181
|
+
/**
|
|
182
|
+
* One schedule on the wire.
|
|
183
|
+
*
|
|
184
|
+
* The budget crosses as `durationMs` because that is what every other call here
|
|
185
|
+
* takes; the engine's own duration also accepts `'30m'`, which nothing on this
|
|
186
|
+
* side ever writes.
|
|
187
|
+
*/
|
|
188
|
+
export const serializeVirtualUserSchedule = (schedule, personas) => {
|
|
189
|
+
const persona = personas[schedule.persona];
|
|
190
|
+
return {
|
|
191
|
+
persona: schedule.persona,
|
|
192
|
+
enabled: schedule.enabled,
|
|
193
|
+
disposition: schedule.disposition,
|
|
194
|
+
goals: schedule.goals,
|
|
195
|
+
budget: schedule.budget
|
|
196
|
+
? {
|
|
197
|
+
steps: schedule.budget.steps,
|
|
198
|
+
mutations: schedule.budget.mutations,
|
|
199
|
+
durationMs: typeof schedule.budget.duration === 'number'
|
|
200
|
+
? schedule.budget.duration
|
|
201
|
+
: undefined,
|
|
202
|
+
}
|
|
203
|
+
: null,
|
|
204
|
+
minIntervalMs: schedule.minIntervalMs,
|
|
205
|
+
maxIntervalMs: schedule.maxIntervalMs,
|
|
206
|
+
nextRunAt: schedule.nextRunAt.toISOString(),
|
|
207
|
+
lastRunId: schedule.lastRunId,
|
|
208
|
+
lastRunAt: schedule.lastRunAt ? schedule.lastRunAt.toISOString() : null,
|
|
209
|
+
declared: {
|
|
210
|
+
disposition: (persona?.disposition ??
|
|
211
|
+
'realistic'),
|
|
212
|
+
goals: persona?.goals ?? [],
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
/**
|
|
217
|
+
* Writes a persona's cadence.
|
|
218
|
+
*
|
|
219
|
+
* Applies the same rule `startVirtualUserRun` enforces, at the point the row is
|
|
220
|
+
* written rather than every hour afterwards: an acted-upon persona has no
|
|
221
|
+
* session, so a cadence for one is a tick that can only ever fail to start.
|
|
222
|
+
*/
|
|
223
|
+
export const writeVirtualUserSchedule = async ({ store, personas, persona, enabled, disposition, goals, budget, minIntervalMs, maxIntervalMs, nextRunAt, }) => {
|
|
224
|
+
const scheduleStore = requireVirtualUserScheduleStore(store);
|
|
225
|
+
runnablePersona(personas, persona);
|
|
226
|
+
return scheduleStore.set({
|
|
227
|
+
persona,
|
|
228
|
+
enabled,
|
|
229
|
+
disposition: disposition,
|
|
230
|
+
goals,
|
|
231
|
+
budget: budget === undefined
|
|
232
|
+
? undefined
|
|
233
|
+
: budget === null
|
|
234
|
+
? null
|
|
235
|
+
: {
|
|
236
|
+
steps: budget.steps,
|
|
237
|
+
mutations: budget.mutations,
|
|
238
|
+
duration: budget.durationMs,
|
|
239
|
+
},
|
|
240
|
+
minIntervalMs,
|
|
241
|
+
maxIntervalMs,
|
|
242
|
+
nextRunAt: nextRunAt ? new Date(nextRunAt) : undefined,
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
/** What a due schedule asks `runVirtualUser` for. */
|
|
246
|
+
export const virtualUserScheduleRunInput = (schedule) => ({
|
|
247
|
+
persona: schedule.persona,
|
|
248
|
+
disposition: schedule.disposition,
|
|
249
|
+
goals: schedule.goals,
|
|
250
|
+
budget: schedule.budget
|
|
251
|
+
? {
|
|
252
|
+
steps: schedule.budget.steps,
|
|
253
|
+
mutations: schedule.budget.mutations,
|
|
254
|
+
durationMs: typeof schedule.budget.duration === 'number'
|
|
255
|
+
? schedule.budget.duration
|
|
256
|
+
: undefined,
|
|
257
|
+
}
|
|
258
|
+
: undefined,
|
|
259
|
+
});
|
|
260
|
+
/**
|
|
261
|
+
* What a tick did.
|
|
262
|
+
*
|
|
263
|
+
* Logged rather than returned: the caller is a cron, and a run this started is
|
|
264
|
+
* otherwise the only trace that a persona is still out there working.
|
|
265
|
+
*/
|
|
266
|
+
export const logVirtualUserTick = (logger, result) => {
|
|
267
|
+
for (const { persona, runId } of result.dispatched) {
|
|
268
|
+
logger.info(`Virtual user ${persona} started run ${runId} on schedule`);
|
|
269
|
+
}
|
|
270
|
+
for (const runId of result.reaped) {
|
|
271
|
+
logger.warn(`Virtual user run ${runId} was abandoned — marked failed so its persona can run again`);
|
|
272
|
+
}
|
|
273
|
+
for (const { persona, reason } of result.skipped) {
|
|
274
|
+
logger.info(`Virtual user ${persona} skipped this tick: ${reason}`);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
/**
|
|
278
|
+
* The run itself.
|
|
279
|
+
*
|
|
280
|
+
* Everything it needs is derived through `metaService` and the generated
|
|
281
|
+
* personas — the same public surface any consumer has. Nothing reaches into
|
|
282
|
+
* pikku's internals, because an app could not, and a feature built on what only
|
|
283
|
+
* the framework can see would not be this feature.
|
|
284
|
+
*/
|
|
285
|
+
export const executeVirtualUserRun = async ({ runStore, metaService, agentRunner, variables, logger, personas, createPersonas, runId, persona: personaId, disposition, goals, memory, seed, budget, operatorToken, }) => {
|
|
286
|
+
const store = requireVirtualUserRunStore(runStore);
|
|
287
|
+
try {
|
|
288
|
+
if (!metaService) {
|
|
289
|
+
throw new Error('metaService is not wired — there is no catalogue to derive');
|
|
290
|
+
}
|
|
291
|
+
if (!agentRunner) {
|
|
292
|
+
throw new Error('agentRunner is not wired — there is nothing to think with');
|
|
293
|
+
}
|
|
294
|
+
const apiUrl = await variables.get(VIRTUAL_USER_VARIABLES.apiUrl);
|
|
295
|
+
if (!apiUrl) {
|
|
296
|
+
throw new Error(`${VIRTUAL_USER_VARIABLES.apiUrl} is not set — a virtual user has no address to sign in at.`);
|
|
297
|
+
}
|
|
298
|
+
// An operator token wins wherever one is available: it is asymmetric, and
|
|
299
|
+
// it does not need the target to hold a shared secret at all. The actor
|
|
300
|
+
// secret is the local-only fallback, because only `pikku dev` serves the
|
|
301
|
+
// endpoint that accepts it.
|
|
302
|
+
const token = operatorToken ??
|
|
303
|
+
(await variables.get(VIRTUAL_USER_VARIABLES.operatorToken));
|
|
304
|
+
const secret = token
|
|
305
|
+
? undefined
|
|
306
|
+
: await variables.get(VIRTUAL_USER_VARIABLES.secret);
|
|
307
|
+
if (!token && !secret) {
|
|
308
|
+
throw new Error(`Neither an operator token nor ${VIRTUAL_USER_VARIABLES.secret} is available — there is nobody for the virtual user to be. ` +
|
|
309
|
+
`Hand a Fabric operator token in with the run against a deployed stage, or export ${VIRTUAL_USER_VARIABLES.secret} against a local \`pikku dev\` target.`);
|
|
310
|
+
}
|
|
311
|
+
const createMissing = String(await variables.get(VIRTUAL_USER_VARIABLES.createMissing)) ===
|
|
312
|
+
'true';
|
|
313
|
+
const model = await variables.get(VIRTUAL_USER_VARIABLES.model);
|
|
314
|
+
if (!model) {
|
|
315
|
+
throw new Error(`${VIRTUAL_USER_VARIABLES.model} is not set — no model to think with.`);
|
|
316
|
+
}
|
|
317
|
+
const functionsMeta = await metaService.getFunctionsMeta();
|
|
318
|
+
// Only the schemas the catalogue can actually refer to, so a large app does
|
|
319
|
+
// not pull every schema it has ever generated into one run.
|
|
320
|
+
const schemaNames = [
|
|
321
|
+
...new Set(Object.values(functionsMeta).flatMap((meta) => [meta.inputSchemaName, meta.outputSchemaName].filter((name) => !!name))),
|
|
322
|
+
];
|
|
323
|
+
const schemas = (await metaService.getSchemas(schemaNames));
|
|
324
|
+
const persona = personas[personaId];
|
|
325
|
+
if (!persona) {
|
|
326
|
+
throw new Error(`Persona "${personaId}" is no longer declared`);
|
|
327
|
+
}
|
|
328
|
+
const { catalogue, intents, agents } = prepareVirtualUserRun({
|
|
329
|
+
persona,
|
|
330
|
+
functionsMeta,
|
|
331
|
+
schemas,
|
|
332
|
+
workflowsMeta: await metaService.getWorkflowMeta(),
|
|
333
|
+
systemRoles: await metaService.getSystemRolesMeta(),
|
|
334
|
+
agentsMeta: await metaService.getAgentsMeta(),
|
|
335
|
+
});
|
|
336
|
+
const configuredSignInPath = (await variables.get(VIRTUAL_USER_VARIABLES.signInPath)) ?? undefined;
|
|
337
|
+
const signedIn = createPersonas({
|
|
338
|
+
apiUrl,
|
|
339
|
+
...(token
|
|
340
|
+
? {
|
|
341
|
+
operator: {
|
|
342
|
+
token,
|
|
343
|
+
createMissing,
|
|
344
|
+
signInPath: signInPathFor(configuredSignInPath, 'fabric'),
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
: { secret }),
|
|
348
|
+
model,
|
|
349
|
+
signInPath: signInPathFor(configuredSignInPath, 'actor'),
|
|
350
|
+
rpcPath: (await variables.get(VIRTUAL_USER_VARIABLES.rpcPath)) ?? undefined,
|
|
351
|
+
});
|
|
352
|
+
const target = signedIn[personaId];
|
|
353
|
+
if (!target) {
|
|
354
|
+
throw new Error(`Persona "${personaId}" cannot sign in`);
|
|
355
|
+
}
|
|
356
|
+
const result = await runVirtualUserEngine({
|
|
357
|
+
persona,
|
|
358
|
+
personaId,
|
|
359
|
+
disposition: disposition,
|
|
360
|
+
catalogue,
|
|
361
|
+
intents,
|
|
362
|
+
goals,
|
|
363
|
+
memory,
|
|
364
|
+
seed,
|
|
365
|
+
agents,
|
|
366
|
+
target: personaVirtualUserTarget(target, {
|
|
367
|
+
model,
|
|
368
|
+
agents: agents.map((agent) => agent.name),
|
|
369
|
+
}),
|
|
370
|
+
// `AgentRunnerService.run` IS the engine's `ActorLLM` — same params, same
|
|
371
|
+
// result — so a virtual user thinks through the same runner every agent in
|
|
372
|
+
// the app does, provider quirks and all.
|
|
373
|
+
llm: (params) => agentRunner.run(params),
|
|
374
|
+
model,
|
|
375
|
+
budget: {
|
|
376
|
+
steps: budget?.steps,
|
|
377
|
+
mutations: budget?.mutations,
|
|
378
|
+
duration: budget?.durationMs,
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
await store.complete(runId, {
|
|
382
|
+
findings: result.findings,
|
|
383
|
+
tally: result.tally,
|
|
384
|
+
memory: result.memory,
|
|
385
|
+
stoppedBy: result.stoppedBy ?? null,
|
|
386
|
+
intents: result.intents,
|
|
387
|
+
steps: result.steps,
|
|
388
|
+
});
|
|
389
|
+
return { findings: result.findings.length };
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
// A crashed run and a run that found nothing are different states, and the
|
|
393
|
+
// record is the only place that distinction survives — leaving it at
|
|
394
|
+
// 'running' forever is what `fail` exists to prevent.
|
|
395
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
396
|
+
logger.error(`Virtual user run ${runId} (${personaId}) failed: ${message}`);
|
|
397
|
+
await store.fail(runId, message);
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
};
|
|
@@ -3,6 +3,7 @@ export { WorkflowCancelledException, WorkflowSuspendedException, WorkflowDispatc
|
|
|
3
3
|
export { DEFAULT_STEP_RETRIES } from './workflow-constants.js';
|
|
4
4
|
export { assertWorkflowRunOwner, WorkflowRunForbiddenError, } from './workflow-run-ownership.js';
|
|
5
5
|
export { WorkflowApprovalForbiddenError } from './workflow-approval-policy.js';
|
|
6
|
+
export { streamWorkflowRunStatus, type WorkflowStatusStreamParams, } from './workflow-status-stream.js';
|
|
6
7
|
export type { WorkflowRunEngine, WorkflowRunExtension, } from './workflow-run-engine.types.js';
|
|
7
8
|
export { deriveInvocationId, uuidv5 } from './workflow-invocation-id.js';
|
|
8
9
|
export { isRef } from './graph/workflow-graph.types.js';
|
|
@@ -3,6 +3,7 @@ export { WorkflowCancelledException, WorkflowSuspendedException, WorkflowDispatc
|
|
|
3
3
|
export { DEFAULT_STEP_RETRIES } from './workflow-constants.js';
|
|
4
4
|
export { assertWorkflowRunOwner, WorkflowRunForbiddenError, } from './workflow-run-ownership.js';
|
|
5
5
|
export { WorkflowApprovalForbiddenError } from './workflow-approval-policy.js';
|
|
6
|
+
export { streamWorkflowRunStatus, } from './workflow-status-stream.js';
|
|
6
7
|
export { deriveInvocationId, uuidv5 } from './workflow-invocation-id.js';
|
|
7
8
|
export { isRef } from './graph/workflow-graph.types.js';
|
|
8
9
|
export { buildRunTimeline, reconstructStateAt, reconstructFinalState, } from './run-timeline.js';
|