@pikku/kysely 0.13.20 → 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 +120 -0
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +1 -0
- package/dist/src/kysely-tables.d.ts +46 -0
- package/dist/src/kysely-virtual-user-run-store.d.ts +7 -19
- package/dist/src/kysely-virtual-user-run-store.js +69 -2
- 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/schema/index.d.ts +1 -0
- package/dist/src/schema/index.js +3 -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 +39 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/index.ts +3 -0
- package/src/kysely-tables.ts +48 -0
- package/src/kysely-virtual-user-run-store.test.ts +142 -1
- package/src/kysely-virtual-user-run-store.ts +90 -4
- package/src/kysely-virtual-user-schedule-store.test.ts +187 -0
- package/src/kysely-virtual-user-schedule-store.ts +187 -0
- package/src/kysely-workflow-service.test.ts +1 -1
- package/src/schema/index.ts +3 -0
- package/src/schema/virtual-user-schedule.schema.ts +48 -0
- package/src/schema/virtual-user.schema.ts +41 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,123 @@
|
|
|
1
|
+
## 0.13.21
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- cfd364a: Remove the last `@pikku/core/ecosystem` references and guard against new ones
|
|
6
|
+
|
|
7
|
+
`@pikku/kysely`'s workflow-service test still imported `StepState` from
|
|
8
|
+
`@pikku/core/ecosystem/workflow`, a subpath that no longer exists in
|
|
9
|
+
`@pikku/core`'s `exports`. Nothing caught it: the import is type-only, so tsx
|
|
10
|
+
erases it before it can fail at runtime, and the package tsconfig excludes
|
|
11
|
+
`**/*.test.ts`, so `yarn tsc` never saw it either. It now imports from
|
|
12
|
+
`@pikku/core/workflow`.
|
|
13
|
+
|
|
14
|
+
A new guard test in `@pikku/core` scans the repository for the dead specifier
|
|
15
|
+
and fails if one comes back, so the next stale import is a red test rather than
|
|
16
|
+
a silent `any`.
|
|
17
|
+
|
|
18
|
+
- 05e47cf: feat(virtual-user): keep the transcript a run already produced
|
|
19
|
+
|
|
20
|
+
The engine returns `intents` and `steps` on every run — what the user set out
|
|
21
|
+
to do, and every turn it took getting there — and `VirtualUserRunOutcome` kept
|
|
22
|
+
neither. The record held counts and findings, so the one question anybody
|
|
23
|
+
actually asks of a completed run ("what did it _do_?") had no answer anywhere,
|
|
24
|
+
even though the answer had been computed and thrown away a moment earlier.
|
|
25
|
+
|
|
26
|
+
`VirtualUserRunOutcome` now carries both, and `VirtualUserRunStore` gains a
|
|
27
|
+
`steps(runId, options?)` read. Intents ride on the run record: there are a
|
|
28
|
+
handful of them and every read of the run wants them. Steps get their own
|
|
29
|
+
`virtualUserRunStep` table, because a run at a 500-step budget carries more
|
|
30
|
+
transcript than every other column together and `list()` would pay for it on
|
|
31
|
+
every row.
|
|
32
|
+
|
|
33
|
+
Three things the kysely store had to get right, all of them driver differences
|
|
34
|
+
rather than design:
|
|
35
|
+
|
|
36
|
+
- steps are inserted in chunks of 50, because a bare sqlite driver binds at
|
|
37
|
+
most 999 variables per statement and ten columns times a 500-step budget is
|
|
38
|
+
five thousand — an un-chunked insert fails on long runs, which are the
|
|
39
|
+
interesting ones;
|
|
40
|
+
- `ok` is stored as 0 or 1, since a bare driver cannot bind a boolean at all
|
|
41
|
+
and `SerializePlugin` is not installed everywhere;
|
|
42
|
+
- `response` is stored JSON-encoded, because a truncated API response usually
|
|
43
|
+
starts with a brace and `SerializePlugin` would otherwise read it back as an
|
|
44
|
+
object rather than the string the engine saw.
|
|
45
|
+
|
|
46
|
+
Completing a run that does not exist no longer writes steps: there is no
|
|
47
|
+
foreign key to refuse them and nothing would ever read or reap them.
|
|
48
|
+
|
|
49
|
+
**This adds a table to the `virtualUser` schema**, and the runtime creates
|
|
50
|
+
nothing: a database that already has `virtualUserRun` gets the store's own
|
|
51
|
+
refusal at startup until `pikku db generate` writes the migration and
|
|
52
|
+
`pikku db migrate` applies it. Landing it now costs nothing, because
|
|
53
|
+
`scaffold.virtualUser` is not yet switched on anywhere.
|
|
54
|
+
|
|
55
|
+
- 05e47cf: feat(virtual-user): put each persona on its own clock
|
|
56
|
+
|
|
57
|
+
A budget says where one run stops. Nothing said how often a persona should use
|
|
58
|
+
the application, so in practice each one ran whenever somebody remembered — and
|
|
59
|
+
what actually tells you about a product is the same user coming back over a
|
|
60
|
+
fortnight.
|
|
61
|
+
|
|
62
|
+
Each persona now gets a row rather than a bigger budget. `virtualUserSchedule`
|
|
63
|
+
holds `enabled`, the disposition and goals to run with, an interval **range**,
|
|
64
|
+
and `nextRunAt`. `tickVirtualUserSchedules` acts on whichever rows are due:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
wireScheduler({
|
|
68
|
+
name: 'virtualUsers',
|
|
69
|
+
schedule: '0 * * * *',
|
|
70
|
+
func: tickVirtualUserSchedules,
|
|
71
|
+
})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The tick is generated and wired by nobody, deliberately. A scaffolded
|
|
75
|
+
`wireScheduler` would start spending an application's model budget the moment
|
|
76
|
+
somebody ran `pikku all`, on a host that may not run schedulers at all. Tick
|
|
77
|
+
resolution bounds how late a due persona is, never how often it runs.
|
|
78
|
+
|
|
79
|
+
Three things it does that are easy to leave out:
|
|
80
|
+
|
|
81
|
+
- The next due time is written **before** the run is dispatched, so a tick that
|
|
82
|
+
dies halfway cannot hand the same persona to the next one. A dispatch that
|
|
83
|
+
throws waits a full interval instead of retrying every minute for a week.
|
|
84
|
+
That write is a compare-and-set against the `nextRunAt` the tick read, so it
|
|
85
|
+
is also how a tick _wins_ the persona: two processes on the same cron see the
|
|
86
|
+
same due row, and only the one whose claim lands dispatches.
|
|
87
|
+
- A persona whose previous run is still `running` is skipped, not queued. Two
|
|
88
|
+
copies of the same user acting at once is a different test, and its findings
|
|
89
|
+
do not reproduce.
|
|
90
|
+
- A run still `running` after two hours is failed. Without that, one restart
|
|
91
|
+
mid-run blocks that persona's schedule permanently — which is where the
|
|
92
|
+
stranded-record cost of not using a queue finally gets paid.
|
|
93
|
+
|
|
94
|
+
Reschedule-on-completion was the other candidate and is worse in exactly one
|
|
95
|
+
way, fatally: a crash between finishing and scheduling ends the persona forever,
|
|
96
|
+
and the evidence is an absence.
|
|
97
|
+
|
|
98
|
+
New: `VirtualUserScheduleStore` in core (with the tick, `isDue` and `nextRunAt`
|
|
99
|
+
as pure functions), `KyselyVirtualUserScheduleStore` and its own schema —
|
|
100
|
+
its own rather than a third table in `virtualUserSchema`, and owned by its own
|
|
101
|
+
store, so a project that records runs and never wants them unattended carries no
|
|
102
|
+
cadence table. `scaffold.virtualUser` gains `setVirtualUserSchedule`,
|
|
103
|
+
`listVirtualUserSchedules` and the tick, behind a new `virtualUser:schedule`
|
|
104
|
+
scope: starting a run spends money once with a caller watching, while writing a
|
|
105
|
+
schedule spends it repeatedly with nobody there.
|
|
106
|
+
|
|
107
|
+
The console's Virtual Users screen gains a **Run now** button beside a persona's
|
|
108
|
+
run history, gated on `pikku:console:virtualUsers:run`. It dispatches the
|
|
109
|
+
project's own `runVirtualUser` rather than starting a run itself, so a run the
|
|
110
|
+
application would refuse — an acted-upon persona, a non-accountable disposition
|
|
111
|
+
in production — is still refused.
|
|
112
|
+
|
|
113
|
+
- Updated dependencies [3c0012c]
|
|
114
|
+
- Updated dependencies [05e47cf]
|
|
115
|
+
- Updated dependencies [cfd364a]
|
|
116
|
+
- Updated dependencies [05e47cf]
|
|
117
|
+
- Updated dependencies [05e47cf]
|
|
118
|
+
- Updated dependencies [05e47cf]
|
|
119
|
+
- @pikku/core@0.12.90
|
|
120
|
+
|
|
1
121
|
## 0.13.20
|
|
2
122
|
|
|
3
123
|
### Patch Changes
|
package/dist/src/index.d.ts
CHANGED
|
@@ -15,10 +15,11 @@ export { KyselyWebhookService } from './kysely-webhook-service.js';
|
|
|
15
15
|
export { createAuditedKysely, type CreateAuditedKyselyOptions, } from './create-audited-kysely.js';
|
|
16
16
|
export { KyselyAuditService } from './kysely-audit-service.js';
|
|
17
17
|
export { KyselyVirtualUserRunStore } from './kysely-virtual-user-run-store.js';
|
|
18
|
+
export { KyselyVirtualUserScheduleStore } from './kysely-virtual-user-schedule-store.js';
|
|
18
19
|
export { SerializePlugin, BaseSerializePlugin, type Serializer, type Deserializer, } from './serialize-plugin.js';
|
|
19
20
|
export { createCoercionPlugin, type ColumnKind, type CoercionMap, type CreateCoercionPluginOptions, } from './coercion-plugin.js';
|
|
20
21
|
export { agentSchema, auditSchema, channelSchema, credentialSchema, deploymentSchema, scopeSchema, secretSchema, sessionSchema, virtualUserSchema, webhookSchema, workflowSchema, pikkuSchemas, requiredPikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, requirePikkuSchema, resolveRequirements, type PikkuSchema, type RequiredTypes, type UnmetRequirement, type SchemaStatementFactory, } from './schema/index.js';
|
|
21
22
|
export type { KyselyPikkuDB } from './kysely-tables.js';
|
|
22
23
|
export type { WorkflowRunService } from '@pikku/core/workflow';
|
|
23
24
|
export type { AgentRunService, AgentRunRow } from '@pikku/core/agent';
|
|
24
|
-
export type { VirtualUserRunStore, VirtualUserRunRecord, } from '@pikku/core/virtual-user';
|
|
25
|
+
export type { VirtualUserRunStore, VirtualUserRunRecord, VirtualUserScheduleStore, VirtualUserScheduleRecord, } from '@pikku/core/virtual-user';
|
package/dist/src/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export { KyselyWebhookService } from './kysely-webhook-service.js';
|
|
|
15
15
|
export { createAuditedKysely, } from './create-audited-kysely.js';
|
|
16
16
|
export { KyselyAuditService } from './kysely-audit-service.js';
|
|
17
17
|
export { KyselyVirtualUserRunStore } from './kysely-virtual-user-run-store.js';
|
|
18
|
+
export { KyselyVirtualUserScheduleStore } from './kysely-virtual-user-schedule-store.js';
|
|
18
19
|
export { SerializePlugin, BaseSerializePlugin, } from './serialize-plugin.js';
|
|
19
20
|
export { createCoercionPlugin, } from './coercion-plugin.js';
|
|
20
21
|
export { agentSchema, auditSchema, channelSchema, credentialSchema, deploymentSchema, scopeSchema, secretSchema, sessionSchema, virtualUserSchema, webhookSchema, workflowSchema, pikkuSchemas, requiredPikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, requirePikkuSchema, resolveRequirements, } from './schema/index.js';
|
|
@@ -277,6 +277,7 @@ export interface VirtualUserRunTable {
|
|
|
277
277
|
goals: Generated<string>;
|
|
278
278
|
memory: Generated<string>;
|
|
279
279
|
findings: Generated<string>;
|
|
280
|
+
intents: Generated<string>;
|
|
280
281
|
tally: string | null;
|
|
281
282
|
stoppedBy: string | null;
|
|
282
283
|
error: string | null;
|
|
@@ -316,6 +317,49 @@ export interface AuditTable {
|
|
|
316
317
|
old: string | null;
|
|
317
318
|
data: string | null;
|
|
318
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* One turn of a run's transcript.
|
|
322
|
+
*
|
|
323
|
+
* Its own table rather than a column on {@link VirtualUserRunTable}: a run at a
|
|
324
|
+
* 500-step budget carries more transcript than every other column together, and
|
|
325
|
+
* the run list would pay for it on every row.
|
|
326
|
+
*/
|
|
327
|
+
export interface VirtualUserRunStepTable {
|
|
328
|
+
runId: string;
|
|
329
|
+
/** The engine's step number. `stepIndex` because `index` is reserved in mysql. */
|
|
330
|
+
stepIndex: number;
|
|
331
|
+
intentId: string | null;
|
|
332
|
+
/** JSON: the action the engine scheduled, or the `invalid` shape. */
|
|
333
|
+
action: string;
|
|
334
|
+
status: number | null;
|
|
335
|
+
/** 0 or 1: not every driver in this package can bind a boolean. */
|
|
336
|
+
ok: number | null;
|
|
337
|
+
/** JSON-encoded, so a brace-leading response is not read back as an object. */
|
|
338
|
+
response: string | null;
|
|
339
|
+
/** JSON array, null when the turn produced no finding. */
|
|
340
|
+
findingKinds: string | null;
|
|
341
|
+
tokensIn: Generated<number>;
|
|
342
|
+
tokensOut: Generated<number>;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* One persona's cadence. Nullable timestamps and 0/1 flags for the same driver
|
|
346
|
+
* reasons as {@link VirtualUserRunTable}.
|
|
347
|
+
*/
|
|
348
|
+
export interface VirtualUserScheduleTable {
|
|
349
|
+
persona: string;
|
|
350
|
+
/** 0 or 1: not every driver in this package can bind a boolean. */
|
|
351
|
+
enabled: Generated<number>;
|
|
352
|
+
disposition: string;
|
|
353
|
+
goals: Generated<string>;
|
|
354
|
+
/** JSON, or null for the engine's own default budget. */
|
|
355
|
+
budget: string | null;
|
|
356
|
+
/** BIGINT, so a driver may hand either back. */
|
|
357
|
+
minIntervalMs: string | number | bigint;
|
|
358
|
+
maxIntervalMs: string | number | bigint;
|
|
359
|
+
nextRunAt: Date | string;
|
|
360
|
+
lastRunId: string | null;
|
|
361
|
+
lastRunAt: Date | string | null;
|
|
362
|
+
}
|
|
319
363
|
export interface KyselyPikkuDB {
|
|
320
364
|
pikkuScopes: PikkuScopesTable;
|
|
321
365
|
pikkuRoles: PikkuRolesTable;
|
|
@@ -346,5 +390,7 @@ export interface KyselyPikkuDB {
|
|
|
346
390
|
webhookDelivery: WebhookDeliveryTable;
|
|
347
391
|
webhookDeliveryAttempt: WebhookDeliveryAttemptTable;
|
|
348
392
|
virtualUserRun: VirtualUserRunTable;
|
|
393
|
+
virtualUserRunStep: VirtualUserRunStepTable;
|
|
394
|
+
virtualUserSchedule: VirtualUserScheduleTable;
|
|
349
395
|
audit: AuditTable;
|
|
350
396
|
}
|
|
@@ -1,25 +1,7 @@
|
|
|
1
1
|
import type { Kysely } from 'kysely';
|
|
2
|
+
import type { StepRecord } from '@pikku/core/virtual-user';
|
|
2
3
|
import type { VirtualUserRunOutcome, VirtualUserRunRecord, VirtualUserRunStart, VirtualUserRunStore } from '@pikku/core/virtual-user';
|
|
3
4
|
import type { KyselyPikkuDB } from './kysely-tables.js';
|
|
4
|
-
/**
|
|
5
|
-
* Records virtual-user runs in a `virtualUserRun` table.
|
|
6
|
-
*
|
|
7
|
-
* The store is the whole record of a run: `runVirtualUser` returns as soon as
|
|
8
|
-
* the row exists and the engine keeps going without it, so nothing else knows
|
|
9
|
-
* the run happened. That is why `fail()` exists as its own call — a run that
|
|
10
|
-
* crashed and a run that found nothing are different answers, and a row left at
|
|
11
|
-
* `running` is neither.
|
|
12
|
-
*
|
|
13
|
-
* Works with or without `SerializePlugin`, which not every project installs: the
|
|
14
|
-
* JSON columns are written as strings (which the plugin passes through) and read
|
|
15
|
-
* back through `parseJson` (which passes through what the plugin already
|
|
16
|
-
* parsed). Timestamps are written as ISO strings for the same reason — a bare
|
|
17
|
-
* SQLite driver cannot bind a `Date` at all.
|
|
18
|
-
*
|
|
19
|
-
* SECURITY: an `adversarial` run's findings are working exploits carrying live
|
|
20
|
-
* ids. This is a privileged store; the scaffolded reads are scope-gated, and
|
|
21
|
-
* exposing these rows more widely publishes your own exploits.
|
|
22
|
-
*/
|
|
23
5
|
export declare class KyselyVirtualUserRunStore implements VirtualUserRunStore {
|
|
24
6
|
private db;
|
|
25
7
|
private initialized;
|
|
@@ -39,5 +21,11 @@ export declare class KyselyVirtualUserRunStore implements VirtualUserRunStore {
|
|
|
39
21
|
limit?: number;
|
|
40
22
|
offset?: number;
|
|
41
23
|
}): Promise<VirtualUserRunRecord[]>;
|
|
24
|
+
steps(runId: string, options?: {
|
|
25
|
+
limit?: number;
|
|
26
|
+
offset?: number;
|
|
27
|
+
}): Promise<StepRecord[]>;
|
|
28
|
+
private writeSteps;
|
|
29
|
+
private toStep;
|
|
42
30
|
private toRecord;
|
|
43
31
|
}
|
|
@@ -21,6 +21,15 @@ import { virtualUserSchema } from './schema/virtual-user.schema.js';
|
|
|
21
21
|
* ids. This is a privileged store; the scaffolded reads are scope-gated, and
|
|
22
22
|
* exposing these rows more widely publishes your own exploits.
|
|
23
23
|
*/
|
|
24
|
+
/**
|
|
25
|
+
* How many step rows go in one insert.
|
|
26
|
+
*
|
|
27
|
+
* Not a performance dial. A bare SQLite driver binds at most 999 variables per
|
|
28
|
+
* statement, and ten columns times a 500-step budget is five thousand — so an
|
|
29
|
+
* un-chunked insert does not fail on a long run, it fails on the *interesting*
|
|
30
|
+
* long run, which is the one nobody wants to lose.
|
|
31
|
+
*/
|
|
32
|
+
const STEP_INSERT_CHUNK = 50;
|
|
24
33
|
export class KyselyVirtualUserRunStore {
|
|
25
34
|
db;
|
|
26
35
|
initialized = false;
|
|
@@ -61,7 +70,7 @@ export class KyselyVirtualUserRunStore {
|
|
|
61
70
|
}
|
|
62
71
|
async complete(runId, outcome) {
|
|
63
72
|
await this.init();
|
|
64
|
-
await this.db
|
|
73
|
+
const updated = await this.db
|
|
65
74
|
.updateTable('virtualUserRun')
|
|
66
75
|
.set({
|
|
67
76
|
status: 'completed',
|
|
@@ -70,11 +79,17 @@ export class KyselyVirtualUserRunStore {
|
|
|
70
79
|
// Overwritten rather than merged: the engine's memory already carries
|
|
71
80
|
// what it was given, plus what it learned on the way.
|
|
72
81
|
memory: JSON.stringify(outcome.memory),
|
|
82
|
+
intents: JSON.stringify(outcome.intents),
|
|
73
83
|
stoppedBy: outcome.stoppedBy,
|
|
74
84
|
finishedAt: new Date().toISOString(),
|
|
75
85
|
})
|
|
76
86
|
.where('runId', '=', runId)
|
|
77
|
-
.
|
|
87
|
+
.executeTakeFirst();
|
|
88
|
+
// Nothing owns steps written against a run that is not there, and nothing
|
|
89
|
+
// would ever read or reap them — there is no foreign key to refuse them.
|
|
90
|
+
if (updated.numUpdatedRows === 0n)
|
|
91
|
+
return;
|
|
92
|
+
await this.writeSteps(runId, outcome.steps);
|
|
78
93
|
}
|
|
79
94
|
async fail(runId, error) {
|
|
80
95
|
await this.init();
|
|
@@ -110,6 +125,57 @@ export class KyselyVirtualUserRunStore {
|
|
|
110
125
|
.execute();
|
|
111
126
|
return rows.map((row) => this.toRecord(row));
|
|
112
127
|
}
|
|
128
|
+
async steps(runId, options) {
|
|
129
|
+
await this.init();
|
|
130
|
+
const rows = await this.db
|
|
131
|
+
.selectFrom('virtualUserRunStep')
|
|
132
|
+
.selectAll()
|
|
133
|
+
.where('runId', '=', runId)
|
|
134
|
+
.orderBy('stepIndex', 'asc')
|
|
135
|
+
.limit(options?.limit ?? 1000)
|
|
136
|
+
.offset(options?.offset ?? 0)
|
|
137
|
+
.execute();
|
|
138
|
+
return rows.map((row) => this.toStep(row));
|
|
139
|
+
}
|
|
140
|
+
async writeSteps(runId, steps) {
|
|
141
|
+
for (let at = 0; at < steps.length; at += STEP_INSERT_CHUNK) {
|
|
142
|
+
const chunk = steps.slice(at, at + STEP_INSERT_CHUNK);
|
|
143
|
+
await this.db
|
|
144
|
+
.insertInto('virtualUserRunStep')
|
|
145
|
+
.values(chunk.map((step) => ({
|
|
146
|
+
runId,
|
|
147
|
+
stepIndex: step.index,
|
|
148
|
+
intentId: step.intentId ?? null,
|
|
149
|
+
action: JSON.stringify(step.action),
|
|
150
|
+
status: step.status ?? null,
|
|
151
|
+
ok: step.ok === undefined ? null : step.ok ? 1 : 0,
|
|
152
|
+
response: step.response === undefined
|
|
153
|
+
? null
|
|
154
|
+
: JSON.stringify(step.response),
|
|
155
|
+
findingKinds: step.findingKinds
|
|
156
|
+
? JSON.stringify(step.findingKinds)
|
|
157
|
+
: null,
|
|
158
|
+
tokensIn: step.tokensIn,
|
|
159
|
+
tokensOut: step.tokensOut,
|
|
160
|
+
})))
|
|
161
|
+
.execute();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
toStep(row) {
|
|
165
|
+
return {
|
|
166
|
+
index: Number(row.stepIndex),
|
|
167
|
+
...(row.intentId ? { intentId: row.intentId } : {}),
|
|
168
|
+
action: parseJson(row.action),
|
|
169
|
+
...(row.status === null ? {} : { status: Number(row.status) }),
|
|
170
|
+
...(row.ok === null ? {} : { ok: Boolean(row.ok) }),
|
|
171
|
+
...(row.response === null ? {} : { response: parseJson(row.response) }),
|
|
172
|
+
...(row.findingKinds
|
|
173
|
+
? { findingKinds: parseJson(row.findingKinds) }
|
|
174
|
+
: {}),
|
|
175
|
+
tokensIn: Number(row.tokensIn),
|
|
176
|
+
tokensOut: Number(row.tokensOut),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
113
179
|
toRecord(row) {
|
|
114
180
|
return {
|
|
115
181
|
runId: row.runId,
|
|
@@ -120,6 +186,7 @@ export class KyselyVirtualUserRunStore {
|
|
|
120
186
|
goals: parseJson(row.goals) ?? [],
|
|
121
187
|
memory: parseJson(row.memory) ?? {},
|
|
122
188
|
findings: parseJson(row.findings) ?? [],
|
|
189
|
+
intents: parseJson(row.intents) ?? [],
|
|
123
190
|
tally: row.tally ? (parseJson(row.tally) ?? null) : null,
|
|
124
191
|
stoppedBy: row.stoppedBy,
|
|
125
192
|
error: row.error,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Kysely } from 'kysely';
|
|
2
|
+
import type { VirtualUserScheduleInput, VirtualUserScheduleRecord, VirtualUserScheduleStore } from '@pikku/core/virtual-user';
|
|
3
|
+
import type { KyselyPikkuDB } from './kysely-tables.js';
|
|
4
|
+
/**
|
|
5
|
+
* Keeps each persona's cadence in a `virtualUserSchedule` table.
|
|
6
|
+
*
|
|
7
|
+
* Wiring this is what turns virtual users from something a person triggers into
|
|
8
|
+
* something that happens: without it, {@link KyselyVirtualUserRunStore} still
|
|
9
|
+
* records every run, and every run still had someone behind it. Two services
|
|
10
|
+
* rather than one so that an app can want the history without wanting the bill.
|
|
11
|
+
*
|
|
12
|
+
* Reads and writes the same way the run store does — JSON as text, timestamps
|
|
13
|
+
* as ISO strings, flags as 0/1 — so a project on SQLite and one on Postgres
|
|
14
|
+
* hold the same bytes and `SerializePlugin` is optional on both.
|
|
15
|
+
*/
|
|
16
|
+
export declare class KyselyVirtualUserScheduleStore implements VirtualUserScheduleStore {
|
|
17
|
+
private db;
|
|
18
|
+
private initialized;
|
|
19
|
+
constructor(db: Kysely<KyselyPikkuDB>);
|
|
20
|
+
init(): Promise<void>;
|
|
21
|
+
set(schedule: VirtualUserScheduleInput): Promise<VirtualUserScheduleRecord>;
|
|
22
|
+
get(persona: string): Promise<VirtualUserScheduleRecord | null>;
|
|
23
|
+
list(): Promise<VirtualUserScheduleRecord[]>;
|
|
24
|
+
due(now: Date): Promise<VirtualUserScheduleRecord[]>;
|
|
25
|
+
claim(persona: string, claim: {
|
|
26
|
+
from: Date;
|
|
27
|
+
nextRunAt: Date;
|
|
28
|
+
runId: string | null;
|
|
29
|
+
at: Date;
|
|
30
|
+
}): Promise<boolean>;
|
|
31
|
+
remove(persona: string): Promise<void>;
|
|
32
|
+
private toRecord;
|
|
33
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { DEFAULT_MAX_INTERVAL_MS, DEFAULT_MIN_INTERVAL_MS, } from '@pikku/core/virtual-user';
|
|
2
|
+
import { parseJson } from './kysely-json.js';
|
|
3
|
+
import { requirePikkuSchema } from './schema/index.js';
|
|
4
|
+
import { virtualUserScheduleSchema } from './schema/virtual-user-schedule.schema.js';
|
|
5
|
+
/**
|
|
6
|
+
* Keeps each persona's cadence in a `virtualUserSchedule` table.
|
|
7
|
+
*
|
|
8
|
+
* Wiring this is what turns virtual users from something a person triggers into
|
|
9
|
+
* something that happens: without it, {@link KyselyVirtualUserRunStore} still
|
|
10
|
+
* records every run, and every run still had someone behind it. Two services
|
|
11
|
+
* rather than one so that an app can want the history without wanting the bill.
|
|
12
|
+
*
|
|
13
|
+
* Reads and writes the same way the run store does — JSON as text, timestamps
|
|
14
|
+
* as ISO strings, flags as 0/1 — so a project on SQLite and one on Postgres
|
|
15
|
+
* hold the same bytes and `SerializePlugin` is optional on both.
|
|
16
|
+
*/
|
|
17
|
+
export class KyselyVirtualUserScheduleStore {
|
|
18
|
+
db;
|
|
19
|
+
initialized = false;
|
|
20
|
+
constructor(db) {
|
|
21
|
+
this.db = db;
|
|
22
|
+
}
|
|
23
|
+
async init() {
|
|
24
|
+
if (this.initialized)
|
|
25
|
+
return;
|
|
26
|
+
await requirePikkuSchema(this.db, virtualUserScheduleSchema);
|
|
27
|
+
this.initialized = true;
|
|
28
|
+
}
|
|
29
|
+
async set(schedule) {
|
|
30
|
+
await this.init();
|
|
31
|
+
// Read-then-write rather than a partial upsert: a schedule is configuration
|
|
32
|
+
// written by a person, and the alternative is two dialects of "leave the
|
|
33
|
+
// columns I did not mention alone".
|
|
34
|
+
const existing = await this.get(schedule.persona);
|
|
35
|
+
const merged = {
|
|
36
|
+
persona: schedule.persona,
|
|
37
|
+
enabled: schedule.enabled ?? existing?.enabled ?? false,
|
|
38
|
+
disposition: schedule.disposition ?? existing?.disposition ?? 'realistic',
|
|
39
|
+
goals: [...(schedule.goals ?? existing?.goals ?? [])],
|
|
40
|
+
budget: schedule.budget === undefined
|
|
41
|
+
? (existing?.budget ?? null)
|
|
42
|
+
: schedule.budget,
|
|
43
|
+
minIntervalMs: schedule.minIntervalMs ??
|
|
44
|
+
existing?.minIntervalMs ??
|
|
45
|
+
DEFAULT_MIN_INTERVAL_MS,
|
|
46
|
+
maxIntervalMs: schedule.maxIntervalMs ??
|
|
47
|
+
existing?.maxIntervalMs ??
|
|
48
|
+
DEFAULT_MAX_INTERVAL_MS,
|
|
49
|
+
// A new schedule is due immediately. Enabling one is a deliberate act, and
|
|
50
|
+
// waiting a random half-day to find out whether it works is not a feature.
|
|
51
|
+
nextRunAt: schedule.nextRunAt ?? existing?.nextRunAt ?? new Date(),
|
|
52
|
+
lastRunId: existing?.lastRunId ?? null,
|
|
53
|
+
lastRunAt: existing?.lastRunAt ?? null,
|
|
54
|
+
};
|
|
55
|
+
const values = {
|
|
56
|
+
persona: merged.persona,
|
|
57
|
+
enabled: merged.enabled ? 1 : 0,
|
|
58
|
+
disposition: merged.disposition,
|
|
59
|
+
goals: JSON.stringify(merged.goals),
|
|
60
|
+
budget: merged.budget ? JSON.stringify(merged.budget) : null,
|
|
61
|
+
minIntervalMs: String(merged.minIntervalMs),
|
|
62
|
+
maxIntervalMs: String(merged.maxIntervalMs),
|
|
63
|
+
nextRunAt: merged.nextRunAt.toISOString(),
|
|
64
|
+
};
|
|
65
|
+
await this.db
|
|
66
|
+
.insertInto('virtualUserSchedule')
|
|
67
|
+
.values(values)
|
|
68
|
+
.onConflict((oc) => oc.column('persona').doUpdateSet({
|
|
69
|
+
enabled: values.enabled,
|
|
70
|
+
disposition: values.disposition,
|
|
71
|
+
goals: values.goals,
|
|
72
|
+
budget: values.budget,
|
|
73
|
+
minIntervalMs: values.minIntervalMs,
|
|
74
|
+
maxIntervalMs: values.maxIntervalMs,
|
|
75
|
+
nextRunAt: values.nextRunAt,
|
|
76
|
+
}))
|
|
77
|
+
.execute();
|
|
78
|
+
return merged;
|
|
79
|
+
}
|
|
80
|
+
async get(persona) {
|
|
81
|
+
await this.init();
|
|
82
|
+
const row = await this.db
|
|
83
|
+
.selectFrom('virtualUserSchedule')
|
|
84
|
+
.selectAll()
|
|
85
|
+
.where('persona', '=', persona)
|
|
86
|
+
.executeTakeFirst();
|
|
87
|
+
return row ? this.toRecord(row) : null;
|
|
88
|
+
}
|
|
89
|
+
async list() {
|
|
90
|
+
await this.init();
|
|
91
|
+
const rows = await this.db
|
|
92
|
+
.selectFrom('virtualUserSchedule')
|
|
93
|
+
.selectAll()
|
|
94
|
+
.orderBy('persona', 'asc')
|
|
95
|
+
.execute();
|
|
96
|
+
return rows.map((row) => this.toRecord(row));
|
|
97
|
+
}
|
|
98
|
+
async due(now) {
|
|
99
|
+
await this.init();
|
|
100
|
+
const rows = await this.db
|
|
101
|
+
.selectFrom('virtualUserSchedule')
|
|
102
|
+
.selectAll()
|
|
103
|
+
.where('enabled', '=', 1)
|
|
104
|
+
.where('nextRunAt', '<=', now.toISOString())
|
|
105
|
+
.orderBy('nextRunAt', 'asc')
|
|
106
|
+
.execute();
|
|
107
|
+
return rows.map((row) => this.toRecord(row));
|
|
108
|
+
}
|
|
109
|
+
async claim(persona, claim) {
|
|
110
|
+
await this.init();
|
|
111
|
+
const result = await this.db
|
|
112
|
+
.updateTable('virtualUserSchedule')
|
|
113
|
+
.set({
|
|
114
|
+
nextRunAt: claim.nextRunAt.toISOString(),
|
|
115
|
+
// A claim with no run id is the one written before dispatch, and must
|
|
116
|
+
// not blank out which run this persona last managed to start.
|
|
117
|
+
...(claim.runId
|
|
118
|
+
? { lastRunId: claim.runId, lastRunAt: claim.at.toISOString() }
|
|
119
|
+
: {}),
|
|
120
|
+
})
|
|
121
|
+
.where('persona', '=', persona)
|
|
122
|
+
// The compare-and-set: whoever writes first moves `nextRunAt`, and every
|
|
123
|
+
// other tick holding the value they read matches nothing.
|
|
124
|
+
.where('nextRunAt', '=', claim.from.toISOString())
|
|
125
|
+
.executeTakeFirst();
|
|
126
|
+
return Number(result.numUpdatedRows ?? 0) > 0;
|
|
127
|
+
}
|
|
128
|
+
async remove(persona) {
|
|
129
|
+
await this.init();
|
|
130
|
+
await this.db
|
|
131
|
+
.deleteFrom('virtualUserSchedule')
|
|
132
|
+
.where('persona', '=', persona)
|
|
133
|
+
.execute();
|
|
134
|
+
}
|
|
135
|
+
toRecord(row) {
|
|
136
|
+
return {
|
|
137
|
+
persona: row.persona,
|
|
138
|
+
enabled: Boolean(row.enabled),
|
|
139
|
+
disposition: row.disposition,
|
|
140
|
+
goals: parseJson(row.goals) ?? [],
|
|
141
|
+
budget: row.budget
|
|
142
|
+
? (parseJson(row.budget) ?? null)
|
|
143
|
+
: null,
|
|
144
|
+
minIntervalMs: Number(row.minIntervalMs),
|
|
145
|
+
maxIntervalMs: Number(row.maxIntervalMs),
|
|
146
|
+
nextRunAt: new Date(row.nextRunAt),
|
|
147
|
+
lastRunId: row.lastRunId,
|
|
148
|
+
lastRunAt: row.lastRunAt ? new Date(row.lastRunAt) : null,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -26,6 +26,7 @@ export { scopeSchema } from './scope.schema.js';
|
|
|
26
26
|
export { secretSchema } from './secret.schema.js';
|
|
27
27
|
export { sessionSchema } from './session.schema.js';
|
|
28
28
|
export { virtualUserSchema } from './virtual-user.schema.js';
|
|
29
|
+
export { virtualUserScheduleSchema } from './virtual-user-schedule.schema.js';
|
|
29
30
|
export { webhookSchema } from './webhook.schema.js';
|
|
30
31
|
export { workflowSchema } from './workflow.schema.js';
|
|
31
32
|
/**
|
package/dist/src/schema/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { sessionSchema } from './session.schema.js';
|
|
|
11
11
|
import { webhookSchema } from './webhook.schema.js';
|
|
12
12
|
import { workflowSchema } from './workflow.schema.js';
|
|
13
13
|
import { virtualUserSchema } from './virtual-user.schema.js';
|
|
14
|
+
import { virtualUserScheduleSchema } from './virtual-user-schedule.schema.js';
|
|
14
15
|
export { rawStatement, requiredType, schemaContext, unqualifiedContext, } from './pikku-schema.types.js';
|
|
15
16
|
/**
|
|
16
17
|
* Every table any part of the pikku runtime can need, in dependency order.
|
|
@@ -38,6 +39,7 @@ export const pikkuSchemas = [
|
|
|
38
39
|
scopeSchema,
|
|
39
40
|
auditSchema,
|
|
40
41
|
virtualUserSchema,
|
|
42
|
+
virtualUserScheduleSchema,
|
|
41
43
|
];
|
|
42
44
|
export { agentSchema } from './agent.schema.js';
|
|
43
45
|
export { auditSchema } from './audit.schema.js';
|
|
@@ -48,6 +50,7 @@ export { scopeSchema } from './scope.schema.js';
|
|
|
48
50
|
export { secretSchema } from './secret.schema.js';
|
|
49
51
|
export { sessionSchema } from './session.schema.js';
|
|
50
52
|
export { virtualUserSchema } from './virtual-user.schema.js';
|
|
53
|
+
export { virtualUserScheduleSchema } from './virtual-user-schedule.schema.js';
|
|
51
54
|
export { webhookSchema } from './webhook.schema.js';
|
|
52
55
|
export { workflowSchema } from './workflow.schema.js';
|
|
53
56
|
/**
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PikkuSchema } from './pikku-schema.types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The `virtualUserSchedule` table {@link KyselyVirtualUserScheduleStore} writes
|
|
4
|
+
* to: one row per persona that keeps using the app on its own.
|
|
5
|
+
*
|
|
6
|
+
* Its own schema rather than a third table in {@link virtualUserSchema}, and
|
|
7
|
+
* owned by its own store: recording what a persona did and deciding that it
|
|
8
|
+
* keeps going on its own are separate opt-ins, and a project that only wants
|
|
9
|
+
* the first should not be handed a cadence table it never writes to.
|
|
10
|
+
*
|
|
11
|
+
* `persona` is the primary key: a persona has one cadence, and a second row for
|
|
12
|
+
* the same one would mean two copies of the same user acting at once — not a
|
|
13
|
+
* heavier test, a different and unreproducible one.
|
|
14
|
+
*/
|
|
15
|
+
export declare const virtualUserScheduleSchema: PikkuSchema;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `virtualUserSchedule` table {@link KyselyVirtualUserScheduleStore} writes
|
|
3
|
+
* to: one row per persona that keeps using the app on its own.
|
|
4
|
+
*
|
|
5
|
+
* Its own schema rather than a third table in {@link virtualUserSchema}, and
|
|
6
|
+
* owned by its own store: recording what a persona did and deciding that it
|
|
7
|
+
* keeps going on its own are separate opt-ins, and a project that only wants
|
|
8
|
+
* the first should not be handed a cadence table it never writes to.
|
|
9
|
+
*
|
|
10
|
+
* `persona` is the primary key: a persona has one cadence, and a second row for
|
|
11
|
+
* the same one would mean two copies of the same user acting at once — not a
|
|
12
|
+
* heavier test, a different and unreproducible one.
|
|
13
|
+
*/
|
|
14
|
+
export const virtualUserScheduleSchema = {
|
|
15
|
+
name: 'virtual-user-schedule',
|
|
16
|
+
ownedBy: ['virtualUserScheduleStore'],
|
|
17
|
+
statements: [
|
|
18
|
+
(db) => db.schema
|
|
19
|
+
.createTable('virtualUserSchedule')
|
|
20
|
+
.addColumn('persona', 'varchar(255)', (col) => col.primaryKey())
|
|
21
|
+
// 0 or 1 rather than a boolean, matching the run tables: a bare sqlite
|
|
22
|
+
// driver cannot bind one, and `SerializePlugin` is not installed
|
|
23
|
+
// everywhere. Off by default, so creating the table spends nothing.
|
|
24
|
+
.addColumn('enabled', 'integer', (col) => col.defaultTo(0).notNull())
|
|
25
|
+
.addColumn('disposition', 'varchar(50)', (col) => col.notNull())
|
|
26
|
+
.addColumn('goals', 'text', (col) => col.defaultTo('[]').notNull())
|
|
27
|
+
.addColumn('budget', 'text')
|
|
28
|
+
// The gap to the next run is drawn between these rather than fixed, so
|
|
29
|
+
// the persona does not keep an appointment.
|
|
30
|
+
.addColumn('minIntervalMs', 'bigint', (col) => col.notNull())
|
|
31
|
+
.addColumn('maxIntervalMs', 'bigint', (col) => col.notNull())
|
|
32
|
+
// The entire schedule. Written before a run is dispatched, so a tick
|
|
33
|
+
// that dies halfway cannot leave the row due for the next one.
|
|
34
|
+
.addColumn('nextRunAt', 'timestamp', (col) => col.notNull())
|
|
35
|
+
.addColumn('lastRunId', 'varchar(36)')
|
|
36
|
+
.addColumn('lastRunAt', 'timestamp'),
|
|
37
|
+
// The only read that matters at speed: which rows are due, on every tick.
|
|
38
|
+
(db) => db.schema
|
|
39
|
+
.createIndex('idx_virtual_user_schedule_due')
|
|
40
|
+
.on('virtualUserSchedule')
|
|
41
|
+
.columns(['enabled', 'nextRunAt']),
|
|
42
|
+
],
|
|
43
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { PikkuSchema } from './pikku-schema.types.js';
|
|
2
2
|
/**
|
|
3
|
-
* The `virtualUserRun`
|
|
3
|
+
* The `virtualUserRun` and `virtualUserRunStep` tables
|
|
4
|
+
* {@link KyselyVirtualUserRunStore} writes to.
|
|
4
5
|
*
|
|
5
6
|
* Deliberately not in `pikkuSchemas`: the runtime never needs this table, only
|
|
6
7
|
* a project that runs virtual users does, and creating it for everyone would
|