@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,795 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {
|
|
4
|
+
executeVirtualUserRun,
|
|
5
|
+
logVirtualUserTick,
|
|
6
|
+
requireVirtualUserRunStore,
|
|
7
|
+
runnablePersona,
|
|
8
|
+
serializeVirtualUserRun,
|
|
9
|
+
serializeVirtualUserSchedule,
|
|
10
|
+
serializeVirtualUserSteps,
|
|
11
|
+
signInPathFor,
|
|
12
|
+
startVirtualUserRun,
|
|
13
|
+
virtualUserScheduleRunInput,
|
|
14
|
+
writeVirtualUserSchedule,
|
|
15
|
+
type ScaffoldPersonas,
|
|
16
|
+
} from './virtual-user-scaffold.js'
|
|
17
|
+
import type {
|
|
18
|
+
VirtualUserRunRecord,
|
|
19
|
+
VirtualUserRunStore,
|
|
20
|
+
} from './virtual-user-run-store.js'
|
|
21
|
+
import type {
|
|
22
|
+
VirtualUserScheduleRecord,
|
|
23
|
+
VirtualUserScheduleStore,
|
|
24
|
+
} from './virtual-user-schedule-store.js'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* These are the bodies of the scaffolded RPCs, so what is asserted here used to
|
|
28
|
+
* be asserted by matching the generated source with a regular expression. The
|
|
29
|
+
* properties are the same ones; the difference is that they are now checked by
|
|
30
|
+
* running the code rather than by reading it.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const personas = {
|
|
34
|
+
susan: {
|
|
35
|
+
id: 'susan',
|
|
36
|
+
name: 'Susan',
|
|
37
|
+
email: 'susan@actors.local',
|
|
38
|
+
roles: [],
|
|
39
|
+
goals: ['Get the weekly order in'],
|
|
40
|
+
tags: [],
|
|
41
|
+
runnable: true,
|
|
42
|
+
disposition: 'realistic',
|
|
43
|
+
},
|
|
44
|
+
observed: {
|
|
45
|
+
id: 'observed',
|
|
46
|
+
name: 'Observed',
|
|
47
|
+
email: 'observed@actors.local',
|
|
48
|
+
roles: [],
|
|
49
|
+
goals: [],
|
|
50
|
+
tags: [],
|
|
51
|
+
runnable: false,
|
|
52
|
+
},
|
|
53
|
+
} as unknown as ScaffoldPersonas
|
|
54
|
+
|
|
55
|
+
const runStore = () => {
|
|
56
|
+
const started: any[] = []
|
|
57
|
+
const completed: any[] = []
|
|
58
|
+
const failed: any[] = []
|
|
59
|
+
const store = {
|
|
60
|
+
start: async (start: any) => {
|
|
61
|
+
started.push(start)
|
|
62
|
+
return `run-${started.length}`
|
|
63
|
+
},
|
|
64
|
+
complete: async (runId: string, outcome: any) => {
|
|
65
|
+
completed.push({ runId, outcome })
|
|
66
|
+
},
|
|
67
|
+
fail: async (runId: string, error: string) => {
|
|
68
|
+
failed.push({ runId, error })
|
|
69
|
+
},
|
|
70
|
+
get: async () => null,
|
|
71
|
+
list: async () => [],
|
|
72
|
+
steps: async () => [],
|
|
73
|
+
} as unknown as VirtualUserRunStore
|
|
74
|
+
return { store, started, completed, failed }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const variables = (values: Record<string, string>) =>
|
|
78
|
+
({
|
|
79
|
+
get: async (name: string) => values[name],
|
|
80
|
+
getVariables: async () => ({}),
|
|
81
|
+
getAll: async () => values,
|
|
82
|
+
set: async () => {},
|
|
83
|
+
has: async (name: string) => name in values,
|
|
84
|
+
delete: async () => {},
|
|
85
|
+
}) as any
|
|
86
|
+
|
|
87
|
+
const silentLogger = () => {
|
|
88
|
+
const lines: string[] = []
|
|
89
|
+
return {
|
|
90
|
+
lines,
|
|
91
|
+
logger: {
|
|
92
|
+
debug: (m: string) => lines.push(`debug ${m}`),
|
|
93
|
+
info: (m: string) => lines.push(`info ${m}`),
|
|
94
|
+
warn: (m: string) => lines.push(`warn ${m}`),
|
|
95
|
+
error: (m: string) => lines.push(`error ${m}`),
|
|
96
|
+
} as any,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const emptyMeta = {
|
|
101
|
+
getFunctionsMeta: async () => ({}),
|
|
102
|
+
getSchemas: async () => ({}),
|
|
103
|
+
getWorkflowMeta: async () => ({}),
|
|
104
|
+
getSystemRolesMeta: async () => ({}),
|
|
105
|
+
getAgentsMeta: async () => ({}),
|
|
106
|
+
} as any
|
|
107
|
+
|
|
108
|
+
describe('signInPathFor', () => {
|
|
109
|
+
// Both are better-auth plugins on one mount, so an app that moved auth to
|
|
110
|
+
// `/api/auth` should not have to name each door separately.
|
|
111
|
+
test('an app that moved its auth mount moves both sign-in paths', () => {
|
|
112
|
+
assert.equal(
|
|
113
|
+
signInPathFor('/api/auth/sign-in/actor', 'fabric'),
|
|
114
|
+
'/api/auth/sign-in/fabric'
|
|
115
|
+
)
|
|
116
|
+
assert.equal(
|
|
117
|
+
signInPathFor('/api/auth/sign-in/fabric', 'actor'),
|
|
118
|
+
'/api/auth/sign-in/actor'
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('a path naming neither plugin was configured deliberately', () => {
|
|
123
|
+
assert.equal(signInPathFor('/custom/login', 'actor'), '/custom/login')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('nothing configured stays nothing, so the default applies', () => {
|
|
127
|
+
assert.equal(signInPathFor(undefined, 'actor'), undefined)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('runnablePersona', () => {
|
|
132
|
+
test('an undeclared persona is named, with what declares one', () => {
|
|
133
|
+
assert.throws(
|
|
134
|
+
() => runnablePersona(personas, 'nobody'),
|
|
135
|
+
/Unknown persona "nobody" — declare it with definePersonas\(\)/
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// An acted-upon persona has no session of its own, and running one races the
|
|
140
|
+
// scenario that acts on it.
|
|
141
|
+
test('refuses a persona that is declared as acted upon', () => {
|
|
142
|
+
assert.throws(
|
|
143
|
+
() => runnablePersona(personas, 'observed'),
|
|
144
|
+
/declared as acted upon, never run/
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('requireVirtualUserRunStore', () => {
|
|
150
|
+
test('says which store to wire rather than failing on undefined', () => {
|
|
151
|
+
assert.throws(
|
|
152
|
+
() => requireVirtualUserRunStore(undefined),
|
|
153
|
+
/KyselyVirtualUserRunStore/
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('a read says there are no runs, not that one cannot be recorded', () => {
|
|
158
|
+
assert.throws(
|
|
159
|
+
() => requireVirtualUserRunStore(undefined, true),
|
|
160
|
+
/there are no runs to read/
|
|
161
|
+
)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('startVirtualUserRun', () => {
|
|
166
|
+
test('records the run before the caller gets the id back', async () => {
|
|
167
|
+
const { store, started } = runStore()
|
|
168
|
+
const run = await startVirtualUserRun({
|
|
169
|
+
store,
|
|
170
|
+
personas,
|
|
171
|
+
config: undefined,
|
|
172
|
+
persona: 'susan',
|
|
173
|
+
startedBy: 'user-1',
|
|
174
|
+
})
|
|
175
|
+
assert.equal(run.runId, 'run-1')
|
|
176
|
+
assert.equal(started.length, 1)
|
|
177
|
+
assert.equal(started[0].persona, 'susan')
|
|
178
|
+
assert.equal(started[0].startedBy, 'user-1')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// The seed is what makes a finding reproducible; deciding it inside the
|
|
182
|
+
// engine would lose it whenever a run dies before returning.
|
|
183
|
+
test('seeds the run before the record is written', async () => {
|
|
184
|
+
const { store, started } = runStore()
|
|
185
|
+
const run = await startVirtualUserRun({
|
|
186
|
+
store,
|
|
187
|
+
personas,
|
|
188
|
+
config: undefined,
|
|
189
|
+
persona: 'susan',
|
|
190
|
+
})
|
|
191
|
+
assert.equal(typeof started[0].seed, 'number')
|
|
192
|
+
assert.equal(started[0].seed, run.seed)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('a seed that was asked for is the one recorded', async () => {
|
|
196
|
+
const { store, started } = runStore()
|
|
197
|
+
await startVirtualUserRun({
|
|
198
|
+
store,
|
|
199
|
+
personas,
|
|
200
|
+
config: undefined,
|
|
201
|
+
persona: 'susan',
|
|
202
|
+
seed: 42,
|
|
203
|
+
})
|
|
204
|
+
assert.equal(started[0].seed, 42)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('the disposition falls back to what the persona declares', async () => {
|
|
208
|
+
const { store, started } = runStore()
|
|
209
|
+
await startVirtualUserRun({
|
|
210
|
+
store,
|
|
211
|
+
personas,
|
|
212
|
+
config: undefined,
|
|
213
|
+
persona: 'susan',
|
|
214
|
+
})
|
|
215
|
+
assert.equal(started[0].disposition, 'realistic')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('refuses every disposition but the accountable one in production', async () => {
|
|
219
|
+
const { store, started } = runStore()
|
|
220
|
+
await assert.rejects(
|
|
221
|
+
startVirtualUserRun({
|
|
222
|
+
store,
|
|
223
|
+
personas,
|
|
224
|
+
config: { nodeEnv: 'production' },
|
|
225
|
+
persona: 'susan',
|
|
226
|
+
disposition: 'adversarial',
|
|
227
|
+
}),
|
|
228
|
+
/Only the 'accountable' disposition may run against production/
|
|
229
|
+
)
|
|
230
|
+
assert.equal(started.length, 0)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// Checked against the effective disposition, so an override cannot smuggle
|
|
234
|
+
// one past a persona that declares the safe one.
|
|
235
|
+
test('the accountable disposition is allowed through in production', async () => {
|
|
236
|
+
const { store, started } = runStore()
|
|
237
|
+
await startVirtualUserRun({
|
|
238
|
+
store,
|
|
239
|
+
personas,
|
|
240
|
+
config: { nodeEnv: 'production' },
|
|
241
|
+
persona: 'susan',
|
|
242
|
+
disposition: 'accountable',
|
|
243
|
+
})
|
|
244
|
+
assert.equal(started[0].disposition, 'accountable')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('refuses a persona that is declared as acted upon', async () => {
|
|
248
|
+
const { store, started } = runStore()
|
|
249
|
+
await assert.rejects(
|
|
250
|
+
startVirtualUserRun({
|
|
251
|
+
store,
|
|
252
|
+
personas,
|
|
253
|
+
config: undefined,
|
|
254
|
+
persona: 'observed',
|
|
255
|
+
}),
|
|
256
|
+
/declared as acted upon, never run/
|
|
257
|
+
)
|
|
258
|
+
assert.equal(started.length, 0)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// A run record outlives the run and is read back over RPC, so a credential
|
|
262
|
+
// stored on it is a credential anyone with read scope can collect. There is
|
|
263
|
+
// no parameter to pass one in with, which is the guarantee.
|
|
264
|
+
test('nothing credential-shaped reaches the record', async () => {
|
|
265
|
+
const { store, started } = runStore()
|
|
266
|
+
await startVirtualUserRun({
|
|
267
|
+
store,
|
|
268
|
+
personas,
|
|
269
|
+
config: undefined,
|
|
270
|
+
persona: 'susan',
|
|
271
|
+
})
|
|
272
|
+
assert.deepEqual(Object.keys(started[0]).sort(), [
|
|
273
|
+
'disposition',
|
|
274
|
+
'goals',
|
|
275
|
+
'memory',
|
|
276
|
+
'persona',
|
|
277
|
+
'seed',
|
|
278
|
+
'startedBy',
|
|
279
|
+
])
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
const record = (): VirtualUserRunRecord =>
|
|
284
|
+
({
|
|
285
|
+
runId: 'run-1',
|
|
286
|
+
persona: 'susan',
|
|
287
|
+
disposition: 'realistic',
|
|
288
|
+
seed: 7,
|
|
289
|
+
status: 'complete',
|
|
290
|
+
goals: [],
|
|
291
|
+
memory: {},
|
|
292
|
+
findings: [
|
|
293
|
+
{
|
|
294
|
+
kind: 'a-kind-invented-tomorrow',
|
|
295
|
+
detail: 'something',
|
|
296
|
+
rpcName: 'getThing',
|
|
297
|
+
status: 500,
|
|
298
|
+
intentId: 'i1',
|
|
299
|
+
step: 3,
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
intents: [
|
|
303
|
+
{
|
|
304
|
+
id: 'i1',
|
|
305
|
+
sourceId: 's1',
|
|
306
|
+
title: 'Buy a thing',
|
|
307
|
+
status: 'done',
|
|
308
|
+
steps: 3,
|
|
309
|
+
suspensions: 0,
|
|
310
|
+
summary: 'bought it',
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
tally: { steps: 3 },
|
|
314
|
+
stoppedBy: null,
|
|
315
|
+
error: null,
|
|
316
|
+
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
317
|
+
finishedAt: new Date('2026-01-01T00:05:00.000Z'),
|
|
318
|
+
}) as unknown as VirtualUserRunRecord
|
|
319
|
+
|
|
320
|
+
describe('serializeVirtualUserRun', () => {
|
|
321
|
+
test('dates cross as strings, since a wire has no Date', () => {
|
|
322
|
+
const wire = serializeVirtualUserRun(record())
|
|
323
|
+
assert.equal(wire.createdAt, '2026-01-01T00:00:00.000Z')
|
|
324
|
+
assert.equal(wire.finishedAt, '2026-01-01T00:05:00.000Z')
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
// A client built before a finding kind existed should still be able to read a
|
|
328
|
+
// run that turned one up.
|
|
329
|
+
test('leaves the finding kind open rather than enumerating today s kinds', () => {
|
|
330
|
+
const wire = serializeVirtualUserRun(record())
|
|
331
|
+
assert.equal(wire.findings[0]!.kind, 'a-kind-invented-tomorrow')
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
test('carries the seed out with the run, so a finding can be replayed', () => {
|
|
335
|
+
assert.equal(serializeVirtualUserRun(record()).seed, 7)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
test('a run still going has no finish time rather than a wrong one', () => {
|
|
339
|
+
const wire = serializeVirtualUserRun({
|
|
340
|
+
...record(),
|
|
341
|
+
finishedAt: null,
|
|
342
|
+
} as VirtualUserRunRecord)
|
|
343
|
+
assert.equal(wire.finishedAt, null)
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
describe('serializeVirtualUserSteps', () => {
|
|
348
|
+
test('a turn carries what it called, what came back, and what it cost', () => {
|
|
349
|
+
const steps = serializeVirtualUserSteps([
|
|
350
|
+
{
|
|
351
|
+
index: 0,
|
|
352
|
+
intentId: 'i1',
|
|
353
|
+
action: { kind: 'call', rpcName: 'getThing' },
|
|
354
|
+
status: 200,
|
|
355
|
+
ok: true,
|
|
356
|
+
response: '{}',
|
|
357
|
+
findingKinds: ['slow'],
|
|
358
|
+
tokensIn: 10,
|
|
359
|
+
tokensOut: 20,
|
|
360
|
+
},
|
|
361
|
+
] as any)
|
|
362
|
+
assert.equal(steps.length, 1)
|
|
363
|
+
assert.deepEqual(steps[0]!.action, { kind: 'call', rpcName: 'getThing' })
|
|
364
|
+
assert.equal(steps[0]!.tokensIn, 10)
|
|
365
|
+
assert.deepEqual(steps[0]!.findingKinds, ['slow'])
|
|
366
|
+
})
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
const schedule = (): VirtualUserScheduleRecord =>
|
|
370
|
+
({
|
|
371
|
+
persona: 'susan',
|
|
372
|
+
enabled: true,
|
|
373
|
+
disposition: 'adversarial',
|
|
374
|
+
goals: ['Break checkout'],
|
|
375
|
+
budget: { steps: 20, mutations: 5, duration: 600_000 },
|
|
376
|
+
minIntervalMs: 3_600_000,
|
|
377
|
+
maxIntervalMs: 7_200_000,
|
|
378
|
+
nextRunAt: new Date('2026-01-02T00:00:00.000Z'),
|
|
379
|
+
lastRunId: 'run-1',
|
|
380
|
+
lastRunAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
381
|
+
}) as VirtualUserScheduleRecord
|
|
382
|
+
|
|
383
|
+
describe('serializeVirtualUserSchedule', () => {
|
|
384
|
+
// A schedule is enabled once and then outlives the declaration it was written
|
|
385
|
+
// from, so both sides have to travel for anyone to see they have parted ways.
|
|
386
|
+
test('a schedule carries what the persona currently declares', () => {
|
|
387
|
+
const wire = serializeVirtualUserSchedule(schedule(), personas)
|
|
388
|
+
assert.equal(wire.disposition, 'adversarial')
|
|
389
|
+
assert.equal(wire.declared.disposition, 'realistic')
|
|
390
|
+
assert.deepEqual(wire.declared.goals, ['Get the weekly order in'])
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
// Which fields differ is a question about how to render two values, and the
|
|
394
|
+
// answer belongs where they are rendered.
|
|
395
|
+
test('the difference is left for the client to work out', () => {
|
|
396
|
+
const wire = serializeVirtualUserSchedule(schedule(), personas)
|
|
397
|
+
assert.equal('drifted' in wire, false)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
test('the budget crosses as durationMs, which is what every call here takes', () => {
|
|
401
|
+
const wire = serializeVirtualUserSchedule(schedule(), personas)
|
|
402
|
+
assert.equal(wire.budget!.durationMs, 600_000)
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
// The engine's own duration also accepts `'30m'`, which nothing on this side
|
|
406
|
+
// ever writes — so it is dropped rather than sent as a number it is not.
|
|
407
|
+
test('a duration that is not a number does not cross as one', () => {
|
|
408
|
+
const wire = serializeVirtualUserSchedule(
|
|
409
|
+
{
|
|
410
|
+
...schedule(),
|
|
411
|
+
budget: { steps: 1, mutations: 1, duration: '30m' },
|
|
412
|
+
} as unknown as VirtualUserScheduleRecord,
|
|
413
|
+
personas
|
|
414
|
+
)
|
|
415
|
+
assert.equal(wire.budget!.durationMs, undefined)
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
test('a persona nobody declares any more still renders', () => {
|
|
419
|
+
const wire = serializeVirtualUserSchedule(
|
|
420
|
+
{ ...schedule(), persona: 'gone' } as VirtualUserScheduleRecord,
|
|
421
|
+
personas
|
|
422
|
+
)
|
|
423
|
+
assert.equal(wire.declared.disposition, 'realistic')
|
|
424
|
+
assert.deepEqual(wire.declared.goals, [])
|
|
425
|
+
})
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
describe('writeVirtualUserSchedule', () => {
|
|
429
|
+
const scheduleStore = () => {
|
|
430
|
+
const writes: any[] = []
|
|
431
|
+
return {
|
|
432
|
+
writes,
|
|
433
|
+
store: {
|
|
434
|
+
set: async (input: any) => {
|
|
435
|
+
writes.push(input)
|
|
436
|
+
return { ...schedule(), ...input }
|
|
437
|
+
},
|
|
438
|
+
get: async () => null,
|
|
439
|
+
list: async () => [],
|
|
440
|
+
due: async () => [],
|
|
441
|
+
claim: async () => true,
|
|
442
|
+
remove: async () => {},
|
|
443
|
+
} as unknown as VirtualUserScheduleStore,
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
test('says which store to wire rather than failing on undefined', async () => {
|
|
448
|
+
await assert.rejects(
|
|
449
|
+
writeVirtualUserSchedule({
|
|
450
|
+
store: undefined,
|
|
451
|
+
personas,
|
|
452
|
+
persona: 'susan',
|
|
453
|
+
}),
|
|
454
|
+
/KyselyVirtualUserScheduleStore/
|
|
455
|
+
)
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
// The same rule a run enforces, applied at the point the row is written
|
|
459
|
+
// rather than every hour afterwards.
|
|
460
|
+
test('refuses a cadence for a persona that is only ever acted upon', async () => {
|
|
461
|
+
const { store, writes } = scheduleStore()
|
|
462
|
+
await assert.rejects(
|
|
463
|
+
writeVirtualUserSchedule({ store, personas, persona: 'observed' }),
|
|
464
|
+
/declared as acted upon, never run/
|
|
465
|
+
)
|
|
466
|
+
assert.equal(writes.length, 0)
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
test('durationMs is written as the budget duration the engine reads', async () => {
|
|
470
|
+
const { store, writes } = scheduleStore()
|
|
471
|
+
await writeVirtualUserSchedule({
|
|
472
|
+
store,
|
|
473
|
+
personas,
|
|
474
|
+
persona: 'susan',
|
|
475
|
+
budget: { steps: 3, durationMs: 1_000 },
|
|
476
|
+
})
|
|
477
|
+
assert.deepEqual(writes[0].budget, {
|
|
478
|
+
steps: 3,
|
|
479
|
+
mutations: undefined,
|
|
480
|
+
duration: 1_000,
|
|
481
|
+
})
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
// Clearing a budget and leaving it alone are different writes, and both have
|
|
485
|
+
// to survive the mapping.
|
|
486
|
+
test('an absent budget is left alone and a null one is cleared', async () => {
|
|
487
|
+
const { store, writes } = scheduleStore()
|
|
488
|
+
await writeVirtualUserSchedule({ store, personas, persona: 'susan' })
|
|
489
|
+
assert.equal(writes[0].budget, undefined)
|
|
490
|
+
await writeVirtualUserSchedule({
|
|
491
|
+
store,
|
|
492
|
+
personas,
|
|
493
|
+
persona: 'susan',
|
|
494
|
+
budget: null,
|
|
495
|
+
})
|
|
496
|
+
assert.equal(writes[1].budget, null)
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
test('a next run time crosses as a string and is stored as a date', async () => {
|
|
500
|
+
const { store, writes } = scheduleStore()
|
|
501
|
+
await writeVirtualUserSchedule({
|
|
502
|
+
store,
|
|
503
|
+
personas,
|
|
504
|
+
persona: 'susan',
|
|
505
|
+
nextRunAt: '2026-03-01T00:00:00.000Z',
|
|
506
|
+
})
|
|
507
|
+
assert.ok(writes[0].nextRunAt instanceof Date)
|
|
508
|
+
assert.equal(writes[0].nextRunAt.toISOString(), '2026-03-01T00:00:00.000Z')
|
|
509
|
+
})
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
describe('virtualUserScheduleRunInput', () => {
|
|
513
|
+
// The tick starts a run through the same gated entry point a person calls, so
|
|
514
|
+
// what it sends has to be the shape that entry point takes.
|
|
515
|
+
test('a due schedule becomes the input runVirtualUser accepts', () => {
|
|
516
|
+
const input = virtualUserScheduleRunInput(schedule())
|
|
517
|
+
assert.deepEqual(input, {
|
|
518
|
+
persona: 'susan',
|
|
519
|
+
disposition: 'adversarial',
|
|
520
|
+
goals: ['Break checkout'],
|
|
521
|
+
budget: { steps: 20, mutations: 5, durationMs: 600_000 },
|
|
522
|
+
})
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
test('no budget on the row means no budget on the run', () => {
|
|
526
|
+
const input = virtualUserScheduleRunInput({
|
|
527
|
+
...schedule(),
|
|
528
|
+
budget: null,
|
|
529
|
+
} as VirtualUserScheduleRecord)
|
|
530
|
+
assert.equal(input.budget, undefined)
|
|
531
|
+
})
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
describe('logVirtualUserTick', () => {
|
|
535
|
+
// The caller is a cron, so a run this started is otherwise the only trace
|
|
536
|
+
// that a persona is still out there working.
|
|
537
|
+
test('everything a tick did is written down, since nobody is watching', () => {
|
|
538
|
+
const { logger, lines } = silentLogger()
|
|
539
|
+
logVirtualUserTick(logger, {
|
|
540
|
+
dispatched: [{ persona: 'susan', runId: 'run-9' }],
|
|
541
|
+
reaped: ['run-8'],
|
|
542
|
+
skipped: [{ persona: 'observed', reason: 'not due' }],
|
|
543
|
+
} as any)
|
|
544
|
+
assert.equal(lines.length, 3)
|
|
545
|
+
assert.match(lines[0]!, /^info Virtual user susan started run run-9/)
|
|
546
|
+
assert.match(lines[1]!, /^warn Virtual user run run-8 was abandoned/)
|
|
547
|
+
assert.match(lines[2]!, /^info Virtual user observed skipped this tick/)
|
|
548
|
+
})
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
describe('executeVirtualUserRun', () => {
|
|
552
|
+
const params = (overrides: Record<string, unknown> = {}) => {
|
|
553
|
+
const { store, completed, failed } = runStore()
|
|
554
|
+
const { logger, lines } = silentLogger()
|
|
555
|
+
return {
|
|
556
|
+
completed,
|
|
557
|
+
failed,
|
|
558
|
+
lines,
|
|
559
|
+
params: {
|
|
560
|
+
runStore: store,
|
|
561
|
+
metaService: emptyMeta,
|
|
562
|
+
agentRunner: { run: async () => ({}) } as any,
|
|
563
|
+
variables: variables({}),
|
|
564
|
+
logger,
|
|
565
|
+
personas,
|
|
566
|
+
createPersonas: () => ({}) as any,
|
|
567
|
+
runId: 'run-1',
|
|
568
|
+
persona: 'susan',
|
|
569
|
+
disposition: 'realistic',
|
|
570
|
+
goals: [],
|
|
571
|
+
memory: {},
|
|
572
|
+
seed: 1,
|
|
573
|
+
...overrides,
|
|
574
|
+
} as any,
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// The record is the only place a crashed run and a run that found nothing are
|
|
579
|
+
// different states; leaving it at 'running' forever is what `fail` prevents.
|
|
580
|
+
test('a run that cannot start is failed on the record, not left running', async () => {
|
|
581
|
+
const { params: p, failed, lines } = params()
|
|
582
|
+
await assert.rejects(
|
|
583
|
+
executeVirtualUserRun(p),
|
|
584
|
+
/VIRTUAL_USER_API_URL is not set/
|
|
585
|
+
)
|
|
586
|
+
assert.equal(failed.length, 1)
|
|
587
|
+
assert.equal(failed[0].runId, 'run-1')
|
|
588
|
+
assert.match(failed[0].error, /VIRTUAL_USER_API_URL is not set/)
|
|
589
|
+
assert.match(lines[0]!, /^error Virtual user run run-1 \(susan\) failed/)
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
test('says which credential is missing rather than assuming the local one', async () => {
|
|
593
|
+
const { params: p } = params({
|
|
594
|
+
variables: variables({ VIRTUAL_USER_API_URL: 'http://localhost:4000' }),
|
|
595
|
+
})
|
|
596
|
+
await assert.rejects(
|
|
597
|
+
executeVirtualUserRun(p),
|
|
598
|
+
/Neither an operator token nor SCENARIO_ACTOR_SECRET is available/
|
|
599
|
+
)
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
test('a model to think with is not assumed either', async () => {
|
|
603
|
+
const { params: p } = params({
|
|
604
|
+
variables: variables({
|
|
605
|
+
VIRTUAL_USER_API_URL: 'http://localhost:4000',
|
|
606
|
+
SCENARIO_ACTOR_SECRET: 'shh',
|
|
607
|
+
}),
|
|
608
|
+
})
|
|
609
|
+
await assert.rejects(
|
|
610
|
+
executeVirtualUserRun(p),
|
|
611
|
+
/VIRTUAL_USER_MODEL is not set/
|
|
612
|
+
)
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
// Asymmetric, so the stage can verify one and can never mint one. The actor
|
|
616
|
+
// secret is symmetric and only `pikku dev` serves the endpoint that takes it.
|
|
617
|
+
test('an operator token wins over the actor secret wherever both exist', async () => {
|
|
618
|
+
let options: any
|
|
619
|
+
const { params: p } = params({
|
|
620
|
+
operatorToken: 'handed-in',
|
|
621
|
+
variables: variables({
|
|
622
|
+
VIRTUAL_USER_API_URL: 'http://localhost:4000',
|
|
623
|
+
SCENARIO_ACTOR_SECRET: 'shh',
|
|
624
|
+
VIRTUAL_USER_MODEL: 'a-model',
|
|
625
|
+
SCENARIO_SIGN_IN_PATH: '/api/auth/sign-in/actor',
|
|
626
|
+
}),
|
|
627
|
+
createPersonas: (given: any) => {
|
|
628
|
+
options = given
|
|
629
|
+
return {}
|
|
630
|
+
},
|
|
631
|
+
})
|
|
632
|
+
await assert.rejects(executeVirtualUserRun(p), /cannot sign in/)
|
|
633
|
+
assert.equal(options.secret, undefined)
|
|
634
|
+
assert.equal(options.operator.token, 'handed-in')
|
|
635
|
+
assert.equal(options.operator.signInPath, '/api/auth/sign-in/fabric')
|
|
636
|
+
// The actor door moves with the mount too, for the run that has no token.
|
|
637
|
+
assert.equal(options.signInPath, '/api/auth/sign-in/actor')
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
test('a run nobody handed a token to falls back to the environment', async () => {
|
|
641
|
+
let options: any
|
|
642
|
+
const { params: p } = params({
|
|
643
|
+
variables: variables({
|
|
644
|
+
VIRTUAL_USER_API_URL: 'http://localhost:4000',
|
|
645
|
+
FABRIC_OPERATOR_TOKEN: 'from-the-environment',
|
|
646
|
+
VIRTUAL_USER_MODEL: 'a-model',
|
|
647
|
+
}),
|
|
648
|
+
createPersonas: (given: any) => {
|
|
649
|
+
options = given
|
|
650
|
+
return {}
|
|
651
|
+
},
|
|
652
|
+
})
|
|
653
|
+
await assert.rejects(executeVirtualUserRun(p), /cannot sign in/)
|
|
654
|
+
assert.equal(options.operator.token, 'from-the-environment')
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
test('with no token at all the actor secret is what signs in', async () => {
|
|
658
|
+
let options: any
|
|
659
|
+
const { params: p } = params({
|
|
660
|
+
variables: variables({
|
|
661
|
+
VIRTUAL_USER_API_URL: 'http://localhost:4000',
|
|
662
|
+
SCENARIO_ACTOR_SECRET: 'shh',
|
|
663
|
+
VIRTUAL_USER_MODEL: 'a-model',
|
|
664
|
+
}),
|
|
665
|
+
createPersonas: (given: any) => {
|
|
666
|
+
options = given
|
|
667
|
+
return {}
|
|
668
|
+
},
|
|
669
|
+
})
|
|
670
|
+
await assert.rejects(executeVirtualUserRun(p), /cannot sign in/)
|
|
671
|
+
assert.equal(options.secret, 'shh')
|
|
672
|
+
assert.equal(options.operator, undefined)
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
test('a persona nobody declares any more is refused before signing in', async () => {
|
|
676
|
+
let called = false
|
|
677
|
+
const { params: p } = params({
|
|
678
|
+
persona: 'gone',
|
|
679
|
+
variables: variables({
|
|
680
|
+
VIRTUAL_USER_API_URL: 'http://localhost:4000',
|
|
681
|
+
SCENARIO_ACTOR_SECRET: 'shh',
|
|
682
|
+
VIRTUAL_USER_MODEL: 'a-model',
|
|
683
|
+
}),
|
|
684
|
+
createPersonas: () => {
|
|
685
|
+
called = true
|
|
686
|
+
return {}
|
|
687
|
+
},
|
|
688
|
+
})
|
|
689
|
+
await assert.rejects(
|
|
690
|
+
executeVirtualUserRun(p),
|
|
691
|
+
/Persona "gone" is no longer declared/
|
|
692
|
+
)
|
|
693
|
+
assert.equal(called, false)
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
// Reaching into pikku internals would make this a thing only the framework
|
|
697
|
+
// can do, which is the opposite of a scaffold.
|
|
698
|
+
test('derives everything through the public meta surface', async () => {
|
|
699
|
+
const asked: string[] = []
|
|
700
|
+
const { params: p } = params({
|
|
701
|
+
metaService: {
|
|
702
|
+
getFunctionsMeta: async () => {
|
|
703
|
+
asked.push('functions')
|
|
704
|
+
return {
|
|
705
|
+
getThing: { inputSchemaName: 'GetThingInput' },
|
|
706
|
+
other: { inputSchemaName: 'GetThingInput', outputSchemaName: null },
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
getSchemas: async (names: string[]) => {
|
|
710
|
+
asked.push(`schemas:${names.join(',')}`)
|
|
711
|
+
return {}
|
|
712
|
+
},
|
|
713
|
+
getWorkflowMeta: async () => {
|
|
714
|
+
asked.push('workflows')
|
|
715
|
+
return {}
|
|
716
|
+
},
|
|
717
|
+
getSystemRolesMeta: async () => {
|
|
718
|
+
asked.push('roles')
|
|
719
|
+
return {}
|
|
720
|
+
},
|
|
721
|
+
getAgentsMeta: async () => {
|
|
722
|
+
asked.push('agents')
|
|
723
|
+
return {}
|
|
724
|
+
},
|
|
725
|
+
},
|
|
726
|
+
variables: variables({
|
|
727
|
+
VIRTUAL_USER_API_URL: 'http://localhost:4000',
|
|
728
|
+
SCENARIO_ACTOR_SECRET: 'shh',
|
|
729
|
+
VIRTUAL_USER_MODEL: 'a-model',
|
|
730
|
+
}),
|
|
731
|
+
})
|
|
732
|
+
await assert.rejects(executeVirtualUserRun(p), /cannot sign in/)
|
|
733
|
+
assert.ok(asked.includes('functions'))
|
|
734
|
+
assert.ok(asked.includes('workflows'))
|
|
735
|
+
assert.ok(asked.includes('roles'))
|
|
736
|
+
assert.ok(asked.includes('agents'))
|
|
737
|
+
// Only the schemas the catalogue can refer to, asked for once each — a
|
|
738
|
+
// large app would otherwise pull every schema it has into one run.
|
|
739
|
+
assert.ok(asked.includes('schemas:GetThingInput'))
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
// A budget of no steps runs the engine without letting it take a turn, which
|
|
743
|
+
// is the cheapest way to prove the whole path down to the record.
|
|
744
|
+
test('the transcript a run produced is stored, not discarded', async () => {
|
|
745
|
+
const {
|
|
746
|
+
params: p,
|
|
747
|
+
completed,
|
|
748
|
+
failed,
|
|
749
|
+
} = params({
|
|
750
|
+
budget: { steps: 0 },
|
|
751
|
+
variables: variables({
|
|
752
|
+
VIRTUAL_USER_API_URL: 'http://localhost:4000',
|
|
753
|
+
SCENARIO_ACTOR_SECRET: 'shh',
|
|
754
|
+
VIRTUAL_USER_MODEL: 'a-model',
|
|
755
|
+
}),
|
|
756
|
+
createPersonas: () => ({
|
|
757
|
+
susan: {
|
|
758
|
+
name: 'susan',
|
|
759
|
+
email: 'susan@actors.local',
|
|
760
|
+
invokeRaw: async () => ({ status: 200, body: {} }),
|
|
761
|
+
converse: async () => ({
|
|
762
|
+
passed: true,
|
|
763
|
+
reasoning: '',
|
|
764
|
+
transcript: [],
|
|
765
|
+
}),
|
|
766
|
+
},
|
|
767
|
+
}),
|
|
768
|
+
})
|
|
769
|
+
const result = await executeVirtualUserRun(p)
|
|
770
|
+
assert.equal(result.findings, 0)
|
|
771
|
+
assert.equal(failed.length, 0)
|
|
772
|
+
assert.equal(completed.length, 1)
|
|
773
|
+
assert.equal(completed[0].runId, 'run-1')
|
|
774
|
+
const outcome = completed[0].outcome
|
|
775
|
+
assert.deepEqual(Object.keys(outcome).sort(), [
|
|
776
|
+
'findings',
|
|
777
|
+
'intents',
|
|
778
|
+
'memory',
|
|
779
|
+
'steps',
|
|
780
|
+
'stoppedBy',
|
|
781
|
+
'tally',
|
|
782
|
+
])
|
|
783
|
+
assert.equal(outcome.stoppedBy, 'budget-steps')
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
test('nothing to think with is a wiring problem, said as one', async () => {
|
|
787
|
+
const { params: p } = params({ agentRunner: undefined })
|
|
788
|
+
await assert.rejects(executeVirtualUserRun(p), /agentRunner is not wired/)
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
test('nothing to derive from is a wiring problem, said as one', async () => {
|
|
792
|
+
const { params: p } = params({ metaService: undefined })
|
|
793
|
+
await assert.rejects(executeVirtualUserRun(p), /metaService is not wired/)
|
|
794
|
+
})
|
|
795
|
+
})
|