@pikku/kysely 0.13.24 → 0.13.28
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 +271 -0
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +3 -1
- package/dist/src/kysely-analytics-service.d.ts +30 -0
- package/dist/src/kysely-analytics-service.js +80 -0
- package/dist/src/kysely-feature-flag-store.d.ts +37 -0
- package/dist/src/kysely-feature-flag-store.js +239 -0
- package/dist/src/kysely-scope-service.d.ts +1 -0
- package/dist/src/kysely-scope-service.js +29 -0
- package/dist/src/kysely-tables.d.ts +45 -0
- package/dist/src/schema/analytics.schema.d.ts +17 -0
- package/dist/src/schema/analytics.schema.js +48 -0
- package/dist/src/schema/flag.schema.d.ts +16 -0
- package/dist/src/schema/flag.schema.js +62 -0
- package/dist/src/schema/index.d.ts +2 -0
- package/dist/src/schema/index.js +6 -0
- package/package.json +11 -6
- package/run-tests.sh +9 -1
- package/bin/pikku-kysely-pure.ts +0 -59
- package/dist/src/kysely-lock-vault.d.ts +0 -34
- package/dist/src/kysely-lock-vault.js +0 -77
- package/dist/src/schema/data-lock.schema.d.ts +0 -15
- package/dist/src/schema/data-lock.schema.js +0 -27
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/classification-crypto.test.ts +0 -143
- package/src/classification-crypto.ts +0 -163
- package/src/classification-plugin.pglite.test.ts +0 -226
- package/src/classification-plugin.test.ts +0 -265
- package/src/classification-plugin.ts +0 -249
- package/src/coercion-plugin.test.ts +0 -160
- package/src/coercion-plugin.ts +0 -187
- package/src/create-audited-kysely.test.ts +0 -560
- package/src/create-audited-kysely.ts +0 -254
- package/src/index.ts +0 -81
- package/src/kysely-agent-run-scores.ts +0 -47
- package/src/kysely-agent-run-service.ts +0 -244
- package/src/kysely-agent-run-state-service.ts +0 -200
- package/src/kysely-agent-storage-service.ts +0 -632
- package/src/kysely-audit-service.test.ts +0 -352
- package/src/kysely-audit-service.ts +0 -237
- package/src/kysely-channel-store.ts +0 -107
- package/src/kysely-credential-service.ts +0 -286
- package/src/kysely-deployment-service.ts +0 -163
- package/src/kysely-eventhub-store.ts +0 -53
- package/src/kysely-json.ts +0 -5
- package/src/kysely-scope-service.test.ts +0 -418
- package/src/kysely-scope-service.ts +0 -425
- package/src/kysely-secret-service.ts +0 -242
- package/src/kysely-services.test.ts +0 -405
- package/src/kysely-session-store.ts +0 -57
- package/src/kysely-tables.test.ts +0 -110
- package/src/kysely-tables.ts +0 -434
- package/src/kysely-virtual-user-run-store.test.ts +0 -369
- package/src/kysely-virtual-user-run-store.ts +0 -235
- package/src/kysely-virtual-user-schedule-store.test.ts +0 -187
- package/src/kysely-virtual-user-schedule-store.ts +0 -187
- package/src/kysely-webhook-service.test.ts +0 -153
- package/src/kysely-webhook-service.ts +0 -139
- package/src/kysely-workflow-mirror.test.ts +0 -259
- package/src/kysely-workflow-mirror.ts +0 -374
- package/src/kysely-workflow-run-service.ts +0 -305
- package/src/kysely-workflow-service.test.ts +0 -544
- package/src/kysely-workflow-service.ts +0 -820
- package/src/schema/agent.schema.ts +0 -162
- package/src/schema/audit.schema.ts +0 -57
- package/src/schema/channel.schema.ts +0 -38
- package/src/schema/credential.schema.ts +0 -58
- package/src/schema/deployment.schema.ts +0 -47
- package/src/schema/index.ts +0 -441
- package/src/schema/pikku-schema.types.ts +0 -169
- package/src/schema/schema.test.ts +0 -591
- package/src/schema/scope.schema.ts +0 -110
- package/src/schema/secret.schema.ts +0 -49
- package/src/schema/session.schema.ts +0 -20
- package/src/schema/virtual-user-schedule.schema.ts +0 -48
- package/src/schema/virtual-user.schema.ts +0 -106
- package/src/schema/webhook.schema.ts +0 -58
- package/src/schema/workflow.schema.ts +0 -150
- package/src/serialize-plugin.ts +0 -233
- package/tsconfig.json +0 -17
|
@@ -1,369 +0,0 @@
|
|
|
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 { KyselyVirtualUserRunStore } from './kysely-virtual-user-run-store.js'
|
|
7
|
-
import { SerializePlugin } from './serialize-plugin.js'
|
|
8
|
-
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
9
|
-
import { applyPikkuSchemas, virtualUserSchema } from './schema/index.js'
|
|
10
|
-
|
|
11
|
-
// SerializePlugin is here because most projects in this package run with it, and
|
|
12
|
-
// it is the harder case: it deserialises JSON columns the store has already
|
|
13
|
-
// stringified. The store has to survive that, and its absence.
|
|
14
|
-
const newDb = () =>
|
|
15
|
-
new Kysely<KyselyPikkuDB>({
|
|
16
|
-
dialect: new SqliteDialect({ database: new Database(':memory:') }),
|
|
17
|
-
plugins: [new CamelCasePlugin(), new SerializePlugin()],
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
const migratedDb = async () => {
|
|
21
|
-
const db = newDb()
|
|
22
|
-
await applyPikkuSchemas(db, [virtualUserSchema])
|
|
23
|
-
return db
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const TALLY = {
|
|
27
|
-
steps: 12,
|
|
28
|
-
calls: 30,
|
|
29
|
-
mutations: 4,
|
|
30
|
-
tokensIn: 900,
|
|
31
|
-
tokensOut: 120,
|
|
32
|
-
elapsed: 4200,
|
|
33
|
-
} as any
|
|
34
|
-
|
|
35
|
-
const FINDING = {
|
|
36
|
-
kind: 'schema-mismatch',
|
|
37
|
-
detail: 'listDocs returned a doc with no title',
|
|
38
|
-
rpcName: 'listDocs',
|
|
39
|
-
step: 7,
|
|
40
|
-
} as any
|
|
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
|
-
|
|
64
|
-
describe('KyselyVirtualUserRunStore', () => {
|
|
65
|
-
let db: Kysely<KyselyPikkuDB>
|
|
66
|
-
let store: KyselyVirtualUserRunStore
|
|
67
|
-
|
|
68
|
-
beforeEach(async () => {
|
|
69
|
-
db = await migratedDb()
|
|
70
|
-
store = new KyselyVirtualUserRunStore(db)
|
|
71
|
-
})
|
|
72
|
-
|
|
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 () => {
|
|
85
|
-
const runId = await store.start({
|
|
86
|
-
persona: 'susan',
|
|
87
|
-
disposition: 'realistic',
|
|
88
|
-
seed: 42,
|
|
89
|
-
})
|
|
90
|
-
assert.ok(runId)
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
test('a run starts as running, with no findings and no finish time', async () => {
|
|
94
|
-
const runId = await store.start({
|
|
95
|
-
persona: 'susan',
|
|
96
|
-
disposition: 'careless',
|
|
97
|
-
seed: 7,
|
|
98
|
-
goals: ['find the export button'],
|
|
99
|
-
memory: { docId: 'doc_1' },
|
|
100
|
-
startedBy: 'user_1',
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
const run = await store.get(runId)
|
|
104
|
-
assert.equal(run?.status, 'running')
|
|
105
|
-
assert.deepEqual(run?.findings, [])
|
|
106
|
-
assert.equal(run?.finishedAt, null)
|
|
107
|
-
assert.equal(run?.error, null)
|
|
108
|
-
assert.deepEqual(run?.goals, ['find the export button'])
|
|
109
|
-
assert.deepEqual(run?.memory, { docId: 'doc_1' })
|
|
110
|
-
assert.equal(run?.startedBy, 'user_1')
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
// The seed is the whole reason a finding is reproducible, and a BIGINT comes
|
|
114
|
-
// back as a string on some drivers — so it has to survive the round trip as a
|
|
115
|
-
// number.
|
|
116
|
-
test('the seed round-trips as a number', async () => {
|
|
117
|
-
const runId = await store.start({
|
|
118
|
-
persona: 'susan',
|
|
119
|
-
disposition: 'realistic',
|
|
120
|
-
seed: 2_147_483_646,
|
|
121
|
-
})
|
|
122
|
-
const run = await store.get(runId)
|
|
123
|
-
assert.strictEqual(run?.seed, 2_147_483_646)
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
test('completing a run stores what it found', async () => {
|
|
127
|
-
const runId = await store.start({
|
|
128
|
-
persona: 'susan',
|
|
129
|
-
disposition: 'auditor',
|
|
130
|
-
seed: 1,
|
|
131
|
-
memory: { docId: 'doc_1' },
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
await store.complete(runId, {
|
|
135
|
-
findings: [FINDING],
|
|
136
|
-
tally: TALLY,
|
|
137
|
-
memory: { docId: 'doc_1', orgId: 'org_9' },
|
|
138
|
-
stoppedBy: 'budget-steps',
|
|
139
|
-
intents: [INTENT],
|
|
140
|
-
steps: [step(0), step(1)],
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
const run = await store.get(runId)
|
|
144
|
-
assert.equal(run?.status, 'completed')
|
|
145
|
-
assert.equal(run?.findings.length, 1)
|
|
146
|
-
assert.equal(run?.findings[0]?.detail, FINDING.detail)
|
|
147
|
-
assert.deepEqual(run?.tally, TALLY)
|
|
148
|
-
assert.equal(run?.stoppedBy, 'budget-steps')
|
|
149
|
-
// Overwritten, not merged: the engine's memory already carries what it was
|
|
150
|
-
// given plus what it learned.
|
|
151
|
-
assert.deepEqual(run?.memory, { docId: 'doc_1', orgId: 'org_9' })
|
|
152
|
-
assert.ok(run?.finishedAt instanceof Date)
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
// A crashed run and a run that found nothing are different answers, and this
|
|
156
|
-
// record is the only place that distinction survives.
|
|
157
|
-
test('a failed run is failed, not an empty success', async () => {
|
|
158
|
-
const runId = await store.start({
|
|
159
|
-
persona: 'susan',
|
|
160
|
-
disposition: 'realistic',
|
|
161
|
-
seed: 1,
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
await store.fail(runId, 'SCENARIO_ACTOR_SECRET is not set')
|
|
165
|
-
|
|
166
|
-
const run = await store.get(runId)
|
|
167
|
-
assert.equal(run?.status, 'failed')
|
|
168
|
-
assert.equal(run?.error, 'SCENARIO_ACTOR_SECRET is not set')
|
|
169
|
-
assert.deepEqual(run?.findings, [])
|
|
170
|
-
assert.ok(run?.finishedAt instanceof Date)
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
test('an unknown run is null rather than a throw', async () => {
|
|
174
|
-
await store.init()
|
|
175
|
-
assert.equal(await store.get('nope'), null)
|
|
176
|
-
})
|
|
177
|
-
|
|
178
|
-
test('lists newest first', async () => {
|
|
179
|
-
const first = await store.start({
|
|
180
|
-
persona: 'susan',
|
|
181
|
-
disposition: 'realistic',
|
|
182
|
-
seed: 1,
|
|
183
|
-
})
|
|
184
|
-
const second = await store.start({
|
|
185
|
-
persona: 'raj',
|
|
186
|
-
disposition: 'realistic',
|
|
187
|
-
seed: 2,
|
|
188
|
-
})
|
|
189
|
-
|
|
190
|
-
const runs = await store.list()
|
|
191
|
-
assert.equal(runs.length, 2)
|
|
192
|
-
assert.deepEqual(
|
|
193
|
-
new Set(runs.map((run) => run.runId)),
|
|
194
|
-
new Set([first, second])
|
|
195
|
-
)
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
// Comparing what a persona finds this week against last week is the whole
|
|
199
|
-
// reason runs are kept.
|
|
200
|
-
test('narrows to one persona s history', async () => {
|
|
201
|
-
await store.start({ persona: 'susan', disposition: 'realistic', seed: 1 })
|
|
202
|
-
await store.start({ persona: 'raj', disposition: 'realistic', seed: 2 })
|
|
203
|
-
|
|
204
|
-
const runs = await store.list({ persona: 'susan' })
|
|
205
|
-
assert.equal(runs.length, 1)
|
|
206
|
-
assert.equal(runs[0]?.persona, 'susan')
|
|
207
|
-
})
|
|
208
|
-
|
|
209
|
-
test('completing an unknown run does not throw, and leaves no steps behind', async () => {
|
|
210
|
-
await store.init()
|
|
211
|
-
await store.complete('nope', {
|
|
212
|
-
findings: [],
|
|
213
|
-
tally: TALLY,
|
|
214
|
-
memory: {},
|
|
215
|
-
stoppedBy: 'exhausted',
|
|
216
|
-
intents: [INTENT],
|
|
217
|
-
steps: [step(0)],
|
|
218
|
-
})
|
|
219
|
-
assert.deepEqual(await store.steps('nope'), [])
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
// Not every project installs SerializePlugin, and a bare SQLite driver cannot
|
|
223
|
-
// bind a Date at all — so the whole round trip has to hold without it.
|
|
224
|
-
test('round-trips without SerializePlugin', async () => {
|
|
225
|
-
const bare = new Kysely<KyselyPikkuDB>({
|
|
226
|
-
dialect: new SqliteDialect({ database: new Database(':memory:') }),
|
|
227
|
-
plugins: [new CamelCasePlugin()],
|
|
228
|
-
})
|
|
229
|
-
await applyPikkuSchemas(bare, [virtualUserSchema])
|
|
230
|
-
const bareStore = new KyselyVirtualUserRunStore(bare)
|
|
231
|
-
|
|
232
|
-
const runId = await bareStore.start({
|
|
233
|
-
persona: 'susan',
|
|
234
|
-
disposition: 'realistic',
|
|
235
|
-
seed: 99,
|
|
236
|
-
goals: ['export a doc'],
|
|
237
|
-
memory: { docId: 'doc_1' },
|
|
238
|
-
})
|
|
239
|
-
await bareStore.complete(runId, {
|
|
240
|
-
findings: [FINDING],
|
|
241
|
-
tally: TALLY,
|
|
242
|
-
memory: { docId: 'doc_1' },
|
|
243
|
-
stoppedBy: 'exhausted',
|
|
244
|
-
intents: [INTENT],
|
|
245
|
-
steps: [step(0)],
|
|
246
|
-
})
|
|
247
|
-
|
|
248
|
-
const run = await bareStore.get(runId)
|
|
249
|
-
assert.equal(run?.status, 'completed')
|
|
250
|
-
assert.strictEqual(run?.seed, 99)
|
|
251
|
-
assert.deepEqual(run?.goals, ['export a doc'])
|
|
252
|
-
assert.deepEqual(run?.tally, TALLY)
|
|
253
|
-
assert.equal(run?.findings[0]?.detail, FINDING.detail)
|
|
254
|
-
assert.ok(run?.createdAt instanceof Date)
|
|
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), [])
|
|
368
|
-
})
|
|
369
|
-
})
|
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
import type { Kysely, Selectable } from 'kysely'
|
|
2
|
-
import { randomUUID } from 'node:crypto'
|
|
3
|
-
import type {
|
|
4
|
-
StepRecord,
|
|
5
|
-
VirtualUserDisposition,
|
|
6
|
-
} from '@pikku/core/virtual-user'
|
|
7
|
-
import type {
|
|
8
|
-
VirtualUserRunOutcome,
|
|
9
|
-
VirtualUserRunRecord,
|
|
10
|
-
VirtualUserRunStart,
|
|
11
|
-
VirtualUserRunStore,
|
|
12
|
-
} from '@pikku/core/virtual-user'
|
|
13
|
-
import type {
|
|
14
|
-
KyselyPikkuDB,
|
|
15
|
-
VirtualUserRunStepTable,
|
|
16
|
-
VirtualUserRunTable,
|
|
17
|
-
} from './kysely-tables.js'
|
|
18
|
-
import { parseJson } from './kysely-json.js'
|
|
19
|
-
import { requirePikkuSchema } from './schema/index.js'
|
|
20
|
-
import { virtualUserSchema } from './schema/virtual-user.schema.js'
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Records virtual-user runs in a `virtualUserRun` table.
|
|
24
|
-
*
|
|
25
|
-
* The store is the whole record of a run: `runVirtualUser` returns as soon as
|
|
26
|
-
* the row exists and the engine keeps going without it, so nothing else knows
|
|
27
|
-
* the run happened. That is why `fail()` exists as its own call — a run that
|
|
28
|
-
* crashed and a run that found nothing are different answers, and a row left at
|
|
29
|
-
* `running` is neither.
|
|
30
|
-
*
|
|
31
|
-
* Works with or without `SerializePlugin`, which not every project installs: the
|
|
32
|
-
* JSON columns are written as strings (which the plugin passes through) and read
|
|
33
|
-
* back through `parseJson` (which passes through what the plugin already
|
|
34
|
-
* parsed). Timestamps are written as ISO strings for the same reason — a bare
|
|
35
|
-
* SQLite driver cannot bind a `Date` at all.
|
|
36
|
-
*
|
|
37
|
-
* SECURITY: an `adversarial` run's findings are working exploits carrying live
|
|
38
|
-
* ids. This is a privileged store; the scaffolded reads are scope-gated, and
|
|
39
|
-
* exposing these rows more widely publishes your own exploits.
|
|
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
|
-
|
|
51
|
-
export class KyselyVirtualUserRunStore implements VirtualUserRunStore {
|
|
52
|
-
private initialized = false
|
|
53
|
-
|
|
54
|
-
constructor(private db: Kysely<KyselyPikkuDB>) {}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Creates the table on first use, like the audit sink — the runtime does not
|
|
58
|
-
* need it, so it arrives with the feature that fills it rather than in every
|
|
59
|
-
* database.
|
|
60
|
-
*/
|
|
61
|
-
public async init(): Promise<void> {
|
|
62
|
-
if (this.initialized) return
|
|
63
|
-
await requirePikkuSchema(this.db, virtualUserSchema)
|
|
64
|
-
this.initialized = true
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async start(run: VirtualUserRunStart): Promise<string> {
|
|
68
|
-
await this.init()
|
|
69
|
-
const runId = randomUUID()
|
|
70
|
-
await this.db
|
|
71
|
-
.insertInto('virtualUserRun')
|
|
72
|
-
.values({
|
|
73
|
-
runId,
|
|
74
|
-
persona: run.persona,
|
|
75
|
-
disposition: run.disposition,
|
|
76
|
-
// Text on every engine (see the schema), so the seed round-trips
|
|
77
|
-
// through drivers that hand a BIGINT back as a string.
|
|
78
|
-
seed: String(run.seed),
|
|
79
|
-
status: 'running',
|
|
80
|
-
goals: JSON.stringify(run.goals ?? []),
|
|
81
|
-
memory: JSON.stringify(run.memory ?? {}),
|
|
82
|
-
findings: '[]',
|
|
83
|
-
startedBy: run.startedBy ?? null,
|
|
84
|
-
})
|
|
85
|
-
.execute()
|
|
86
|
-
return runId
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async complete(runId: string, outcome: VirtualUserRunOutcome): Promise<void> {
|
|
90
|
-
await this.init()
|
|
91
|
-
const updated = await this.db
|
|
92
|
-
.updateTable('virtualUserRun')
|
|
93
|
-
.set({
|
|
94
|
-
status: 'completed',
|
|
95
|
-
findings: JSON.stringify(outcome.findings),
|
|
96
|
-
tally: JSON.stringify(outcome.tally),
|
|
97
|
-
// Overwritten rather than merged: the engine's memory already carries
|
|
98
|
-
// what it was given, plus what it learned on the way.
|
|
99
|
-
memory: JSON.stringify(outcome.memory),
|
|
100
|
-
intents: JSON.stringify(outcome.intents),
|
|
101
|
-
stoppedBy: outcome.stoppedBy,
|
|
102
|
-
finishedAt: new Date().toISOString(),
|
|
103
|
-
})
|
|
104
|
-
.where('runId', '=', runId)
|
|
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)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
async fail(runId: string, error: string): Promise<void> {
|
|
113
|
-
await this.init()
|
|
114
|
-
await this.db
|
|
115
|
-
.updateTable('virtualUserRun')
|
|
116
|
-
.set({
|
|
117
|
-
status: 'failed',
|
|
118
|
-
error,
|
|
119
|
-
finishedAt: new Date().toISOString(),
|
|
120
|
-
})
|
|
121
|
-
.where('runId', '=', runId)
|
|
122
|
-
.execute()
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async get(runId: string): Promise<VirtualUserRunRecord | null> {
|
|
126
|
-
await this.init()
|
|
127
|
-
const row = await this.db
|
|
128
|
-
.selectFrom('virtualUserRun')
|
|
129
|
-
.selectAll()
|
|
130
|
-
.where('runId', '=', runId)
|
|
131
|
-
.executeTakeFirst()
|
|
132
|
-
return row ? this.toRecord(row) : null
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
async list(options?: {
|
|
136
|
-
persona?: string
|
|
137
|
-
limit?: number
|
|
138
|
-
offset?: number
|
|
139
|
-
}): Promise<VirtualUserRunRecord[]> {
|
|
140
|
-
await this.init()
|
|
141
|
-
let query = this.db.selectFrom('virtualUserRun').selectAll()
|
|
142
|
-
if (options?.persona) {
|
|
143
|
-
query = query.where('persona', '=', options.persona)
|
|
144
|
-
}
|
|
145
|
-
const rows = await query
|
|
146
|
-
.orderBy('createdAt', 'desc')
|
|
147
|
-
.limit(options?.limit ?? 50)
|
|
148
|
-
.offset(options?.offset ?? 0)
|
|
149
|
-
.execute()
|
|
150
|
-
return rows.map((row) => this.toRecord(row))
|
|
151
|
-
}
|
|
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
|
-
|
|
216
|
-
private toRecord(row: Selectable<VirtualUserRunTable>): VirtualUserRunRecord {
|
|
217
|
-
return {
|
|
218
|
-
runId: row.runId,
|
|
219
|
-
persona: row.persona,
|
|
220
|
-
disposition: row.disposition as VirtualUserDisposition,
|
|
221
|
-
seed: Number(row.seed),
|
|
222
|
-
status: row.status,
|
|
223
|
-
goals: parseJson(row.goals) ?? [],
|
|
224
|
-
memory: parseJson(row.memory) ?? {},
|
|
225
|
-
findings: parseJson(row.findings) ?? [],
|
|
226
|
-
intents: parseJson(row.intents) ?? [],
|
|
227
|
-
tally: row.tally ? (parseJson(row.tally) ?? null) : null,
|
|
228
|
-
stoppedBy: row.stoppedBy,
|
|
229
|
-
error: row.error,
|
|
230
|
-
startedBy: row.startedBy,
|
|
231
|
-
createdAt: new Date(row.createdAt),
|
|
232
|
-
finishedAt: row.finishedAt ? new Date(row.finishedAt) : null,
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
}
|