@pikku/core 0.12.89 → 0.12.91
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 +181 -0
- package/dist/services/http-personas.d.ts +21 -7
- package/dist/services/http-personas.js +39 -28
- package/dist/services/persona-sign-in.d.ts +107 -0
- package/dist/services/persona-sign-in.js +179 -0
- package/dist/types/core.types.d.ts +7 -0
- package/dist/wirings/persona/index.d.ts +1 -0
- package/dist/wirings/persona/index.js +1 -0
- package/dist/wirings/rpc/rpc-runner.js +4 -5
- 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 +3 -2
- package/dist/wirings/workflow/scenario-cookie-jar.js +10 -1
- 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 +10 -0
- package/src/services/http-personas-converse.test.ts +16 -2
- package/src/services/http-personas.ts +60 -33
- package/src/services/persona-sign-in.test.ts +209 -0
- package/src/services/persona-sign-in.ts +284 -0
- package/src/types/core.types.ts +7 -0
- package/src/wirings/persona/index.ts +9 -0
- package/src/wirings/rpc/rpc-runner.test.ts +100 -0
- package/src/wirings/rpc/rpc-runner.ts +8 -5
- 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 +3 -2
- package/src/wirings/workflow/scenario-cookie-jar.ts +12 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { VirtualUserRunStore } from './virtual-user-run-store.js'
|
|
2
|
+
import type {
|
|
3
|
+
VirtualUserScheduleRecord,
|
|
4
|
+
VirtualUserScheduleStore,
|
|
5
|
+
} from './virtual-user-schedule-store.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How long a run may sit at `running` before it is read as dead rather than
|
|
9
|
+
* busy.
|
|
10
|
+
*
|
|
11
|
+
* A run holds no process across a restart — see {@link VirtualUserRunRecord} —
|
|
12
|
+
* so a deploy mid-run strands the record, and a stranded record would block its
|
|
13
|
+
* persona's schedule for good. Twice the longest duration budget anyone sets in
|
|
14
|
+
* practice, because the failure this guards against is cheap to recover from
|
|
15
|
+
* and expensive to trigger early: reaping a run that was still working loses
|
|
16
|
+
* its findings.
|
|
17
|
+
*/
|
|
18
|
+
export const STALE_RUN_AFTER_MS = 2 * 60 * 60 * 1000
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The cadence a schedule gets when it is written without one: roughly a run a
|
|
22
|
+
* day, at an hour nobody can predict.
|
|
23
|
+
*
|
|
24
|
+
* Sparse on purpose. Every tick spends model budget with no caller present to
|
|
25
|
+
* notice, so the default is the one an app can leave switched on and forget,
|
|
26
|
+
* not the one that finds the most.
|
|
27
|
+
*/
|
|
28
|
+
export const DEFAULT_MIN_INTERVAL_MS = 6 * 60 * 60 * 1000
|
|
29
|
+
export const DEFAULT_MAX_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
30
|
+
|
|
31
|
+
/** Whether a row is the tick's business, for stores that cannot ask in a query. */
|
|
32
|
+
export const isDue = (schedule: VirtualUserScheduleRecord, now: Date) =>
|
|
33
|
+
schedule.enabled && schedule.nextRunAt.getTime() <= now.getTime()
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* When this persona should next appear, drawn from its own interval.
|
|
37
|
+
*
|
|
38
|
+
* Uniform between the two bounds. Reversed bounds are read as a range rather
|
|
39
|
+
* than rejected, because a schedule is configuration and a swapped pair is a
|
|
40
|
+
* typo, not an attack.
|
|
41
|
+
*/
|
|
42
|
+
export const nextRunAt = (
|
|
43
|
+
schedule: Pick<VirtualUserScheduleRecord, 'minIntervalMs' | 'maxIntervalMs'>,
|
|
44
|
+
now: Date,
|
|
45
|
+
random: () => number
|
|
46
|
+
) => {
|
|
47
|
+
const low = Math.max(
|
|
48
|
+
0,
|
|
49
|
+
Math.min(schedule.minIntervalMs, schedule.maxIntervalMs)
|
|
50
|
+
)
|
|
51
|
+
const high = Math.max(
|
|
52
|
+
0,
|
|
53
|
+
Math.max(schedule.minIntervalMs, schedule.maxIntervalMs)
|
|
54
|
+
)
|
|
55
|
+
return new Date(now.getTime() + low + random() * (high - low))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type VirtualUserSkipReason =
|
|
59
|
+
'in-flight' | 'dispatch-failed' | 'claimed-elsewhere'
|
|
60
|
+
|
|
61
|
+
export interface VirtualUserTickResult {
|
|
62
|
+
dispatched: { persona: string; runId: string }[]
|
|
63
|
+
skipped: { persona: string; reason: VirtualUserSkipReason }[]
|
|
64
|
+
/** Runs found stranded at `running` and marked failed. */
|
|
65
|
+
reaped: string[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface VirtualUserTickParams {
|
|
69
|
+
schedules: VirtualUserScheduleStore
|
|
70
|
+
runs: VirtualUserRunStore
|
|
71
|
+
/** Starts one run and answers with its id. Nothing here knows how. */
|
|
72
|
+
dispatch: (schedule: VirtualUserScheduleRecord) => Promise<string>
|
|
73
|
+
now?: Date
|
|
74
|
+
random?: () => number
|
|
75
|
+
staleAfterMs?: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Acts on whichever personas are due, once.
|
|
80
|
+
*
|
|
81
|
+
* The whole cadence lives in this one call, so what schedules it is the host's
|
|
82
|
+
* choice — a cron wiring, a platform scheduler, or a person clicking a button.
|
|
83
|
+
* Pikku does not start a timer on an app's behalf; a scaffold that did would
|
|
84
|
+
* begin spending model budget the moment a project ran `pikku all`.
|
|
85
|
+
*
|
|
86
|
+
* A persona is skipped, not queued, while its previous run is still going. Two
|
|
87
|
+
* copies of the same user acting at once is not a heavier test, it is a
|
|
88
|
+
* different one, and every finding it produces is unreproducible. Two ticks
|
|
89
|
+
* running at once are held to the same rule by the claim, which only lands for
|
|
90
|
+
* whichever of them still sees the `nextRunAt` it read.
|
|
91
|
+
*/
|
|
92
|
+
export const tickVirtualUserSchedules = async ({
|
|
93
|
+
schedules,
|
|
94
|
+
runs,
|
|
95
|
+
dispatch,
|
|
96
|
+
now = new Date(),
|
|
97
|
+
random = Math.random,
|
|
98
|
+
staleAfterMs = STALE_RUN_AFTER_MS,
|
|
99
|
+
}: VirtualUserTickParams): Promise<VirtualUserTickResult> => {
|
|
100
|
+
const result: VirtualUserTickResult = {
|
|
101
|
+
dispatched: [],
|
|
102
|
+
skipped: [],
|
|
103
|
+
reaped: [],
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const schedule of await schedules.due(now)) {
|
|
107
|
+
const [latest] = await runs.list({ persona: schedule.persona, limit: 1 })
|
|
108
|
+
if (latest?.status === 'running') {
|
|
109
|
+
if (now.getTime() - latest.createdAt.getTime() < staleAfterMs) {
|
|
110
|
+
result.skipped.push({ persona: schedule.persona, reason: 'in-flight' })
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
await runs.fail(
|
|
114
|
+
latest.runId,
|
|
115
|
+
`Abandoned: still running ${Math.round((now.getTime() - latest.createdAt.getTime()) / 60000)}m after it started, which is longer than any budget allows.`
|
|
116
|
+
)
|
|
117
|
+
result.reaped.push(latest.runId)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const due = nextRunAt(schedule, now, random)
|
|
121
|
+
const acquired = await schedules.claim(schedule.persona, {
|
|
122
|
+
from: schedule.nextRunAt,
|
|
123
|
+
nextRunAt: due,
|
|
124
|
+
runId: null,
|
|
125
|
+
at: now,
|
|
126
|
+
})
|
|
127
|
+
if (!acquired) {
|
|
128
|
+
result.skipped.push({
|
|
129
|
+
persona: schedule.persona,
|
|
130
|
+
reason: 'claimed-elsewhere',
|
|
131
|
+
})
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let runId: string
|
|
136
|
+
try {
|
|
137
|
+
runId = await dispatch(schedule)
|
|
138
|
+
} catch {
|
|
139
|
+
result.skipped.push({
|
|
140
|
+
persona: schedule.persona,
|
|
141
|
+
reason: 'dispatch-failed',
|
|
142
|
+
})
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await schedules.claim(schedule.persona, {
|
|
147
|
+
from: due,
|
|
148
|
+
nextRunAt: due,
|
|
149
|
+
runId,
|
|
150
|
+
at: now,
|
|
151
|
+
})
|
|
152
|
+
result.dispatched.push({ persona: schedule.persona, runId })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return result
|
|
156
|
+
}
|
|
@@ -644,8 +644,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
644
644
|
* overriding this, or no concurrency for one to exclude: the relay makes
|
|
645
645
|
* duplicate dispatch routine, and the claim is what keeps a duplicate from
|
|
646
646
|
* becoming a second execution. Every `@pikku/kysely` dialect qualifies on its
|
|
647
|
-
* status-guarded claim, `
|
|
648
|
-
* `
|
|
647
|
+
* status-guarded claim, `mongodb` on the same claim expressed as a
|
|
648
|
+
* single-document update, `in-memory` on being inline and single-process —
|
|
649
|
+
* none of them overrides this yet.
|
|
649
650
|
*/
|
|
650
651
|
protected async findUndispatchedSteps(
|
|
651
652
|
_before: Date,
|
|
@@ -11,8 +11,19 @@ export const createCookieJar = (apiUrl: string): ScenarioCookieJar => {
|
|
|
11
11
|
fetch: async (input, init) => {
|
|
12
12
|
const headers = new Headers(init?.headers)
|
|
13
13
|
headers.set('origin', origin)
|
|
14
|
-
|
|
14
|
+
// The caller's own cookie header wins per name: a request that already
|
|
15
|
+
// carries a session is stating which one it means, and emitting the jar's
|
|
16
|
+
// copy alongside it sends the same name twice.
|
|
15
17
|
const caller = headers.get('cookie')
|
|
18
|
+
const named = new Set(
|
|
19
|
+
(caller ?? '')
|
|
20
|
+
.split(';')
|
|
21
|
+
.map((pair) => pair.split('=')[0]?.trim())
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
)
|
|
24
|
+
const held = [...jar]
|
|
25
|
+
.filter(([name]) => !named.has(name))
|
|
26
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
16
27
|
if (held.length > 0 || caller) {
|
|
17
28
|
headers.set('cookie', [caller, ...held].filter(Boolean).join('; '))
|
|
18
29
|
}
|