@pikku/core 0.12.88 → 0.12.90
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 +196 -0
- package/dist/services/http-personas.js +8 -0
- package/dist/types/core.types.d.ts +8 -1
- package/dist/wirings/rpc/rpc-runner.js +5 -6
- package/dist/wirings/virtual-user/index.d.ts +3 -1
- package/dist/wirings/virtual-user/index.js +1 -0
- package/dist/wirings/virtual-user/virtual-user-agents.d.ts +7 -2
- package/dist/wirings/virtual-user/virtual-user-agents.js +8 -2
- package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +32 -1
- package/dist/wirings/virtual-user/virtual-user-schedule-store.d.ts +89 -0
- package/dist/wirings/virtual-user/virtual-user-schedule-store.js +1 -0
- package/dist/wirings/virtual-user/virtual-user-schedule.d.ts +71 -0
- package/dist/wirings/virtual-user/virtual-user-schedule.js +101 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +3 -2
- package/dist/wirings/workflow/pikku-workflow-service.js +7 -4
- package/dist/wirings/workflow/workflow-constants.d.ts +17 -0
- package/dist/wirings/workflow/workflow-constants.js +17 -0
- package/dist/wirings/workflow/workflow-recovery.d.ts +18 -1
- package/dist/wirings/workflow/workflow-recovery.js +30 -2
- package/knowledge/decisions/internals/a-virtual-user-cadence-is-a-row-not-a-timer.md +66 -0
- package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +8 -3
- package/knowledge/decisions/internals/index.md +1 -0
- package/knowledge/decisions/internals/the-ecosystem-entry-point-carries-the-adapter-surface.md +7 -6
- package/package.json +1 -1
- package/src/app-leaf-surface.test.ts +2 -2
- package/src/ecosystem-tier-removed.test.ts +69 -0
- package/src/public-surface.json +6 -0
- package/src/services/http-personas-converse.test.ts +16 -2
- package/src/services/http-personas.ts +8 -0
- package/src/types/core.types.ts +8 -1
- package/src/wirings/rpc/rpc-runner.test.ts +106 -1
- package/src/wirings/rpc/rpc-runner.ts +9 -6
- package/src/wirings/virtual-user/index.ts +18 -0
- package/src/wirings/virtual-user/virtual-user-agents.test.ts +8 -4
- package/src/wirings/virtual-user/virtual-user-agents.ts +8 -3
- package/src/wirings/virtual-user/virtual-user-run-store.ts +33 -0
- package/src/wirings/virtual-user/virtual-user-schedule-store.ts +93 -0
- package/src/wirings/virtual-user/virtual-user-schedule.test.ts +280 -0
- package/src/wirings/virtual-user/virtual-user-schedule.ts +156 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +6 -2
- package/src/wirings/workflow/workflow-constants.ts +19 -0
- package/src/wirings/workflow/workflow-recovery.ts +31 -1
- package/src/wirings/workflow/workflow-stalled-recovery.test.ts +46 -0
- package/src/wirings/workflow/workflow-terminal-run-guard.test.ts +105 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -596,6 +596,106 @@ describe('ContextAwareRPCService.rpcWithWire', () => {
|
|
|
596
596
|
['missingRpc', { value: 2 }, { userId: 'user-2' }, 'trace-3'],
|
|
597
597
|
])
|
|
598
598
|
})
|
|
599
|
+
|
|
600
|
+
test('a missing namespaced rpc still reaches deploymentService through rpcWithWire', async () => {
|
|
601
|
+
const remoteCalls: unknown[][] = []
|
|
602
|
+
pikkuState(null, 'addons', 'packages').set('stripe', {
|
|
603
|
+
package: '@addon/stripe',
|
|
604
|
+
} as never)
|
|
605
|
+
|
|
606
|
+
const service = new ContextAwareRPCService(
|
|
607
|
+
createServices({
|
|
608
|
+
deploymentService: {
|
|
609
|
+
invoke: async (...args: unknown[]) => {
|
|
610
|
+
remoteCalls.push(args)
|
|
611
|
+
return { remote: true }
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
}),
|
|
615
|
+
{ traceId: 'trace-addon-wire' } as never,
|
|
616
|
+
{}
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
const result = await service.rpcWithWire(
|
|
620
|
+
'stripe:missingFunc',
|
|
621
|
+
{ value: 3 },
|
|
622
|
+
{ custom: 'wire' } as never
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
assert.deepEqual(result, { remote: true })
|
|
626
|
+
assert.deepEqual(remoteCalls, [
|
|
627
|
+
['stripe:missingFunc', { value: 3 }, undefined, 'trace-addon-wire'],
|
|
628
|
+
])
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
test('an unknown namespace still falls through to the local/remote lookup', async () => {
|
|
632
|
+
const remoteCalls: unknown[][] = []
|
|
633
|
+
const service = new ContextAwareRPCService(
|
|
634
|
+
createServices({
|
|
635
|
+
deploymentService: {
|
|
636
|
+
invoke: async (...args: unknown[]) => {
|
|
637
|
+
remoteCalls.push(args)
|
|
638
|
+
return { remote: true }
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
}),
|
|
642
|
+
{ traceId: 'trace-ns-wire' } as never,
|
|
643
|
+
{}
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
const result = await service.rpcWithWire(
|
|
647
|
+
'unknownNs:someFunc',
|
|
648
|
+
{ value: 1 },
|
|
649
|
+
{ custom: 'wire' } as never
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
assert.deepEqual(result, { remote: true })
|
|
653
|
+
assert.deepEqual(remoteCalls, [
|
|
654
|
+
['unknownNs:someFunc', { value: 1 }, undefined, 'trace-ns-wire'],
|
|
655
|
+
])
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
test('the deployment fallback runs under the wire the caller passed, not the ambient one', async () => {
|
|
659
|
+
const remoteCalls: unknown[][] = []
|
|
660
|
+
const service = new ContextAwareRPCService(
|
|
661
|
+
createServices({
|
|
662
|
+
deploymentService: {
|
|
663
|
+
invoke: async (...args: unknown[]) => {
|
|
664
|
+
remoteCalls.push(args)
|
|
665
|
+
return { remote: true }
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
}),
|
|
669
|
+
{
|
|
670
|
+
traceId: 'ambient-trace',
|
|
671
|
+
session: { userId: 'ambient-user' },
|
|
672
|
+
} as never,
|
|
673
|
+
{}
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
const result = await service.rpcWithWire(
|
|
677
|
+
'unknownNs:someFunc',
|
|
678
|
+
{ value: 1 },
|
|
679
|
+
{
|
|
680
|
+
traceId: 'caller-trace',
|
|
681
|
+
session: { userId: 'caller-user' },
|
|
682
|
+
} as never
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
assert.deepEqual(result, { remote: true })
|
|
686
|
+
assert.deepEqual(
|
|
687
|
+
remoteCalls,
|
|
688
|
+
[
|
|
689
|
+
[
|
|
690
|
+
'unknownNs:someFunc',
|
|
691
|
+
{ value: 1 },
|
|
692
|
+
{ userId: 'caller-user' },
|
|
693
|
+
'caller-trace',
|
|
694
|
+
],
|
|
695
|
+
],
|
|
696
|
+
'the remote hop ran under the ambient wire, so an explicit wire is honoured locally but dropped across the deployment boundary'
|
|
697
|
+
)
|
|
698
|
+
})
|
|
599
699
|
})
|
|
600
700
|
|
|
601
701
|
describe('ContextAwareRPCService.startWorkflow', () => {
|
|
@@ -900,7 +1000,12 @@ describe('wireRemoteAddon dispatch', () => {
|
|
|
900
1000
|
calls[0]!.init.headers.authorization,
|
|
901
1001
|
'Bearer secret-value-for-REGISTRY_TOKEN'
|
|
902
1002
|
)
|
|
903
|
-
assert.equal(
|
|
1003
|
+
assert.equal(
|
|
1004
|
+
calls[0]!.init.headers['x-request-id'],
|
|
1005
|
+
'trace-r',
|
|
1006
|
+
'a remote RPC must send the trace id under the header the receiving ' +
|
|
1007
|
+
'runner reads, or the trace chain breaks at the hop'
|
|
1008
|
+
)
|
|
904
1009
|
} finally {
|
|
905
1010
|
restoreFetch()
|
|
906
1011
|
}
|
|
@@ -356,7 +356,7 @@ export class ContextAwareRPCService {
|
|
|
356
356
|
headers.authorization = `Bearer ${token}`
|
|
357
357
|
}
|
|
358
358
|
if (this.wire.traceId) {
|
|
359
|
-
headers['x-
|
|
359
|
+
headers['x-request-id'] = this.wire.traceId
|
|
360
360
|
}
|
|
361
361
|
|
|
362
362
|
const base = serverUrl.replace(/\/+$/, '')
|
|
@@ -393,10 +393,13 @@ export class ContextAwareRPCService {
|
|
|
393
393
|
|
|
394
394
|
if (rpcName.includes(':')) {
|
|
395
395
|
const addonCall = this.resolveAddonFunction(rpcName)
|
|
396
|
-
if (addonCall
|
|
397
|
-
|
|
396
|
+
if (addonCall !== NOT_RESOLVED) {
|
|
397
|
+
return await this.executeAddonFunction<In, Out>(
|
|
398
|
+
addonCall,
|
|
399
|
+
data,
|
|
400
|
+
mergedWire
|
|
401
|
+
)
|
|
398
402
|
}
|
|
399
|
-
return this.executeAddonFunction<In, Out>(addonCall, data, mergedWire)
|
|
400
403
|
}
|
|
401
404
|
|
|
402
405
|
let resolved: { pikkuFuncId: string; packageName: string | null }
|
|
@@ -404,12 +407,12 @@ export class ContextAwareRPCService {
|
|
|
404
407
|
resolved = resolvePikkuFunction(rpcName, this.packageName)
|
|
405
408
|
} catch (e) {
|
|
406
409
|
if (e instanceof RPCNotFoundError && this.services.deploymentService) {
|
|
407
|
-
const session = await resolveWireSession(
|
|
410
|
+
const session = await resolveWireSession(mergedWire)
|
|
408
411
|
return this.services.deploymentService.invoke(
|
|
409
412
|
rpcName,
|
|
410
413
|
data,
|
|
411
414
|
session,
|
|
412
|
-
|
|
415
|
+
mergedWire.traceId
|
|
413
416
|
) as Promise<Out>
|
|
414
417
|
}
|
|
415
418
|
throw e
|
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
*/
|
|
19
19
|
export type {
|
|
20
20
|
ApiCatalogueEntry,
|
|
21
|
+
IntentRecord,
|
|
21
22
|
IntentSource,
|
|
23
|
+
StepRecord,
|
|
24
|
+
VirtualUserBudget,
|
|
22
25
|
VirtualUserDisposition,
|
|
23
26
|
VirtualUserFinding,
|
|
24
27
|
VirtualUserRunResult,
|
|
@@ -40,6 +43,21 @@ export type {
|
|
|
40
43
|
VirtualUserRunStart,
|
|
41
44
|
VirtualUserRunStore,
|
|
42
45
|
} from './virtual-user-run-store.js'
|
|
46
|
+
export type {
|
|
47
|
+
VirtualUserScheduleInput,
|
|
48
|
+
VirtualUserScheduleRecord,
|
|
49
|
+
VirtualUserScheduleStore,
|
|
50
|
+
} from './virtual-user-schedule-store.js'
|
|
51
|
+
export {
|
|
52
|
+
DEFAULT_MAX_INTERVAL_MS,
|
|
53
|
+
DEFAULT_MIN_INTERVAL_MS,
|
|
54
|
+
isDue,
|
|
55
|
+
nextRunAt,
|
|
56
|
+
STALE_RUN_AFTER_MS,
|
|
57
|
+
tickVirtualUserSchedules,
|
|
58
|
+
type VirtualUserTickParams,
|
|
59
|
+
type VirtualUserTickResult,
|
|
60
|
+
} from './virtual-user-schedule.js'
|
|
43
61
|
export {
|
|
44
62
|
DISPOSITIONS,
|
|
45
63
|
dispositionProfile,
|
|
@@ -4,16 +4,13 @@ import { reachableAgents } from './virtual-user-agents.js'
|
|
|
4
4
|
|
|
5
5
|
const AGENTS = {
|
|
6
6
|
'router-agent': {
|
|
7
|
-
name: 'router-agent',
|
|
8
7
|
description: 'Routes requests to the right domain agent',
|
|
9
8
|
},
|
|
10
9
|
'social-poster': {
|
|
11
|
-
name: 'social-poster',
|
|
12
10
|
description: 'Drafts and schedules posts',
|
|
13
11
|
scopes: ['content:write'],
|
|
14
12
|
},
|
|
15
13
|
'refund-agent': {
|
|
16
|
-
name: 'refund-agent',
|
|
17
14
|
scopes: ['billing:write'],
|
|
18
15
|
},
|
|
19
16
|
}
|
|
@@ -58,7 +55,14 @@ describe('reachableAgents', () => {
|
|
|
58
55
|
assert.deepEqual(refund, { name: 'refund-agent' })
|
|
59
56
|
})
|
|
60
57
|
|
|
61
|
-
test('
|
|
58
|
+
test('offers the registration key, not the display name the agent declares', () => {
|
|
59
|
+
assert.deepEqual(
|
|
60
|
+
reachableAgents({ adminAgent: { description: 'Runs the place' } }),
|
|
61
|
+
[{ name: 'adminAgent', description: 'Runs the place' }]
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('an agent carrying nothing but a key is still offered under it', () => {
|
|
62
66
|
assert.deepEqual(reachableAgents({ orphan: {} }), [{ name: 'orphan' }])
|
|
63
67
|
})
|
|
64
68
|
|
|
@@ -16,7 +16,6 @@ import { hasScopes } from '../../scopes.js'
|
|
|
16
16
|
|
|
17
17
|
/** The part of an agent's meta this needs. */
|
|
18
18
|
export interface AgentReachability {
|
|
19
|
-
name?: string
|
|
20
19
|
description?: string
|
|
21
20
|
scopes?: readonly string[]
|
|
22
21
|
auth?: boolean
|
|
@@ -29,7 +28,13 @@ export interface ReachableAgent {
|
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
/**
|
|
32
|
-
* The agents to offer,
|
|
31
|
+
* The agents to offer, named by the key they are registered under.
|
|
32
|
+
*
|
|
33
|
+
* That key is the export's own name, which is what `addAgent` stores and what
|
|
34
|
+
* `resolveAgent` looks up. The `name` an agent declares in its config is a
|
|
35
|
+
* display label and is frequently something else entirely — offering that one
|
|
36
|
+
* hands the persona a name the server cannot resolve, and the run dies on a
|
|
37
|
+
* 500 the moment it takes the offer.
|
|
33
38
|
*
|
|
34
39
|
* Like {@link reachableCatalogue}, this narrows *what is offered* and never
|
|
35
40
|
* what is enforced: the server decides who may talk to what, and an agent
|
|
@@ -52,6 +57,6 @@ export const reachableAgents = (
|
|
|
52
57
|
return hasScopes(agent.scopes, scopes)
|
|
53
58
|
})
|
|
54
59
|
.map(([id, agent]) => ({
|
|
55
|
-
name:
|
|
60
|
+
name: id,
|
|
56
61
|
...(agent.description ? { description: agent.description } : {}),
|
|
57
62
|
}))
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
IntentRecord,
|
|
3
|
+
StepRecord,
|
|
2
4
|
VirtualUserDisposition,
|
|
3
5
|
VirtualUserFinding,
|
|
4
6
|
VirtualUserTally,
|
|
@@ -39,6 +41,16 @@ export interface VirtualUserRunRecord {
|
|
|
39
41
|
*/
|
|
40
42
|
memory: Record<string, string>
|
|
41
43
|
findings: VirtualUserFinding[]
|
|
44
|
+
/**
|
|
45
|
+
* What the user set out to do and how far each one got, which is the spine a
|
|
46
|
+
* transcript hangs off — the steps alone are a list of calls with no account
|
|
47
|
+
* of what they were for.
|
|
48
|
+
*
|
|
49
|
+
* Small and bounded, so it rides on the run row rather than in a table of its
|
|
50
|
+
* own: a run has as many intents as the app has scenarios, and every read of
|
|
51
|
+
* the run wants them.
|
|
52
|
+
*/
|
|
53
|
+
intents: IntentRecord[]
|
|
42
54
|
tally: VirtualUserTally | null
|
|
43
55
|
/** Which budget or stopping rule ended the run. */
|
|
44
56
|
stoppedBy: string | null
|
|
@@ -69,6 +81,16 @@ export interface VirtualUserRunOutcome {
|
|
|
69
81
|
tally: VirtualUserTally
|
|
70
82
|
memory: Record<string, string>
|
|
71
83
|
stoppedBy: string | null
|
|
84
|
+
intents: readonly IntentRecord[]
|
|
85
|
+
/**
|
|
86
|
+
* Every turn the run took. Kept because a finding is an assertion until you
|
|
87
|
+
* can see what the user did before it, and because a run that found nothing
|
|
88
|
+
* is only readable as work through its steps.
|
|
89
|
+
*
|
|
90
|
+
* Stored apart from the run — see {@link VirtualUserRunStore.steps} — so
|
|
91
|
+
* listing runs does not drag a budget's worth of turns along with it.
|
|
92
|
+
*/
|
|
93
|
+
steps: readonly StepRecord[]
|
|
72
94
|
}
|
|
73
95
|
|
|
74
96
|
/**
|
|
@@ -95,4 +117,15 @@ export interface VirtualUserRunStore {
|
|
|
95
117
|
limit?: number
|
|
96
118
|
offset?: number
|
|
97
119
|
}): Promise<VirtualUserRunRecord[]>
|
|
120
|
+
/**
|
|
121
|
+
* One run's turns, in the order they happened.
|
|
122
|
+
*
|
|
123
|
+
* Its own call rather than a field on the record: a run at a 500-step budget
|
|
124
|
+
* carries more transcript than every other column put together, and `list`
|
|
125
|
+
* would pay for it on every row.
|
|
126
|
+
*/
|
|
127
|
+
steps(
|
|
128
|
+
runId: string,
|
|
129
|
+
options?: { limit?: number; offset?: number }
|
|
130
|
+
): Promise<StepRecord[]>
|
|
98
131
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
VirtualUserBudget,
|
|
3
|
+
VirtualUserDisposition,
|
|
4
|
+
} from './virtual-user.types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One persona's standing instruction to keep using the app.
|
|
8
|
+
*
|
|
9
|
+
* A virtual user that runs once tells you about one afternoon. What an app
|
|
10
|
+
* actually wants to know is what a persona hits over a fortnight, and that is
|
|
11
|
+
* a cadence, not a longer run — a budget already caps how far a single run
|
|
12
|
+
* goes, and raising it only buys a more tired user.
|
|
13
|
+
*
|
|
14
|
+
* The row is the schedule. There is deliberately no timer, interval or
|
|
15
|
+
* in-memory loop anywhere near it: a process that holds the next run in its own
|
|
16
|
+
* heap forgets it on the next deploy, and a persona silently stops. Something
|
|
17
|
+
* outside asks which rows are due; the answer survives restarts because it is
|
|
18
|
+
* written down.
|
|
19
|
+
*/
|
|
20
|
+
export interface VirtualUserScheduleRecord {
|
|
21
|
+
persona: string
|
|
22
|
+
/**
|
|
23
|
+
* Off by default. A schedule that ran the moment it was written would start
|
|
24
|
+
* spending an app's model budget as a side effect of a migration.
|
|
25
|
+
*/
|
|
26
|
+
enabled: boolean
|
|
27
|
+
disposition: VirtualUserDisposition
|
|
28
|
+
goals: string[]
|
|
29
|
+
budget: VirtualUserBudget | null
|
|
30
|
+
/**
|
|
31
|
+
* The gap to the next run is drawn between these, not fixed. A persona that
|
|
32
|
+
* appears at exactly 09:00 every day exercises one cache state and one cron
|
|
33
|
+
* neighbourhood; a real one does not keep an appointment.
|
|
34
|
+
*/
|
|
35
|
+
minIntervalMs: number
|
|
36
|
+
maxIntervalMs: number
|
|
37
|
+
/** When this persona is next allowed to run. The whole schedule, in one field. */
|
|
38
|
+
nextRunAt: Date
|
|
39
|
+
lastRunId: string | null
|
|
40
|
+
lastRunAt: Date | null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A partial write — anything left out keeps whatever the row already had. */
|
|
44
|
+
export interface VirtualUserScheduleInput {
|
|
45
|
+
persona: string
|
|
46
|
+
enabled?: boolean
|
|
47
|
+
disposition?: VirtualUserDisposition
|
|
48
|
+
goals?: readonly string[]
|
|
49
|
+
budget?: VirtualUserBudget | null
|
|
50
|
+
minIntervalMs?: number
|
|
51
|
+
maxIntervalMs?: number
|
|
52
|
+
nextRunAt?: Date
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Where cadences are kept, alongside {@link VirtualUserRunStore} and separate
|
|
57
|
+
* from it: a host can want the history of runs it started by hand without
|
|
58
|
+
* wanting unattended ones, and wiring nothing is how it says so.
|
|
59
|
+
*
|
|
60
|
+
* SECURITY: writing a row here spends money on every future tick, without a
|
|
61
|
+
* caller present to see it happen. The scaffold gates writes behind a scope of
|
|
62
|
+
* their own for that reason — reading what the virtual users found is a much
|
|
63
|
+
* smaller permission than deciding they should keep going.
|
|
64
|
+
*/
|
|
65
|
+
export interface VirtualUserScheduleStore {
|
|
66
|
+
/** Creates or updates one persona's cadence. Returns the row as it now stands. */
|
|
67
|
+
set(schedule: VirtualUserScheduleInput): Promise<VirtualUserScheduleRecord>
|
|
68
|
+
get(persona: string): Promise<VirtualUserScheduleRecord | null>
|
|
69
|
+
list(): Promise<VirtualUserScheduleRecord[]>
|
|
70
|
+
/** Enabled rows whose `nextRunAt` has passed. */
|
|
71
|
+
due(now: Date): Promise<VirtualUserScheduleRecord[]>
|
|
72
|
+
/**
|
|
73
|
+
* Pushes the persona's next run out, and records which run this was.
|
|
74
|
+
*
|
|
75
|
+
* Called *before* the run is dispatched, so a tick that dies halfway does not
|
|
76
|
+
* leave a row due and get re-dispatched by the next one. The cost is that a
|
|
77
|
+
* dispatch which throws waits a full interval rather than retrying, which is
|
|
78
|
+
* the right way round: a persona that is failing to start should not be
|
|
79
|
+
* retried every minute for a week.
|
|
80
|
+
*
|
|
81
|
+
* `from` is the `nextRunAt` the caller read, and the write must match it to
|
|
82
|
+
* land — the claim is how a tick wins the persona, not just how it records
|
|
83
|
+
* winning. Two processes on the same cron read the same due row, and without
|
|
84
|
+
* the compare-and-set both would dispatch: the same user acting twice over,
|
|
85
|
+
* at twice the budget, producing findings neither run can reproduce. The
|
|
86
|
+
* loser is told `false` and leaves the persona to whoever got there first.
|
|
87
|
+
*/
|
|
88
|
+
claim(
|
|
89
|
+
persona: string,
|
|
90
|
+
claim: { from: Date; nextRunAt: Date; runId: string | null; at: Date }
|
|
91
|
+
): Promise<boolean>
|
|
92
|
+
remove(persona: string): Promise<void>
|
|
93
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
VirtualUserRunRecord,
|
|
6
|
+
VirtualUserRunStore,
|
|
7
|
+
} from './virtual-user-run-store.js'
|
|
8
|
+
import type {
|
|
9
|
+
VirtualUserScheduleRecord,
|
|
10
|
+
VirtualUserScheduleStore,
|
|
11
|
+
} from './virtual-user-schedule-store.js'
|
|
12
|
+
import {
|
|
13
|
+
isDue,
|
|
14
|
+
nextRunAt,
|
|
15
|
+
STALE_RUN_AFTER_MS,
|
|
16
|
+
tickVirtualUserSchedules,
|
|
17
|
+
} from './virtual-user-schedule.js'
|
|
18
|
+
|
|
19
|
+
const HOUR = 60 * 60 * 1000
|
|
20
|
+
const NOW = new Date('2026-01-01T12:00:00.000Z')
|
|
21
|
+
|
|
22
|
+
const schedule = (
|
|
23
|
+
over: Partial<VirtualUserScheduleRecord> = {}
|
|
24
|
+
): VirtualUserScheduleRecord => ({
|
|
25
|
+
persona: 'guest',
|
|
26
|
+
enabled: true,
|
|
27
|
+
disposition: 'realistic',
|
|
28
|
+
goals: [],
|
|
29
|
+
budget: null,
|
|
30
|
+
minIntervalMs: HOUR,
|
|
31
|
+
maxIntervalMs: 3 * HOUR,
|
|
32
|
+
nextRunAt: new Date(NOW.getTime() - 1000),
|
|
33
|
+
lastRunId: null,
|
|
34
|
+
lastRunAt: null,
|
|
35
|
+
...over,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const run = (
|
|
39
|
+
over: Partial<VirtualUserRunRecord> = {}
|
|
40
|
+
): VirtualUserRunRecord => ({
|
|
41
|
+
runId: 'run-1',
|
|
42
|
+
persona: 'guest',
|
|
43
|
+
disposition: 'realistic',
|
|
44
|
+
seed: 1,
|
|
45
|
+
status: 'completed',
|
|
46
|
+
goals: [],
|
|
47
|
+
memory: {},
|
|
48
|
+
findings: [],
|
|
49
|
+
intents: [],
|
|
50
|
+
tally: null,
|
|
51
|
+
stoppedBy: null,
|
|
52
|
+
error: null,
|
|
53
|
+
startedBy: null,
|
|
54
|
+
createdAt: NOW,
|
|
55
|
+
finishedAt: null,
|
|
56
|
+
...over,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const stores = (
|
|
60
|
+
schedules: VirtualUserScheduleRecord[],
|
|
61
|
+
runs: VirtualUserRunRecord[]
|
|
62
|
+
) => {
|
|
63
|
+
const claims: {
|
|
64
|
+
persona: string
|
|
65
|
+
from: Date
|
|
66
|
+
nextRunAt: Date
|
|
67
|
+
runId: string | null
|
|
68
|
+
}[] = []
|
|
69
|
+
const failed: { runId: string; error: string }[] = []
|
|
70
|
+
const scheduleStore = {
|
|
71
|
+
// Copies, as every real store hands back: a caller holding the row object
|
|
72
|
+
// itself would never see the value it read go stale underneath it.
|
|
73
|
+
due: async (now: Date) =>
|
|
74
|
+
schedules.filter((s) => isDue(s, now)).map((s) => ({ ...s })),
|
|
75
|
+
// The row is the lock, so the fake enforces the same compare-and-set the
|
|
76
|
+
// stores do: a claim lands only while the row still holds what was read.
|
|
77
|
+
claim: async (persona, claim) => {
|
|
78
|
+
const row = schedules.find((s) => s.persona === persona)
|
|
79
|
+
if (!row || row.nextRunAt.getTime() !== claim.from.getTime()) {
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
row.nextRunAt = claim.nextRunAt
|
|
83
|
+
if (claim.runId) {
|
|
84
|
+
row.lastRunId = claim.runId
|
|
85
|
+
row.lastRunAt = claim.at
|
|
86
|
+
}
|
|
87
|
+
claims.push({ persona, ...claim })
|
|
88
|
+
return true
|
|
89
|
+
},
|
|
90
|
+
} as unknown as VirtualUserScheduleStore
|
|
91
|
+
const runStore = {
|
|
92
|
+
list: async ({ persona } = {}) =>
|
|
93
|
+
runs.filter((r) => !persona || r.persona === persona),
|
|
94
|
+
fail: async (runId: string, error: string) => {
|
|
95
|
+
failed.push({ runId, error })
|
|
96
|
+
},
|
|
97
|
+
} as unknown as VirtualUserRunStore
|
|
98
|
+
return { scheduleStore, runStore, claims, failed }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe('virtual user schedule', () => {
|
|
102
|
+
test('a disabled persona is never due, however overdue it looks', () => {
|
|
103
|
+
const row = schedule({
|
|
104
|
+
enabled: false,
|
|
105
|
+
nextRunAt: new Date(NOW.getTime() - 1000 * HOUR),
|
|
106
|
+
})
|
|
107
|
+
assert.equal(isDue(row, NOW), false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('the next run lands somewhere inside the persona interval, not on the hour', () => {
|
|
111
|
+
const row = schedule({ minIntervalMs: HOUR, maxIntervalMs: 5 * HOUR })
|
|
112
|
+
const draws = [0, 0.25, 0.5, 0.99].map(
|
|
113
|
+
(value) => nextRunAt(row, NOW, () => value).getTime() - NOW.getTime()
|
|
114
|
+
)
|
|
115
|
+
for (const gap of draws) {
|
|
116
|
+
assert.ok(gap >= HOUR && gap <= 5 * HOUR, `outside the interval: ${gap}`)
|
|
117
|
+
}
|
|
118
|
+
assert.equal(new Set(draws).size, draws.length)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('a fixed cadence is expressed by asking for the same bound twice', () => {
|
|
122
|
+
const row = schedule({ minIntervalMs: HOUR, maxIntervalMs: HOUR })
|
|
123
|
+
assert.equal(nextRunAt(row, NOW, () => 0.7).getTime() - NOW.getTime(), HOUR)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('bounds the wrong way round read as a range rather than a negative gap', () => {
|
|
127
|
+
const row = schedule({ minIntervalMs: 5 * HOUR, maxIntervalMs: HOUR })
|
|
128
|
+
const gap = nextRunAt(row, NOW, () => 0).getTime() - NOW.getTime()
|
|
129
|
+
assert.equal(gap, HOUR)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('a due persona is dispatched and pushed out before the run starts', async () => {
|
|
133
|
+
const { scheduleStore, runStore, claims } = stores([schedule()], [])
|
|
134
|
+
const order: string[] = []
|
|
135
|
+
const result = await tickVirtualUserSchedules({
|
|
136
|
+
schedules: {
|
|
137
|
+
...scheduleStore,
|
|
138
|
+
claim: async (persona, claim) => {
|
|
139
|
+
order.push('claim')
|
|
140
|
+
return await scheduleStore.claim(persona, claim)
|
|
141
|
+
},
|
|
142
|
+
} as VirtualUserScheduleStore,
|
|
143
|
+
runs: runStore,
|
|
144
|
+
now: NOW,
|
|
145
|
+
random: () => 0.5,
|
|
146
|
+
dispatch: async () => {
|
|
147
|
+
order.push('dispatch')
|
|
148
|
+
return 'run-new'
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
assert.deepEqual(result.dispatched, [
|
|
153
|
+
{ persona: 'guest', runId: 'run-new' },
|
|
154
|
+
])
|
|
155
|
+
assert.deepEqual(order, ['claim', 'dispatch', 'claim'])
|
|
156
|
+
assert.ok(claims[0]!.nextRunAt.getTime() > NOW.getTime())
|
|
157
|
+
assert.equal(claims.at(-1)!.runId, 'run-new')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('a persona already acting is left alone rather than doubled up', async () => {
|
|
161
|
+
const { scheduleStore, runStore, claims } = stores(
|
|
162
|
+
[schedule()],
|
|
163
|
+
[run({ status: 'running', createdAt: new Date(NOW.getTime() - 60_000) })]
|
|
164
|
+
)
|
|
165
|
+
let dispatched = 0
|
|
166
|
+
const result = await tickVirtualUserSchedules({
|
|
167
|
+
schedules: scheduleStore,
|
|
168
|
+
runs: runStore,
|
|
169
|
+
now: NOW,
|
|
170
|
+
dispatch: async () => {
|
|
171
|
+
dispatched++
|
|
172
|
+
return 'run-new'
|
|
173
|
+
},
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
assert.equal(dispatched, 0)
|
|
177
|
+
assert.deepEqual(result.skipped, [
|
|
178
|
+
{ persona: 'guest', reason: 'in-flight' },
|
|
179
|
+
])
|
|
180
|
+
assert.deepEqual(claims, [])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('a run stranded by a restart is failed, not waited on forever', async () => {
|
|
184
|
+
const { scheduleStore, runStore, failed } = stores(
|
|
185
|
+
[schedule()],
|
|
186
|
+
[
|
|
187
|
+
run({
|
|
188
|
+
status: 'running',
|
|
189
|
+
createdAt: new Date(NOW.getTime() - STALE_RUN_AFTER_MS - 1),
|
|
190
|
+
}),
|
|
191
|
+
]
|
|
192
|
+
)
|
|
193
|
+
const result = await tickVirtualUserSchedules({
|
|
194
|
+
schedules: scheduleStore,
|
|
195
|
+
runs: runStore,
|
|
196
|
+
now: NOW,
|
|
197
|
+
dispatch: async () => 'run-new',
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
assert.deepEqual(result.reaped, ['run-1'])
|
|
201
|
+
assert.equal(failed.length, 1)
|
|
202
|
+
assert.match(failed[0]!.error, /still running/)
|
|
203
|
+
assert.deepEqual(result.dispatched, [
|
|
204
|
+
{ persona: 'guest', runId: 'run-new' },
|
|
205
|
+
])
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('a persona that will not start waits its interval instead of spinning', async () => {
|
|
209
|
+
const { scheduleStore, runStore, claims } = stores([schedule()], [])
|
|
210
|
+
const result = await tickVirtualUserSchedules({
|
|
211
|
+
schedules: scheduleStore,
|
|
212
|
+
runs: runStore,
|
|
213
|
+
now: NOW,
|
|
214
|
+
dispatch: async () => {
|
|
215
|
+
throw new Error('no target')
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
assert.deepEqual(result.dispatched, [])
|
|
220
|
+
assert.deepEqual(result.skipped, [
|
|
221
|
+
{ persona: 'guest', reason: 'dispatch-failed' },
|
|
222
|
+
])
|
|
223
|
+
assert.equal(claims.length, 1)
|
|
224
|
+
assert.ok(claims[0]!.nextRunAt.getTime() >= NOW.getTime() + HOUR)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('two ticks racing over one persona produce a single run', async () => {
|
|
228
|
+
const row = schedule()
|
|
229
|
+
const { scheduleStore, runStore } = stores([row], [])
|
|
230
|
+
const dispatched: string[] = []
|
|
231
|
+
const tick = () =>
|
|
232
|
+
tickVirtualUserSchedules({
|
|
233
|
+
schedules: scheduleStore,
|
|
234
|
+
runs: runStore,
|
|
235
|
+
now: NOW,
|
|
236
|
+
random: () => 0.5,
|
|
237
|
+
dispatch: async ({ persona }) => {
|
|
238
|
+
const runId = `run-${dispatched.length + 1}`
|
|
239
|
+
dispatched.push(persona)
|
|
240
|
+
return runId
|
|
241
|
+
},
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
// Both read the row while it is still due — the state two cron processes
|
|
245
|
+
// are in the moment before either of them writes.
|
|
246
|
+
const [first, second] = await Promise.all([tick(), tick()])
|
|
247
|
+
|
|
248
|
+
assert.equal(dispatched.length, 1)
|
|
249
|
+
const [won, lost] =
|
|
250
|
+
first.dispatched.length > 0 ? [first, second] : [second, first]
|
|
251
|
+
assert.equal(won.dispatched.length, 1)
|
|
252
|
+
assert.deepEqual(lost.dispatched, [])
|
|
253
|
+
assert.deepEqual(lost.skipped, [
|
|
254
|
+
{ persona: 'guest', reason: 'claimed-elsewhere' },
|
|
255
|
+
])
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('one persona failing does not stop the others from running', async () => {
|
|
259
|
+
const { scheduleStore, runStore } = stores(
|
|
260
|
+
[schedule({ persona: 'guest' }), schedule({ persona: 'admin' })],
|
|
261
|
+
[]
|
|
262
|
+
)
|
|
263
|
+
const result = await tickVirtualUserSchedules({
|
|
264
|
+
schedules: scheduleStore,
|
|
265
|
+
runs: runStore,
|
|
266
|
+
now: NOW,
|
|
267
|
+
dispatch: async ({ persona }) => {
|
|
268
|
+
if (persona === 'guest') throw new Error('no target')
|
|
269
|
+
return 'run-admin'
|
|
270
|
+
},
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
assert.deepEqual(result.dispatched, [
|
|
274
|
+
{ persona: 'admin', runId: 'run-admin' },
|
|
275
|
+
])
|
|
276
|
+
assert.deepEqual(result.skipped, [
|
|
277
|
+
{ persona: 'guest', reason: 'dispatch-failed' },
|
|
278
|
+
])
|
|
279
|
+
})
|
|
280
|
+
})
|