@pikku/kysely 0.13.7 → 0.13.11
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 +202 -0
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +3 -1
- package/dist/src/kysely-audit-service.d.ts +34 -2
- package/dist/src/kysely-audit-service.js +163 -3
- package/dist/src/kysely-secret-service.d.ts +4 -3
- package/dist/src/kysely-secret-service.js +6 -3
- package/dist/src/kysely-tables.d.ts +31 -0
- package/dist/src/kysely-virtual-user-run-store.d.ts +43 -0
- package/dist/src/kysely-virtual-user-run-store.js +131 -0
- package/dist/src/kysely-workflow-service.d.ts +1 -0
- package/dist/src/kysely-workflow-service.js +22 -0
- package/dist/src/schema/audit.schema.d.ts +16 -0
- package/dist/src/schema/audit.schema.js +47 -0
- package/dist/src/schema/credential.schema.d.ts +3 -2
- package/dist/src/schema/credential.schema.js +4 -3
- package/dist/src/schema/index.d.ts +17 -4
- package/dist/src/schema/index.js +40 -6
- package/dist/src/schema/pikku-schema.types.d.ts +41 -5
- package/dist/src/schema/pikku-schema.types.js +27 -12
- package/dist/src/schema/virtual-user.schema.d.ts +19 -0
- package/dist/src/schema/virtual-user.schema.js +54 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/create-audited-kysely.test.ts +10 -10
- package/src/index.ts +7 -0
- package/src/kysely-audit-service.test.ts +360 -0
- package/src/kysely-audit-service.ts +181 -4
- package/src/kysely-secret-service.ts +11 -10
- package/src/kysely-services.test.ts +3 -3
- package/src/kysely-tables.test.ts +12 -1
- package/src/kysely-tables.ts +32 -0
- package/src/kysely-virtual-user-run-store.test.ts +212 -0
- package/src/kysely-virtual-user-run-store.ts +149 -0
- package/src/kysely-workflow-service.ts +39 -0
- package/src/schema/audit.schema.ts +55 -0
- package/src/schema/credential.schema.ts +5 -3
- package/src/schema/index.ts +59 -10
- package/src/schema/pikku-schema.types.ts +65 -10
- package/src/schema/schema.test.ts +82 -4
- package/src/schema/virtual-user.schema.ts +65 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { createSecretValue, isSecretValue, type SecretValue } from '@pikku/core'
|
|
2
|
+
import type { SecretService, SecretValues } from '@pikku/core/services'
|
|
2
3
|
import type { Kysely } from 'kysely'
|
|
3
4
|
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
4
5
|
import {
|
|
@@ -112,7 +113,7 @@ export class KyselySecretService implements SecretService {
|
|
|
112
113
|
return kek
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
async getSecret<T = string>(key: string): Promise<T
|
|
116
|
+
async getSecret<T = string>(key: string): Promise<SecretValue<T>> {
|
|
116
117
|
const row = await this.db
|
|
117
118
|
.selectFrom('secrets')
|
|
118
119
|
.select(['ciphertext', 'wrappedDek', 'keyVersion'])
|
|
@@ -124,7 +125,7 @@ export class KyselySecretService implements SecretService {
|
|
|
124
125
|
const kek = await this.getKEK(row.keyVersion)
|
|
125
126
|
const result = await envelopeDecrypt<T>(kek, row.ciphertext, row.wrappedDek)
|
|
126
127
|
await this.logAudit(key, 'read')
|
|
127
|
-
return result
|
|
128
|
+
return createSecretValue(result)
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
async hasSecret(key: string): Promise<boolean> {
|
|
@@ -137,9 +138,11 @@ export class KyselySecretService implements SecretService {
|
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
async setSecret(key: string, value: unknown): Promise<void> {
|
|
141
|
+
// Encrypting the wrapper would store its redaction, not the secret —
|
|
142
|
+
// writing a secret back to the vault is exactly what this method is for.
|
|
140
143
|
const { ciphertext, wrappedDEK } = await envelopeEncrypt(
|
|
141
144
|
await this.getKEK(this.keyVersion),
|
|
142
|
-
value
|
|
145
|
+
isSecretValue(value) ? value.reveal() : value
|
|
143
146
|
)
|
|
144
147
|
const now = new Date().toISOString()
|
|
145
148
|
|
|
@@ -173,7 +176,7 @@ export class KyselySecretService implements SecretService {
|
|
|
173
176
|
|
|
174
177
|
async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
175
178
|
keys: (keyof T & string)[]
|
|
176
|
-
): Promise<T
|
|
179
|
+
): Promise<Partial<SecretValues<T>>> {
|
|
177
180
|
const rows = await this.db
|
|
178
181
|
.selectFrom('secrets')
|
|
179
182
|
.select(['key', 'ciphertext', 'wrappedDek', 'keyVersion'])
|
|
@@ -184,10 +187,8 @@ export class KyselySecretService implements SecretService {
|
|
|
184
187
|
for (const row of rows) {
|
|
185
188
|
try {
|
|
186
189
|
const kek = await this.getKEK(row.keyVersion)
|
|
187
|
-
out[row.key] =
|
|
188
|
-
kek,
|
|
189
|
-
row.ciphertext,
|
|
190
|
-
row.wrappedDek
|
|
190
|
+
out[row.key] = createSecretValue(
|
|
191
|
+
await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek)
|
|
191
192
|
)
|
|
192
193
|
} catch (cause) {
|
|
193
194
|
// A stored secret that fails to decrypt is never expected — it means the
|
|
@@ -203,7 +204,7 @@ export class KyselySecretService implements SecretService {
|
|
|
203
204
|
)
|
|
204
205
|
}
|
|
205
206
|
}
|
|
206
|
-
return out as T
|
|
207
|
+
return out as Partial<SecretValues<T>>
|
|
207
208
|
}
|
|
208
209
|
|
|
209
210
|
async rotateKEK(): Promise<number> {
|
|
@@ -199,7 +199,7 @@ function registerTests(
|
|
|
199
199
|
|
|
200
200
|
const second = new KyselySecretService(getDb(), { key: kek })
|
|
201
201
|
await second.init()
|
|
202
|
-
assert.equal(await second.getSecret('shared-salt'), 'value')
|
|
202
|
+
assert.equal((await second.getSecret('shared-salt')).reveal(), 'value')
|
|
203
203
|
|
|
204
204
|
const rows = await getDb()
|
|
205
205
|
.selectFrom('secretKekSalts')
|
|
@@ -227,7 +227,7 @@ function registerTests(
|
|
|
227
227
|
const elapsed = performance.now() - start
|
|
228
228
|
|
|
229
229
|
assert.equal(Object.keys(out).length, 50)
|
|
230
|
-
assert.equal(out['bulk-7'], 'value-7')
|
|
230
|
+
assert.equal(out['bulk-7']!.reveal(), 'value-7')
|
|
231
231
|
assert.ok(
|
|
232
232
|
elapsed < 400,
|
|
233
233
|
`getSecrets over 50 secrets took ${elapsed.toFixed(0)}ms; one derivation per secret would cost seconds`
|
|
@@ -258,7 +258,7 @@ function registerTests(
|
|
|
258
258
|
const elapsed = performance.now() - start
|
|
259
259
|
|
|
260
260
|
assert.equal(rotated, 50)
|
|
261
|
-
assert.equal(await rotator.getSecret('rotate-7'), 'value-7')
|
|
261
|
+
assert.equal((await rotator.getSecret('rotate-7')).reveal(), 'value-7')
|
|
262
262
|
assert.ok(
|
|
263
263
|
elapsed < 700,
|
|
264
264
|
`rotating 50 secrets took ${elapsed.toFixed(0)}ms; two derivations per secret would cost seconds`
|
|
@@ -5,7 +5,15 @@ import { join } from 'node:path'
|
|
|
5
5
|
import { Kysely, SqliteDialect } from 'kysely'
|
|
6
6
|
import Database from 'better-sqlite3'
|
|
7
7
|
|
|
8
|
-
import { applyPikkuSchemas } from './schema/index.js'
|
|
8
|
+
import { applyPikkuSchemas, ensurePikkuSchema } from './schema/index.js'
|
|
9
|
+
import { virtualUserSchema } from './schema/virtual-user.schema.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Opt-in schemas that still have an interface in `KyselyPikkuDB`, so the drift
|
|
13
|
+
* check covers them too. A table being opt-in says when it is created, not
|
|
14
|
+
* whether its type is allowed to disagree with its DDL.
|
|
15
|
+
*/
|
|
16
|
+
const OPTIONAL_SCHEMAS = [virtualUserSchema]
|
|
9
17
|
|
|
10
18
|
/**
|
|
11
19
|
* `KyselyPikkuDB` and the schema declaration describe the same tables, and
|
|
@@ -65,6 +73,9 @@ const declaredSchema = async (): Promise<Map<string, Set<string>>> => {
|
|
|
65
73
|
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
66
74
|
.execute()
|
|
67
75
|
await applyPikkuSchemas(db)
|
|
76
|
+
for (const schema of OPTIONAL_SCHEMAS) {
|
|
77
|
+
await ensurePikkuSchema(db, schema)
|
|
78
|
+
}
|
|
68
79
|
|
|
69
80
|
const tables = await db.introspection.getTables()
|
|
70
81
|
return new Map(
|
package/src/kysely-tables.ts
CHANGED
|
@@ -281,6 +281,37 @@ export interface WebhookDeliveryAttemptTable {
|
|
|
281
281
|
createdAt: Generated<Date>
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* One virtual-user run. The JSON columns (`goals`, `memory`, `findings`,
|
|
286
|
+
* `tally`) are text the store serialises itself, so the row is byte-identical
|
|
287
|
+
* on every engine.
|
|
288
|
+
*/
|
|
289
|
+
export interface VirtualUserRunTable {
|
|
290
|
+
runId: string
|
|
291
|
+
persona: string
|
|
292
|
+
disposition: string
|
|
293
|
+
/**
|
|
294
|
+
* Read back as a string by some drivers — a BIGINT does not always fit a JS
|
|
295
|
+
* number — so the store narrows it rather than trusting the column type.
|
|
296
|
+
*/
|
|
297
|
+
seed: string | number | bigint
|
|
298
|
+
status: Generated<'running' | 'completed' | 'failed'>
|
|
299
|
+
goals: Generated<string>
|
|
300
|
+
memory: Generated<string>
|
|
301
|
+
findings: Generated<string>
|
|
302
|
+
tally: string | null
|
|
303
|
+
stoppedBy: string | null
|
|
304
|
+
error: string | null
|
|
305
|
+
startedBy: string | null
|
|
306
|
+
/**
|
|
307
|
+
* Written as an ISO string and read back as whatever the driver returns — a
|
|
308
|
+
* bare SQLite driver cannot bind a `Date` at all, and the store normalises
|
|
309
|
+
* both ends.
|
|
310
|
+
*/
|
|
311
|
+
createdAt: Generated<Date | string>
|
|
312
|
+
finishedAt: Date | string | null
|
|
313
|
+
}
|
|
314
|
+
|
|
284
315
|
export interface KyselyPikkuDB {
|
|
285
316
|
pikkuScopes: PikkuScopesTable
|
|
286
317
|
pikkuRoles: PikkuRolesTable
|
|
@@ -309,4 +340,5 @@ export interface KyselyPikkuDB {
|
|
|
309
340
|
pikkuUserSessions: UserSessionsTable
|
|
310
341
|
webhookDelivery: WebhookDeliveryTable
|
|
311
342
|
webhookDeliveryAttempt: WebhookDeliveryAttemptTable
|
|
343
|
+
virtualUserRun: VirtualUserRunTable
|
|
312
344
|
}
|
|
@@ -0,0 +1,212 @@
|
|
|
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
|
+
|
|
10
|
+
// SerializePlugin is here because most projects in this package run with it, and
|
|
11
|
+
// it is the harder case: it deserialises JSON columns the store has already
|
|
12
|
+
// stringified. The store has to survive that, and its absence.
|
|
13
|
+
const newDb = () =>
|
|
14
|
+
new Kysely<KyselyPikkuDB>({
|
|
15
|
+
dialect: new SqliteDialect({ database: new Database(':memory:') }),
|
|
16
|
+
plugins: [new CamelCasePlugin(), new SerializePlugin()],
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const TALLY = {
|
|
20
|
+
steps: 12,
|
|
21
|
+
calls: 30,
|
|
22
|
+
mutations: 4,
|
|
23
|
+
tokensIn: 900,
|
|
24
|
+
tokensOut: 120,
|
|
25
|
+
elapsed: 4200,
|
|
26
|
+
} as any
|
|
27
|
+
|
|
28
|
+
const FINDING = {
|
|
29
|
+
kind: 'schema-mismatch',
|
|
30
|
+
detail: 'listDocs returned a doc with no title',
|
|
31
|
+
rpcName: 'listDocs',
|
|
32
|
+
step: 7,
|
|
33
|
+
} as any
|
|
34
|
+
|
|
35
|
+
describe('KyselyVirtualUserRunStore', () => {
|
|
36
|
+
let db: Kysely<KyselyPikkuDB>
|
|
37
|
+
let store: KyselyVirtualUserRunStore
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
db = newDb()
|
|
41
|
+
store = new KyselyVirtualUserRunStore(db)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// The runtime never needs this table, so it arrives with the feature that
|
|
45
|
+
// fills it rather than in every database — which means the first write has to
|
|
46
|
+
// create it.
|
|
47
|
+
test('creates its table on first use', async () => {
|
|
48
|
+
const runId = await store.start({
|
|
49
|
+
persona: 'susan',
|
|
50
|
+
disposition: 'realistic',
|
|
51
|
+
seed: 42,
|
|
52
|
+
})
|
|
53
|
+
assert.ok(runId)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('a run starts as running, with no findings and no finish time', async () => {
|
|
57
|
+
const runId = await store.start({
|
|
58
|
+
persona: 'susan',
|
|
59
|
+
disposition: 'careless',
|
|
60
|
+
seed: 7,
|
|
61
|
+
goals: ['find the export button'],
|
|
62
|
+
memory: { docId: 'doc_1' },
|
|
63
|
+
startedBy: 'user_1',
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const run = await store.get(runId)
|
|
67
|
+
assert.equal(run?.status, 'running')
|
|
68
|
+
assert.deepEqual(run?.findings, [])
|
|
69
|
+
assert.equal(run?.finishedAt, null)
|
|
70
|
+
assert.equal(run?.error, null)
|
|
71
|
+
assert.deepEqual(run?.goals, ['find the export button'])
|
|
72
|
+
assert.deepEqual(run?.memory, { docId: 'doc_1' })
|
|
73
|
+
assert.equal(run?.startedBy, 'user_1')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// The seed is the whole reason a finding is reproducible, and a BIGINT comes
|
|
77
|
+
// back as a string on some drivers — so it has to survive the round trip as a
|
|
78
|
+
// number.
|
|
79
|
+
test('the seed round-trips as a number', async () => {
|
|
80
|
+
const runId = await store.start({
|
|
81
|
+
persona: 'susan',
|
|
82
|
+
disposition: 'realistic',
|
|
83
|
+
seed: 2_147_483_646,
|
|
84
|
+
})
|
|
85
|
+
const run = await store.get(runId)
|
|
86
|
+
assert.strictEqual(run?.seed, 2_147_483_646)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('completing a run stores what it found', async () => {
|
|
90
|
+
const runId = await store.start({
|
|
91
|
+
persona: 'susan',
|
|
92
|
+
disposition: 'auditor',
|
|
93
|
+
seed: 1,
|
|
94
|
+
memory: { docId: 'doc_1' },
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
await store.complete(runId, {
|
|
98
|
+
findings: [FINDING],
|
|
99
|
+
tally: TALLY,
|
|
100
|
+
memory: { docId: 'doc_1', orgId: 'org_9' },
|
|
101
|
+
stoppedBy: 'budget-steps',
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const run = await store.get(runId)
|
|
105
|
+
assert.equal(run?.status, 'completed')
|
|
106
|
+
assert.equal(run?.findings.length, 1)
|
|
107
|
+
assert.equal(run?.findings[0]?.detail, FINDING.detail)
|
|
108
|
+
assert.deepEqual(run?.tally, TALLY)
|
|
109
|
+
assert.equal(run?.stoppedBy, 'budget-steps')
|
|
110
|
+
// Overwritten, not merged: the engine's memory already carries what it was
|
|
111
|
+
// given plus what it learned.
|
|
112
|
+
assert.deepEqual(run?.memory, { docId: 'doc_1', orgId: 'org_9' })
|
|
113
|
+
assert.ok(run?.finishedAt instanceof Date)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// A crashed run and a run that found nothing are different answers, and this
|
|
117
|
+
// record is the only place that distinction survives.
|
|
118
|
+
test('a failed run is failed, not an empty success', async () => {
|
|
119
|
+
const runId = await store.start({
|
|
120
|
+
persona: 'susan',
|
|
121
|
+
disposition: 'realistic',
|
|
122
|
+
seed: 1,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
await store.fail(runId, 'SCENARIO_ACTOR_SECRET is not set')
|
|
126
|
+
|
|
127
|
+
const run = await store.get(runId)
|
|
128
|
+
assert.equal(run?.status, 'failed')
|
|
129
|
+
assert.equal(run?.error, 'SCENARIO_ACTOR_SECRET is not set')
|
|
130
|
+
assert.deepEqual(run?.findings, [])
|
|
131
|
+
assert.ok(run?.finishedAt instanceof Date)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('an unknown run is null rather than a throw', async () => {
|
|
135
|
+
await store.init()
|
|
136
|
+
assert.equal(await store.get('nope'), null)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('lists newest first', async () => {
|
|
140
|
+
const first = await store.start({
|
|
141
|
+
persona: 'susan',
|
|
142
|
+
disposition: 'realistic',
|
|
143
|
+
seed: 1,
|
|
144
|
+
})
|
|
145
|
+
const second = await store.start({
|
|
146
|
+
persona: 'raj',
|
|
147
|
+
disposition: 'realistic',
|
|
148
|
+
seed: 2,
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const runs = await store.list()
|
|
152
|
+
assert.equal(runs.length, 2)
|
|
153
|
+
assert.deepEqual(
|
|
154
|
+
new Set(runs.map((run) => run.runId)),
|
|
155
|
+
new Set([first, second])
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// Comparing what a persona finds this week against last week is the whole
|
|
160
|
+
// reason runs are kept.
|
|
161
|
+
test('narrows to one persona s history', async () => {
|
|
162
|
+
await store.start({ persona: 'susan', disposition: 'realistic', seed: 1 })
|
|
163
|
+
await store.start({ persona: 'raj', disposition: 'realistic', seed: 2 })
|
|
164
|
+
|
|
165
|
+
const runs = await store.list({ persona: 'susan' })
|
|
166
|
+
assert.equal(runs.length, 1)
|
|
167
|
+
assert.equal(runs[0]?.persona, 'susan')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('completing an unknown run does not throw', async () => {
|
|
171
|
+
await store.init()
|
|
172
|
+
await store.complete('nope', {
|
|
173
|
+
findings: [],
|
|
174
|
+
tally: TALLY,
|
|
175
|
+
memory: {},
|
|
176
|
+
stoppedBy: 'exhausted',
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// Not every project installs SerializePlugin, and a bare SQLite driver cannot
|
|
181
|
+
// bind a Date at all — so the whole round trip has to hold without it.
|
|
182
|
+
test('round-trips without SerializePlugin', async () => {
|
|
183
|
+
const bare = new Kysely<KyselyPikkuDB>({
|
|
184
|
+
dialect: new SqliteDialect({ database: new Database(':memory:') }),
|
|
185
|
+
plugins: [new CamelCasePlugin()],
|
|
186
|
+
})
|
|
187
|
+
const bareStore = new KyselyVirtualUserRunStore(bare)
|
|
188
|
+
|
|
189
|
+
const runId = await bareStore.start({
|
|
190
|
+
persona: 'susan',
|
|
191
|
+
disposition: 'realistic',
|
|
192
|
+
seed: 99,
|
|
193
|
+
goals: ['export a doc'],
|
|
194
|
+
memory: { docId: 'doc_1' },
|
|
195
|
+
})
|
|
196
|
+
await bareStore.complete(runId, {
|
|
197
|
+
findings: [FINDING],
|
|
198
|
+
tally: TALLY,
|
|
199
|
+
memory: { docId: 'doc_1' },
|
|
200
|
+
stoppedBy: 'exhausted',
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const run = await bareStore.get(runId)
|
|
204
|
+
assert.equal(run?.status, 'completed')
|
|
205
|
+
assert.strictEqual(run?.seed, 99)
|
|
206
|
+
assert.deepEqual(run?.goals, ['export a doc'])
|
|
207
|
+
assert.deepEqual(run?.tally, TALLY)
|
|
208
|
+
assert.equal(run?.findings[0]?.detail, FINDING.detail)
|
|
209
|
+
assert.ok(run?.createdAt instanceof Date)
|
|
210
|
+
assert.ok(run?.finishedAt instanceof Date)
|
|
211
|
+
})
|
|
212
|
+
})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { Kysely, Selectable } from 'kysely'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import type {
|
|
4
|
+
VirtualUserDisposition,
|
|
5
|
+
VirtualUserRunOutcome,
|
|
6
|
+
VirtualUserRunRecord,
|
|
7
|
+
VirtualUserRunStart,
|
|
8
|
+
VirtualUserRunStore,
|
|
9
|
+
} from '@pikku/core/virtual-user'
|
|
10
|
+
import type { KyselyPikkuDB, VirtualUserRunTable } from './kysely-tables.js'
|
|
11
|
+
import { parseJson } from './kysely-json.js'
|
|
12
|
+
import { ensurePikkuSchema } from './schema/index.js'
|
|
13
|
+
import { virtualUserSchema } from './schema/virtual-user.schema.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Records virtual-user runs in a `virtualUserRun` table.
|
|
17
|
+
*
|
|
18
|
+
* The store is the whole record of a run: `runVirtualUser` returns as soon as
|
|
19
|
+
* the row exists and the engine keeps going without it, so nothing else knows
|
|
20
|
+
* the run happened. That is why `fail()` exists as its own call — a run that
|
|
21
|
+
* crashed and a run that found nothing are different answers, and a row left at
|
|
22
|
+
* `running` is neither.
|
|
23
|
+
*
|
|
24
|
+
* Works with or without `SerializePlugin`, which not every project installs: the
|
|
25
|
+
* JSON columns are written as strings (which the plugin passes through) and read
|
|
26
|
+
* back through `parseJson` (which passes through what the plugin already
|
|
27
|
+
* parsed). Timestamps are written as ISO strings for the same reason — a bare
|
|
28
|
+
* SQLite driver cannot bind a `Date` at all.
|
|
29
|
+
*
|
|
30
|
+
* SECURITY: an `adversarial` run's findings are working exploits carrying live
|
|
31
|
+
* ids. This is a privileged store; the scaffolded reads are scope-gated, and
|
|
32
|
+
* exposing these rows more widely publishes your own exploits.
|
|
33
|
+
*/
|
|
34
|
+
export class KyselyVirtualUserRunStore implements VirtualUserRunStore {
|
|
35
|
+
private initialized = false
|
|
36
|
+
|
|
37
|
+
constructor(private db: Kysely<KyselyPikkuDB>) {}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Creates the table on first use, like the audit sink — the runtime does not
|
|
41
|
+
* need it, so it arrives with the feature that fills it rather than in every
|
|
42
|
+
* database.
|
|
43
|
+
*/
|
|
44
|
+
public async init(): Promise<void> {
|
|
45
|
+
if (this.initialized) return
|
|
46
|
+
await ensurePikkuSchema(this.db, virtualUserSchema)
|
|
47
|
+
this.initialized = true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async start(run: VirtualUserRunStart): Promise<string> {
|
|
51
|
+
await this.init()
|
|
52
|
+
const runId = randomUUID()
|
|
53
|
+
await this.db
|
|
54
|
+
.insertInto('virtualUserRun')
|
|
55
|
+
.values({
|
|
56
|
+
runId,
|
|
57
|
+
persona: run.persona,
|
|
58
|
+
disposition: run.disposition,
|
|
59
|
+
// Text on every engine (see the schema), so the seed round-trips
|
|
60
|
+
// through drivers that hand a BIGINT back as a string.
|
|
61
|
+
seed: String(run.seed),
|
|
62
|
+
status: 'running',
|
|
63
|
+
goals: JSON.stringify(run.goals ?? []),
|
|
64
|
+
memory: JSON.stringify(run.memory ?? {}),
|
|
65
|
+
findings: '[]',
|
|
66
|
+
startedBy: run.startedBy ?? null,
|
|
67
|
+
})
|
|
68
|
+
.execute()
|
|
69
|
+
return runId
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async complete(runId: string, outcome: VirtualUserRunOutcome): Promise<void> {
|
|
73
|
+
await this.init()
|
|
74
|
+
await this.db
|
|
75
|
+
.updateTable('virtualUserRun')
|
|
76
|
+
.set({
|
|
77
|
+
status: 'completed',
|
|
78
|
+
findings: JSON.stringify(outcome.findings),
|
|
79
|
+
tally: JSON.stringify(outcome.tally),
|
|
80
|
+
// Overwritten rather than merged: the engine's memory already carries
|
|
81
|
+
// what it was given, plus what it learned on the way.
|
|
82
|
+
memory: JSON.stringify(outcome.memory),
|
|
83
|
+
stoppedBy: outcome.stoppedBy,
|
|
84
|
+
finishedAt: new Date().toISOString(),
|
|
85
|
+
})
|
|
86
|
+
.where('runId', '=', runId)
|
|
87
|
+
.execute()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async fail(runId: string, error: string): Promise<void> {
|
|
91
|
+
await this.init()
|
|
92
|
+
await this.db
|
|
93
|
+
.updateTable('virtualUserRun')
|
|
94
|
+
.set({
|
|
95
|
+
status: 'failed',
|
|
96
|
+
error,
|
|
97
|
+
finishedAt: new Date().toISOString(),
|
|
98
|
+
})
|
|
99
|
+
.where('runId', '=', runId)
|
|
100
|
+
.execute()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async get(runId: string): Promise<VirtualUserRunRecord | null> {
|
|
104
|
+
await this.init()
|
|
105
|
+
const row = await this.db
|
|
106
|
+
.selectFrom('virtualUserRun')
|
|
107
|
+
.selectAll()
|
|
108
|
+
.where('runId', '=', runId)
|
|
109
|
+
.executeTakeFirst()
|
|
110
|
+
return row ? this.toRecord(row) : null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async list(options?: {
|
|
114
|
+
persona?: string
|
|
115
|
+
limit?: number
|
|
116
|
+
offset?: number
|
|
117
|
+
}): Promise<VirtualUserRunRecord[]> {
|
|
118
|
+
await this.init()
|
|
119
|
+
let query = this.db.selectFrom('virtualUserRun').selectAll()
|
|
120
|
+
if (options?.persona) {
|
|
121
|
+
query = query.where('persona', '=', options.persona)
|
|
122
|
+
}
|
|
123
|
+
const rows = await query
|
|
124
|
+
.orderBy('createdAt', 'desc')
|
|
125
|
+
.limit(options?.limit ?? 50)
|
|
126
|
+
.offset(options?.offset ?? 0)
|
|
127
|
+
.execute()
|
|
128
|
+
return rows.map((row) => this.toRecord(row))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private toRecord(row: Selectable<VirtualUserRunTable>): VirtualUserRunRecord {
|
|
132
|
+
return {
|
|
133
|
+
runId: row.runId,
|
|
134
|
+
persona: row.persona,
|
|
135
|
+
disposition: row.disposition as VirtualUserDisposition,
|
|
136
|
+
seed: Number(row.seed),
|
|
137
|
+
status: row.status,
|
|
138
|
+
goals: parseJson(row.goals) ?? [],
|
|
139
|
+
memory: parseJson(row.memory) ?? {},
|
|
140
|
+
findings: parseJson(row.findings) ?? [],
|
|
141
|
+
tally: row.tally ? (parseJson(row.tally) ?? null) : null,
|
|
142
|
+
stoppedBy: row.stoppedBy,
|
|
143
|
+
error: row.error,
|
|
144
|
+
startedBy: row.startedBy,
|
|
145
|
+
createdAt: new Date(row.createdAt),
|
|
146
|
+
finishedAt: row.finishedAt ? new Date(row.finishedAt) : null,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -600,6 +600,45 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
600
600
|
.execute()
|
|
601
601
|
}
|
|
602
602
|
|
|
603
|
+
protected async findStalledRunIds(
|
|
604
|
+
before: Date,
|
|
605
|
+
limit: number
|
|
606
|
+
): Promise<string[]> {
|
|
607
|
+
const rows = await this.db
|
|
608
|
+
.selectFrom('workflowRuns as r')
|
|
609
|
+
.select('r.workflowRunId')
|
|
610
|
+
.where('r.status', '=', 'running')
|
|
611
|
+
.where(({ not, exists, selectFrom }) =>
|
|
612
|
+
not(
|
|
613
|
+
exists(
|
|
614
|
+
selectFrom('workflowStep as s')
|
|
615
|
+
.select('s.workflowStepId')
|
|
616
|
+
.whereRef('s.workflowRunId', '=', 'r.workflowRunId')
|
|
617
|
+
.where('s.status', 'in', ['running', 'scheduled', 'suspended'])
|
|
618
|
+
)
|
|
619
|
+
)
|
|
620
|
+
)
|
|
621
|
+
// The run row only moves on a status change, so idleness has to hold for
|
|
622
|
+
// the steps too — expressed as "no step touched since `before`" rather
|
|
623
|
+
// than max(updated_at) so it stays one index-friendly NOT EXISTS.
|
|
624
|
+
.where('r.updatedAt', '<', before)
|
|
625
|
+
.where(({ not, exists, selectFrom }) =>
|
|
626
|
+
not(
|
|
627
|
+
exists(
|
|
628
|
+
selectFrom('workflowStep as s')
|
|
629
|
+
.select('s.workflowStepId')
|
|
630
|
+
.whereRef('s.workflowRunId', '=', 'r.workflowRunId')
|
|
631
|
+
.where('s.updatedAt', '>=', before)
|
|
632
|
+
)
|
|
633
|
+
)
|
|
634
|
+
)
|
|
635
|
+
.orderBy('r.updatedAt', 'asc')
|
|
636
|
+
.limit(limit)
|
|
637
|
+
.execute()
|
|
638
|
+
|
|
639
|
+
return rows.map((row) => row.workflowRunId)
|
|
640
|
+
}
|
|
641
|
+
|
|
603
642
|
async getRunState(runId: string): Promise<Record<string, unknown>> {
|
|
604
643
|
const row = await this.db
|
|
605
644
|
.selectFrom('workflowRuns')
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { PikkuSchema } from './pikku-schema.types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `audit` table {@link KyselyAuditService} writes to and reads back.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately not in `pikkuSchemas`: the runtime never needs this table, only
|
|
7
|
+
* a project that has wired a durable audit sink does, and creating it for
|
|
8
|
+
* everyone would put an empty table in every database. `KyselyAuditService.init()`
|
|
9
|
+
* applies it, so it arrives exactly when the sink that fills it does.
|
|
10
|
+
*
|
|
11
|
+
* Every column is text on every engine, matching the platform audit-queue
|
|
12
|
+
* consumer's row shape — a locally-run project and a deployed stage write rows
|
|
13
|
+
* the same reader can read. `occurredAt` is an ISO 8601 string rather than a
|
|
14
|
+
* timestamp for that reason: string ordering is chronological ordering, and the
|
|
15
|
+
* queue consumer has no shared type to agree on.
|
|
16
|
+
*/
|
|
17
|
+
export const auditSchema: PikkuSchema = {
|
|
18
|
+
name: 'audit',
|
|
19
|
+
statements: [
|
|
20
|
+
(db) =>
|
|
21
|
+
db.schema
|
|
22
|
+
.createTable('audit')
|
|
23
|
+
.addColumn('auditId', 'text', (col) => col.primaryKey())
|
|
24
|
+
.addColumn('occurredAt', 'text', (col) => col.notNull())
|
|
25
|
+
.addColumn('type', 'text', (col) => col.notNull())
|
|
26
|
+
.addColumn('source', 'text', (col) => col.defaultTo('auto').notNull())
|
|
27
|
+
.addColumn('outcome', 'text')
|
|
28
|
+
.addColumn('functionId', 'text')
|
|
29
|
+
.addColumn('wireType', 'text')
|
|
30
|
+
.addColumn('traceId', 'text')
|
|
31
|
+
.addColumn('transactionId', 'text')
|
|
32
|
+
.addColumn('queryId', 'text')
|
|
33
|
+
.addColumn('userId', 'text')
|
|
34
|
+
.addColumn('orgId', 'text')
|
|
35
|
+
.addColumn('pikkuUserId', 'text')
|
|
36
|
+
.addColumn('tables', 'text')
|
|
37
|
+
.addColumn('changedCols', 'text')
|
|
38
|
+
.addColumn('event', 'text')
|
|
39
|
+
.addColumn('old', 'text')
|
|
40
|
+
.addColumn('data', 'text'),
|
|
41
|
+
|
|
42
|
+
// The trail is only ever read newest-first, and the two filters the console
|
|
43
|
+
// offers are user and type.
|
|
44
|
+
(db) =>
|
|
45
|
+
db.schema
|
|
46
|
+
.createIndex('idx_audit_occurred_at')
|
|
47
|
+
.on('audit')
|
|
48
|
+
.column('occurredAt'),
|
|
49
|
+
|
|
50
|
+
(db) =>
|
|
51
|
+
db.schema.createIndex('idx_audit_user_id').on('audit').column('userId'),
|
|
52
|
+
|
|
53
|
+
(db) => db.schema.createIndex('idx_audit_type').on('audit').column('type'),
|
|
54
|
+
],
|
|
55
|
+
}
|
|
@@ -7,8 +7,9 @@ import { rawStatement, type PikkuSchema } from './pikku-schema.types.js'
|
|
|
7
7
|
* The uniqueness rule is an expression index — `user_id` is nullable and two
|
|
8
8
|
* rows with a null user must still collide on name — which the schema builder
|
|
9
9
|
* cannot express, so it stays raw. Raw SQL is not rewritten by `withSchema`,
|
|
10
|
-
* which is why the table
|
|
11
|
-
* path.
|
|
10
|
+
* which is why the table goes through `ctx.table` rather than being left to the
|
|
11
|
+
* search path. The index's own name stays unqualified: an index belongs to the
|
|
12
|
+
* schema of the table it is on, and postgres rejects one that says otherwise.
|
|
12
13
|
*/
|
|
13
14
|
export const credentialSchema: PikkuSchema = {
|
|
14
15
|
name: 'credential',
|
|
@@ -29,7 +30,8 @@ export const credentialSchema: PikkuSchema = {
|
|
|
29
30
|
),
|
|
30
31
|
|
|
31
32
|
rawStatement(
|
|
32
|
-
|
|
33
|
+
({ table }) =>
|
|
34
|
+
sql`CREATE UNIQUE INDEX credentials_name_user_id_unique ON ${table('credentials')} (name, COALESCE(user_id, ''))`
|
|
33
35
|
),
|
|
34
36
|
|
|
35
37
|
(db) =>
|