@pikku/kysely 0.13.19 → 0.13.21
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 +174 -0
- package/dist/src/index.d.ts +3 -2
- package/dist/src/index.js +2 -1
- package/dist/src/kysely-agent-run-state-service.js +2 -2
- package/dist/src/kysely-agent-storage-service.js +2 -2
- package/dist/src/kysely-audit-service.js +2 -2
- package/dist/src/kysely-channel-store.js +2 -2
- package/dist/src/kysely-credential-service.js +2 -2
- package/dist/src/kysely-deployment-service.js +2 -2
- package/dist/src/kysely-scope-service.js +2 -2
- package/dist/src/kysely-secret-service.js +2 -2
- package/dist/src/kysely-session-store.js +2 -2
- package/dist/src/kysely-tables.d.ts +74 -0
- package/dist/src/kysely-virtual-user-run-store.d.ts +7 -19
- package/dist/src/kysely-virtual-user-run-store.js +71 -4
- package/dist/src/kysely-virtual-user-schedule-store.d.ts +33 -0
- package/dist/src/kysely-virtual-user-schedule-store.js +151 -0
- package/dist/src/kysely-webhook-service.js +2 -2
- package/dist/src/kysely-workflow-mirror.d.ts +1 -1
- package/dist/src/kysely-workflow-mirror.js +3 -3
- package/dist/src/kysely-workflow-service.js +2 -2
- package/dist/src/schema/agent.schema.js +1 -0
- package/dist/src/schema/audit.schema.d.ts +5 -4
- package/dist/src/schema/audit.schema.js +6 -4
- package/dist/src/schema/channel.schema.js +1 -0
- package/dist/src/schema/credential.schema.js +1 -0
- package/dist/src/schema/index.d.ts +48 -26
- package/dist/src/schema/index.js +59 -46
- package/dist/src/schema/pikku-schema.types.d.ts +20 -0
- package/dist/src/schema/scope.schema.js +1 -0
- package/dist/src/schema/virtual-user-schedule.schema.d.ts +15 -0
- package/dist/src/schema/virtual-user-schedule.schema.js +43 -0
- package/dist/src/schema/virtual-user.schema.d.ts +2 -1
- package/dist/src/schema/virtual-user.schema.js +41 -2
- package/dist/src/schema/webhook.schema.js +1 -0
- package/dist/src/schema/workflow.schema.js +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/index.ts +15 -2
- package/src/kysely-agent-run-state-service.ts +2 -2
- package/src/kysely-agent-storage-service.ts +2 -2
- package/src/kysely-audit-service.test.ts +2 -10
- package/src/kysely-audit-service.ts +2 -2
- package/src/kysely-channel-store.ts +2 -2
- package/src/kysely-credential-service.ts +2 -2
- package/src/kysely-deployment-service.ts +2 -2
- package/src/kysely-scope-service.test.ts +2 -0
- package/src/kysely-scope-service.ts +2 -2
- package/src/kysely-secret-service.ts +2 -2
- package/src/kysely-services.test.ts +26 -0
- package/src/kysely-session-store.ts +2 -2
- package/src/kysely-tables.test.ts +1 -12
- package/src/kysely-tables.ts +77 -0
- package/src/kysely-virtual-user-run-store.test.ts +164 -7
- package/src/kysely-virtual-user-run-store.ts +92 -6
- package/src/kysely-virtual-user-schedule-store.test.ts +187 -0
- package/src/kysely-virtual-user-schedule-store.ts +187 -0
- package/src/kysely-webhook-service.test.ts +4 -0
- package/src/kysely-webhook-service.ts +2 -2
- package/src/kysely-workflow-mirror.test.ts +2 -0
- package/src/kysely-workflow-mirror.ts +3 -3
- package/src/kysely-workflow-service.test.ts +3 -1
- package/src/kysely-workflow-service.ts +2 -2
- package/src/schema/agent.schema.ts +1 -0
- package/src/schema/audit.schema.ts +6 -4
- package/src/schema/channel.schema.ts +1 -0
- package/src/schema/credential.schema.ts +1 -0
- package/src/schema/index.ts +70 -52
- package/src/schema/pikku-schema.types.ts +20 -0
- package/src/schema/schema.test.ts +86 -29
- package/src/schema/scope.schema.ts +1 -0
- package/src/schema/virtual-user-schedule.schema.ts +48 -0
- package/src/schema/virtual-user.schema.ts +43 -2
- package/src/schema/webhook.schema.ts +1 -0
- package/src/schema/workflow.schema.ts +1 -0
|
@@ -6,6 +6,7 @@ import { CamelCasePlugin, Kysely, SqliteDialect } from 'kysely'
|
|
|
6
6
|
import { KyselyVirtualUserRunStore } from './kysely-virtual-user-run-store.js'
|
|
7
7
|
import { SerializePlugin } from './serialize-plugin.js'
|
|
8
8
|
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
9
|
+
import { applyPikkuSchemas, virtualUserSchema } from './schema/index.js'
|
|
9
10
|
|
|
10
11
|
// SerializePlugin is here because most projects in this package run with it, and
|
|
11
12
|
// it is the harder case: it deserialises JSON columns the store has already
|
|
@@ -16,6 +17,12 @@ const newDb = () =>
|
|
|
16
17
|
plugins: [new CamelCasePlugin(), new SerializePlugin()],
|
|
17
18
|
})
|
|
18
19
|
|
|
20
|
+
const migratedDb = async () => {
|
|
21
|
+
const db = newDb()
|
|
22
|
+
await applyPikkuSchemas(db, [virtualUserSchema])
|
|
23
|
+
return db
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
const TALLY = {
|
|
20
27
|
steps: 12,
|
|
21
28
|
calls: 30,
|
|
@@ -32,19 +39,49 @@ const FINDING = {
|
|
|
32
39
|
step: 7,
|
|
33
40
|
} as any
|
|
34
41
|
|
|
42
|
+
const INTENT = {
|
|
43
|
+
id: 'intent_1',
|
|
44
|
+
sourceId: 'exportADoc',
|
|
45
|
+
title: 'Export a doc',
|
|
46
|
+
status: 'done',
|
|
47
|
+
steps: [0, 1],
|
|
48
|
+
suspensions: 0,
|
|
49
|
+
} as any
|
|
50
|
+
|
|
51
|
+
const step = (index: number, over: Record<string, unknown> = {}) =>
|
|
52
|
+
({
|
|
53
|
+
index,
|
|
54
|
+
intentId: 'intent_1',
|
|
55
|
+
action: { kind: 'call', rpcName: 'listDocs', input: {} },
|
|
56
|
+
status: 200,
|
|
57
|
+
ok: true,
|
|
58
|
+
response: '{"docs":[]}',
|
|
59
|
+
tokensIn: 40,
|
|
60
|
+
tokensOut: 12,
|
|
61
|
+
...over,
|
|
62
|
+
}) as any
|
|
63
|
+
|
|
35
64
|
describe('KyselyVirtualUserRunStore', () => {
|
|
36
65
|
let db: Kysely<KyselyPikkuDB>
|
|
37
66
|
let store: KyselyVirtualUserRunStore
|
|
38
67
|
|
|
39
|
-
beforeEach(() => {
|
|
40
|
-
db =
|
|
68
|
+
beforeEach(async () => {
|
|
69
|
+
db = await migratedDb()
|
|
41
70
|
store = new KyselyVirtualUserRunStore(db)
|
|
42
71
|
})
|
|
43
72
|
|
|
44
|
-
// The runtime never
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
test('
|
|
73
|
+
// The runtime never creates this table, so a project that skipped the
|
|
74
|
+
// migration has to be told which command writes it rather than quietly
|
|
75
|
+
// getting one at first use.
|
|
76
|
+
test('refuses to start against a database that never migrated it', async () => {
|
|
77
|
+
const store = new KyselyVirtualUserRunStore(newDb())
|
|
78
|
+
await assert.rejects(
|
|
79
|
+
store.start({ persona: 'susan', disposition: 'realistic', seed: 42 }),
|
|
80
|
+
/pikku db generate/
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('writes against a migrated table', async () => {
|
|
48
85
|
const runId = await store.start({
|
|
49
86
|
persona: 'susan',
|
|
50
87
|
disposition: 'realistic',
|
|
@@ -99,6 +136,8 @@ describe('KyselyVirtualUserRunStore', () => {
|
|
|
99
136
|
tally: TALLY,
|
|
100
137
|
memory: { docId: 'doc_1', orgId: 'org_9' },
|
|
101
138
|
stoppedBy: 'budget-steps',
|
|
139
|
+
intents: [INTENT],
|
|
140
|
+
steps: [step(0), step(1)],
|
|
102
141
|
})
|
|
103
142
|
|
|
104
143
|
const run = await store.get(runId)
|
|
@@ -167,14 +206,17 @@ describe('KyselyVirtualUserRunStore', () => {
|
|
|
167
206
|
assert.equal(runs[0]?.persona, 'susan')
|
|
168
207
|
})
|
|
169
208
|
|
|
170
|
-
test('completing an unknown run does not throw', async () => {
|
|
209
|
+
test('completing an unknown run does not throw, and leaves no steps behind', async () => {
|
|
171
210
|
await store.init()
|
|
172
211
|
await store.complete('nope', {
|
|
173
212
|
findings: [],
|
|
174
213
|
tally: TALLY,
|
|
175
214
|
memory: {},
|
|
176
215
|
stoppedBy: 'exhausted',
|
|
216
|
+
intents: [INTENT],
|
|
217
|
+
steps: [step(0)],
|
|
177
218
|
})
|
|
219
|
+
assert.deepEqual(await store.steps('nope'), [])
|
|
178
220
|
})
|
|
179
221
|
|
|
180
222
|
// Not every project installs SerializePlugin, and a bare SQLite driver cannot
|
|
@@ -184,6 +226,7 @@ describe('KyselyVirtualUserRunStore', () => {
|
|
|
184
226
|
dialect: new SqliteDialect({ database: new Database(':memory:') }),
|
|
185
227
|
plugins: [new CamelCasePlugin()],
|
|
186
228
|
})
|
|
229
|
+
await applyPikkuSchemas(bare, [virtualUserSchema])
|
|
187
230
|
const bareStore = new KyselyVirtualUserRunStore(bare)
|
|
188
231
|
|
|
189
232
|
const runId = await bareStore.start({
|
|
@@ -198,6 +241,8 @@ describe('KyselyVirtualUserRunStore', () => {
|
|
|
198
241
|
tally: TALLY,
|
|
199
242
|
memory: { docId: 'doc_1' },
|
|
200
243
|
stoppedBy: 'exhausted',
|
|
244
|
+
intents: [INTENT],
|
|
245
|
+
steps: [step(0)],
|
|
201
246
|
})
|
|
202
247
|
|
|
203
248
|
const run = await bareStore.get(runId)
|
|
@@ -208,5 +253,117 @@ describe('KyselyVirtualUserRunStore', () => {
|
|
|
208
253
|
assert.equal(run?.findings[0]?.detail, FINDING.detail)
|
|
209
254
|
assert.ok(run?.createdAt instanceof Date)
|
|
210
255
|
assert.ok(run?.finishedAt instanceof Date)
|
|
256
|
+
assert.deepEqual(await bareStore.steps(runId), [step(0)])
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('the transcript comes back in the order it happened', async () => {
|
|
260
|
+
const runId = await store.start({
|
|
261
|
+
persona: 'susan',
|
|
262
|
+
disposition: 'realistic',
|
|
263
|
+
seed: 1,
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
await store.complete(runId, {
|
|
267
|
+
findings: [],
|
|
268
|
+
tally: TALLY,
|
|
269
|
+
memory: {},
|
|
270
|
+
stoppedBy: 'exhausted',
|
|
271
|
+
intents: [INTENT],
|
|
272
|
+
steps: [step(2), step(0), step(1)],
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
const steps = await store.steps(runId)
|
|
276
|
+
assert.deepEqual(
|
|
277
|
+
steps.map((one) => one.index),
|
|
278
|
+
[0, 1, 2]
|
|
279
|
+
)
|
|
280
|
+
assert.deepEqual(steps[0], step(0))
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
// The turn the model got wrong is the one worth reading, and it carries none
|
|
284
|
+
// of the fields a successful call does.
|
|
285
|
+
test('a turn with no response and no status survives the round trip', async () => {
|
|
286
|
+
const runId = await store.start({
|
|
287
|
+
persona: 'susan',
|
|
288
|
+
disposition: 'careless',
|
|
289
|
+
seed: 1,
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
const invalid = step(0, {
|
|
293
|
+
intentId: undefined,
|
|
294
|
+
action: { kind: 'invalid', detail: 'no such rpc: listDoc' },
|
|
295
|
+
status: undefined,
|
|
296
|
+
ok: undefined,
|
|
297
|
+
response: undefined,
|
|
298
|
+
findingKinds: ['invalid-action'],
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
await store.complete(runId, {
|
|
302
|
+
findings: [],
|
|
303
|
+
tally: TALLY,
|
|
304
|
+
memory: {},
|
|
305
|
+
stoppedBy: 'exhausted',
|
|
306
|
+
intents: [],
|
|
307
|
+
steps: [invalid],
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
const [stored] = await store.steps(runId)
|
|
311
|
+
assert.deepEqual(stored, {
|
|
312
|
+
index: 0,
|
|
313
|
+
action: { kind: 'invalid', detail: 'no such rpc: listDoc' },
|
|
314
|
+
findingKinds: ['invalid-action'],
|
|
315
|
+
tokensIn: 40,
|
|
316
|
+
tokensOut: 12,
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
// Ten columns times a 500-step budget is five thousand bound variables, and a
|
|
321
|
+
// bare sqlite driver takes 999 — so the long run is exactly the one that must
|
|
322
|
+
// not be lost.
|
|
323
|
+
test('a run longer than one insert can hold is stored whole', async () => {
|
|
324
|
+
const runId = await store.start({
|
|
325
|
+
persona: 'susan',
|
|
326
|
+
disposition: 'adversarial',
|
|
327
|
+
seed: 1,
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
const steps = Array.from({ length: 240 }, (_, index) => step(index))
|
|
331
|
+
await store.complete(runId, {
|
|
332
|
+
findings: [],
|
|
333
|
+
tally: TALLY,
|
|
334
|
+
memory: {},
|
|
335
|
+
stoppedBy: 'budget-steps',
|
|
336
|
+
intents: [],
|
|
337
|
+
steps,
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
assert.equal((await store.steps(runId, { limit: 500 })).length, 240)
|
|
341
|
+
assert.deepEqual(
|
|
342
|
+
(await store.steps(runId, { limit: 2, offset: 100 })).map(
|
|
343
|
+
(one) => one.index
|
|
344
|
+
),
|
|
345
|
+
[100, 101]
|
|
346
|
+
)
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
test('intents ride on the run, not on the transcript', async () => {
|
|
350
|
+
const runId = await store.start({
|
|
351
|
+
persona: 'susan',
|
|
352
|
+
disposition: 'realistic',
|
|
353
|
+
seed: 1,
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
await store.complete(runId, {
|
|
357
|
+
findings: [],
|
|
358
|
+
tally: TALLY,
|
|
359
|
+
memory: {},
|
|
360
|
+
stoppedBy: 'exhausted',
|
|
361
|
+
intents: [INTENT],
|
|
362
|
+
steps: [],
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
const [listed] = await store.list({ persona: 'susan' })
|
|
366
|
+
assert.deepEqual(listed?.intents, [INTENT])
|
|
367
|
+
assert.deepEqual(await store.steps(runId), [])
|
|
211
368
|
})
|
|
212
369
|
})
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import type { Kysely, Selectable } from 'kysely'
|
|
2
2
|
import { randomUUID } from 'node:crypto'
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
StepRecord,
|
|
5
|
+
VirtualUserDisposition,
|
|
6
|
+
} from '@pikku/core/virtual-user'
|
|
4
7
|
import type {
|
|
5
8
|
VirtualUserRunOutcome,
|
|
6
9
|
VirtualUserRunRecord,
|
|
7
10
|
VirtualUserRunStart,
|
|
8
11
|
VirtualUserRunStore,
|
|
9
12
|
} from '@pikku/core/virtual-user'
|
|
10
|
-
import type {
|
|
13
|
+
import type {
|
|
14
|
+
KyselyPikkuDB,
|
|
15
|
+
VirtualUserRunStepTable,
|
|
16
|
+
VirtualUserRunTable,
|
|
17
|
+
} from './kysely-tables.js'
|
|
11
18
|
import { parseJson } from './kysely-json.js'
|
|
12
|
-
import {
|
|
19
|
+
import { requirePikkuSchema } from './schema/index.js'
|
|
13
20
|
import { virtualUserSchema } from './schema/virtual-user.schema.js'
|
|
14
21
|
|
|
15
22
|
/**
|
|
@@ -31,6 +38,16 @@ import { virtualUserSchema } from './schema/virtual-user.schema.js'
|
|
|
31
38
|
* ids. This is a privileged store; the scaffolded reads are scope-gated, and
|
|
32
39
|
* exposing these rows more widely publishes your own exploits.
|
|
33
40
|
*/
|
|
41
|
+
/**
|
|
42
|
+
* How many step rows go in one insert.
|
|
43
|
+
*
|
|
44
|
+
* Not a performance dial. A bare SQLite driver binds at most 999 variables per
|
|
45
|
+
* statement, and ten columns times a 500-step budget is five thousand — so an
|
|
46
|
+
* un-chunked insert does not fail on a long run, it fails on the *interesting*
|
|
47
|
+
* long run, which is the one nobody wants to lose.
|
|
48
|
+
*/
|
|
49
|
+
const STEP_INSERT_CHUNK = 50
|
|
50
|
+
|
|
34
51
|
export class KyselyVirtualUserRunStore implements VirtualUserRunStore {
|
|
35
52
|
private initialized = false
|
|
36
53
|
|
|
@@ -43,7 +60,7 @@ export class KyselyVirtualUserRunStore implements VirtualUserRunStore {
|
|
|
43
60
|
*/
|
|
44
61
|
public async init(): Promise<void> {
|
|
45
62
|
if (this.initialized) return
|
|
46
|
-
await
|
|
63
|
+
await requirePikkuSchema(this.db, virtualUserSchema)
|
|
47
64
|
this.initialized = true
|
|
48
65
|
}
|
|
49
66
|
|
|
@@ -71,7 +88,7 @@ export class KyselyVirtualUserRunStore implements VirtualUserRunStore {
|
|
|
71
88
|
|
|
72
89
|
async complete(runId: string, outcome: VirtualUserRunOutcome): Promise<void> {
|
|
73
90
|
await this.init()
|
|
74
|
-
await this.db
|
|
91
|
+
const updated = await this.db
|
|
75
92
|
.updateTable('virtualUserRun')
|
|
76
93
|
.set({
|
|
77
94
|
status: 'completed',
|
|
@@ -80,11 +97,16 @@ export class KyselyVirtualUserRunStore implements VirtualUserRunStore {
|
|
|
80
97
|
// Overwritten rather than merged: the engine's memory already carries
|
|
81
98
|
// what it was given, plus what it learned on the way.
|
|
82
99
|
memory: JSON.stringify(outcome.memory),
|
|
100
|
+
intents: JSON.stringify(outcome.intents),
|
|
83
101
|
stoppedBy: outcome.stoppedBy,
|
|
84
102
|
finishedAt: new Date().toISOString(),
|
|
85
103
|
})
|
|
86
104
|
.where('runId', '=', runId)
|
|
87
|
-
.
|
|
105
|
+
.executeTakeFirst()
|
|
106
|
+
// Nothing owns steps written against a run that is not there, and nothing
|
|
107
|
+
// would ever read or reap them — there is no foreign key to refuse them.
|
|
108
|
+
if (updated.numUpdatedRows === 0n) return
|
|
109
|
+
await this.writeSteps(runId, outcome.steps)
|
|
88
110
|
}
|
|
89
111
|
|
|
90
112
|
async fail(runId: string, error: string): Promise<void> {
|
|
@@ -128,6 +150,69 @@ export class KyselyVirtualUserRunStore implements VirtualUserRunStore {
|
|
|
128
150
|
return rows.map((row) => this.toRecord(row))
|
|
129
151
|
}
|
|
130
152
|
|
|
153
|
+
async steps(
|
|
154
|
+
runId: string,
|
|
155
|
+
options?: { limit?: number; offset?: number }
|
|
156
|
+
): Promise<StepRecord[]> {
|
|
157
|
+
await this.init()
|
|
158
|
+
const rows = await this.db
|
|
159
|
+
.selectFrom('virtualUserRunStep')
|
|
160
|
+
.selectAll()
|
|
161
|
+
.where('runId', '=', runId)
|
|
162
|
+
.orderBy('stepIndex', 'asc')
|
|
163
|
+
.limit(options?.limit ?? 1000)
|
|
164
|
+
.offset(options?.offset ?? 0)
|
|
165
|
+
.execute()
|
|
166
|
+
return rows.map((row) => this.toStep(row))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async writeSteps(
|
|
170
|
+
runId: string,
|
|
171
|
+
steps: readonly StepRecord[]
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
for (let at = 0; at < steps.length; at += STEP_INSERT_CHUNK) {
|
|
174
|
+
const chunk = steps.slice(at, at + STEP_INSERT_CHUNK)
|
|
175
|
+
await this.db
|
|
176
|
+
.insertInto('virtualUserRunStep')
|
|
177
|
+
.values(
|
|
178
|
+
chunk.map((step) => ({
|
|
179
|
+
runId,
|
|
180
|
+
stepIndex: step.index,
|
|
181
|
+
intentId: step.intentId ?? null,
|
|
182
|
+
action: JSON.stringify(step.action),
|
|
183
|
+
status: step.status ?? null,
|
|
184
|
+
ok: step.ok === undefined ? null : step.ok ? 1 : 0,
|
|
185
|
+
response:
|
|
186
|
+
step.response === undefined
|
|
187
|
+
? null
|
|
188
|
+
: JSON.stringify(step.response),
|
|
189
|
+
findingKinds: step.findingKinds
|
|
190
|
+
? JSON.stringify(step.findingKinds)
|
|
191
|
+
: null,
|
|
192
|
+
tokensIn: step.tokensIn,
|
|
193
|
+
tokensOut: step.tokensOut,
|
|
194
|
+
}))
|
|
195
|
+
)
|
|
196
|
+
.execute()
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private toStep(row: Selectable<VirtualUserRunStepTable>): StepRecord {
|
|
201
|
+
return {
|
|
202
|
+
index: Number(row.stepIndex),
|
|
203
|
+
...(row.intentId ? { intentId: row.intentId } : {}),
|
|
204
|
+
action: parseJson(row.action),
|
|
205
|
+
...(row.status === null ? {} : { status: Number(row.status) }),
|
|
206
|
+
...(row.ok === null ? {} : { ok: Boolean(row.ok) }),
|
|
207
|
+
...(row.response === null ? {} : { response: parseJson(row.response) }),
|
|
208
|
+
...(row.findingKinds
|
|
209
|
+
? { findingKinds: parseJson(row.findingKinds) }
|
|
210
|
+
: {}),
|
|
211
|
+
tokensIn: Number(row.tokensIn),
|
|
212
|
+
tokensOut: Number(row.tokensOut),
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
131
216
|
private toRecord(row: Selectable<VirtualUserRunTable>): VirtualUserRunRecord {
|
|
132
217
|
return {
|
|
133
218
|
runId: row.runId,
|
|
@@ -138,6 +223,7 @@ export class KyselyVirtualUserRunStore implements VirtualUserRunStore {
|
|
|
138
223
|
goals: parseJson(row.goals) ?? [],
|
|
139
224
|
memory: parseJson(row.memory) ?? {},
|
|
140
225
|
findings: parseJson(row.findings) ?? [],
|
|
226
|
+
intents: parseJson(row.intents) ?? [],
|
|
141
227
|
tally: row.tally ? (parseJson(row.tally) ?? null) : null,
|
|
142
228
|
stoppedBy: row.stoppedBy,
|
|
143
229
|
error: row.error,
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, test, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import Database from 'better-sqlite3'
|
|
4
|
+
import { CamelCasePlugin, Kysely, SqliteDialect } from 'kysely'
|
|
5
|
+
|
|
6
|
+
import { KyselyVirtualUserScheduleStore } from './kysely-virtual-user-schedule-store.js'
|
|
7
|
+
import { SerializePlugin } from './serialize-plugin.js'
|
|
8
|
+
import { applyPikkuSchemas, virtualUserScheduleSchema } from './schema/index.js'
|
|
9
|
+
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
10
|
+
|
|
11
|
+
// With the plugin, because most projects in this package run with it and it is
|
|
12
|
+
// the harder case: it deserialises columns the store has already stringified.
|
|
13
|
+
const withPlugin = () =>
|
|
14
|
+
new Kysely<KyselyPikkuDB>({
|
|
15
|
+
dialect: new SqliteDialect({ database: new Database(':memory:') }),
|
|
16
|
+
plugins: [new CamelCasePlugin(), new SerializePlugin()],
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const bare = () =>
|
|
20
|
+
new Kysely<KyselyPikkuDB>({
|
|
21
|
+
dialect: new SqliteDialect({ database: new Database(':memory:') }),
|
|
22
|
+
plugins: [new CamelCasePlugin()],
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const HOUR = 60 * 60 * 1000
|
|
26
|
+
|
|
27
|
+
describe('KyselyVirtualUserScheduleStore', () => {
|
|
28
|
+
let db: Kysely<KyselyPikkuDB>
|
|
29
|
+
let store: KyselyVirtualUserScheduleStore
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
db = withPlugin()
|
|
33
|
+
await applyPikkuSchemas(db, [virtualUserScheduleSchema])
|
|
34
|
+
store = new KyselyVirtualUserScheduleStore(db)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('a persona given a cadence is not running until someone says so', async () => {
|
|
38
|
+
const written = await store.set({ persona: 'guest' })
|
|
39
|
+
assert.equal(written.enabled, false)
|
|
40
|
+
assert.deepEqual(await store.due(new Date(Date.now() + 1000)), [])
|
|
41
|
+
assert.equal((await store.get('guest'))?.enabled, false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('a schedule written without an interval gets one, sparse by default', async () => {
|
|
45
|
+
const written = await store.set({ persona: 'guest' })
|
|
46
|
+
assert.ok(written.minIntervalMs >= HOUR)
|
|
47
|
+
assert.ok(written.maxIntervalMs >= written.minIntervalMs)
|
|
48
|
+
const stored = await store.get('guest')
|
|
49
|
+
assert.equal(stored?.minIntervalMs, written.minIntervalMs)
|
|
50
|
+
assert.equal(stored?.maxIntervalMs, written.maxIntervalMs)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('setting one field leaves the rest of the schedule alone', async () => {
|
|
54
|
+
await store.set({
|
|
55
|
+
persona: 'guest',
|
|
56
|
+
enabled: true,
|
|
57
|
+
disposition: 'adversarial',
|
|
58
|
+
goals: ['book a room'],
|
|
59
|
+
budget: { steps: 40 },
|
|
60
|
+
minIntervalMs: HOUR,
|
|
61
|
+
maxIntervalMs: 2 * HOUR,
|
|
62
|
+
})
|
|
63
|
+
await store.set({ persona: 'guest', enabled: false })
|
|
64
|
+
|
|
65
|
+
const stored = await store.get('guest')
|
|
66
|
+
assert.equal(stored?.enabled, false)
|
|
67
|
+
assert.equal(stored?.disposition, 'adversarial')
|
|
68
|
+
assert.deepEqual(stored?.goals, ['book a room'])
|
|
69
|
+
assert.deepEqual(stored?.budget, { steps: 40 })
|
|
70
|
+
assert.equal(stored?.minIntervalMs, HOUR)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('only enabled personas whose time has come come back as due', async () => {
|
|
74
|
+
const now = new Date()
|
|
75
|
+
await store.set({
|
|
76
|
+
persona: 'due',
|
|
77
|
+
enabled: true,
|
|
78
|
+
nextRunAt: new Date(now.getTime() - HOUR),
|
|
79
|
+
})
|
|
80
|
+
await store.set({
|
|
81
|
+
persona: 'later',
|
|
82
|
+
enabled: true,
|
|
83
|
+
nextRunAt: new Date(now.getTime() + HOUR),
|
|
84
|
+
})
|
|
85
|
+
await store.set({
|
|
86
|
+
persona: 'off',
|
|
87
|
+
enabled: false,
|
|
88
|
+
nextRunAt: new Date(now.getTime() - HOUR),
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
assert.deepEqual(
|
|
92
|
+
(await store.due(now)).map((row) => row.persona),
|
|
93
|
+
['due']
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('claiming pushes the persona out of the due list', async () => {
|
|
98
|
+
const now = new Date()
|
|
99
|
+
const due = new Date(now.getTime() - HOUR)
|
|
100
|
+
await store.set({ persona: 'guest', enabled: true, nextRunAt: due })
|
|
101
|
+
const acquired = await store.claim('guest', {
|
|
102
|
+
from: due,
|
|
103
|
+
nextRunAt: new Date(now.getTime() + HOUR),
|
|
104
|
+
runId: 'run-1',
|
|
105
|
+
at: now,
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
assert.equal(acquired, true)
|
|
109
|
+
|
|
110
|
+
assert.deepEqual(await store.due(now), [])
|
|
111
|
+
const stored = await store.get('guest')
|
|
112
|
+
assert.equal(stored?.lastRunId, 'run-1')
|
|
113
|
+
assert.equal(stored?.lastRunAt?.getTime(), now.getTime())
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('the claim written before a run starts does not blank the last run', async () => {
|
|
117
|
+
const now = new Date()
|
|
118
|
+
const written = await store.set({ persona: 'guest', enabled: true })
|
|
119
|
+
await store.claim('guest', {
|
|
120
|
+
from: written.nextRunAt,
|
|
121
|
+
nextRunAt: now,
|
|
122
|
+
runId: 'run-1',
|
|
123
|
+
at: now,
|
|
124
|
+
})
|
|
125
|
+
await store.claim('guest', {
|
|
126
|
+
from: now,
|
|
127
|
+
nextRunAt: new Date(now.getTime() + HOUR),
|
|
128
|
+
runId: null,
|
|
129
|
+
at: now,
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
assert.equal((await store.get('guest'))?.lastRunId, 'run-1')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('only one of two ticks holding the same due row can claim it', async () => {
|
|
136
|
+
const now = new Date()
|
|
137
|
+
const due = new Date(now.getTime() - HOUR)
|
|
138
|
+
await store.set({ persona: 'guest', enabled: true, nextRunAt: due })
|
|
139
|
+
|
|
140
|
+
const [first] = await store.due(now)
|
|
141
|
+
const [second] = await store.due(now)
|
|
142
|
+
assert.equal(first?.nextRunAt.getTime(), second?.nextRunAt.getTime())
|
|
143
|
+
|
|
144
|
+
const won = await store.claim('guest', {
|
|
145
|
+
from: first!.nextRunAt,
|
|
146
|
+
nextRunAt: new Date(now.getTime() + HOUR),
|
|
147
|
+
runId: 'run-1',
|
|
148
|
+
at: now,
|
|
149
|
+
})
|
|
150
|
+
const lost = await store.claim('guest', {
|
|
151
|
+
from: second!.nextRunAt,
|
|
152
|
+
nextRunAt: new Date(now.getTime() + 2 * HOUR),
|
|
153
|
+
runId: 'run-2',
|
|
154
|
+
at: now,
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
assert.deepEqual([won, lost], [true, false])
|
|
158
|
+
assert.equal((await store.get('guest'))?.lastRunId, 'run-1')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('a cadence survives a store with no serialize plugin', async () => {
|
|
162
|
+
const plainDb = bare()
|
|
163
|
+
await applyPikkuSchemas(plainDb, [virtualUserScheduleSchema])
|
|
164
|
+
const plain = new KyselyVirtualUserScheduleStore(plainDb)
|
|
165
|
+
await plain.set({
|
|
166
|
+
persona: 'guest',
|
|
167
|
+
enabled: true,
|
|
168
|
+
goals: ['book a room'],
|
|
169
|
+
budget: { steps: 40, duration: '30m' },
|
|
170
|
+
minIntervalMs: HOUR,
|
|
171
|
+
maxIntervalMs: 2 * HOUR,
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const stored = await plain.get('guest')
|
|
175
|
+
assert.equal(stored?.enabled, true)
|
|
176
|
+
assert.deepEqual(stored?.goals, ['book a room'])
|
|
177
|
+
assert.deepEqual(stored?.budget, { steps: 40, duration: '30m' })
|
|
178
|
+
assert.ok(stored?.nextRunAt instanceof Date)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('a persona can be taken off the clock entirely', async () => {
|
|
182
|
+
await store.set({ persona: 'guest', enabled: true })
|
|
183
|
+
await store.remove('guest')
|
|
184
|
+
assert.equal(await store.get('guest'), null)
|
|
185
|
+
assert.deepEqual(await store.list(), [])
|
|
186
|
+
})
|
|
187
|
+
})
|