@pikku/kysely 0.13.20 → 0.13.22

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 CHANGED
@@ -1,3 +1,147 @@
1
+ ## 0.13.22
2
+
3
+ ### Patch Changes
4
+
5
+ - 4450b2a: Name the missing key when a secret is not found.
6
+
7
+ Every `SecretService` threw a bare `Requested secret not found`. In a deployed
8
+ runtime the stack is minified, so the message was the only evidence there was —
9
+ and it identified neither the key nor the service. Each implementation now names
10
+ the key it looked for; the better-auth middlewares that skip on an absent secret
11
+ match the prefix through one shared predicate instead of the whole string.
12
+
13
+ - Updated dependencies [4058c3a]
14
+ - Updated dependencies [4058c3a]
15
+ - Updated dependencies [4058c3a]
16
+ - Updated dependencies [4058c3a]
17
+ - Updated dependencies [4058c3a]
18
+ - Updated dependencies [4058c3a]
19
+ - Updated dependencies [114c079]
20
+ - Updated dependencies [4450b2a]
21
+ - Updated dependencies [4058c3a]
22
+ - Updated dependencies [4058c3a]
23
+ - @pikku/core@0.12.93
24
+
25
+ ## 0.13.21
26
+
27
+ ### Patch Changes
28
+
29
+ - cfd364a: Remove the last `@pikku/core/ecosystem` references and guard against new ones
30
+
31
+ `@pikku/kysely`'s workflow-service test still imported `StepState` from
32
+ `@pikku/core/ecosystem/workflow`, a subpath that no longer exists in
33
+ `@pikku/core`'s `exports`. Nothing caught it: the import is type-only, so tsx
34
+ erases it before it can fail at runtime, and the package tsconfig excludes
35
+ `**/*.test.ts`, so `yarn tsc` never saw it either. It now imports from
36
+ `@pikku/core/workflow`.
37
+
38
+ A new guard test in `@pikku/core` scans the repository for the dead specifier
39
+ and fails if one comes back, so the next stale import is a red test rather than
40
+ a silent `any`.
41
+
42
+ - 05e47cf: feat(virtual-user): keep the transcript a run already produced
43
+
44
+ The engine returns `intents` and `steps` on every run — what the user set out
45
+ to do, and every turn it took getting there — and `VirtualUserRunOutcome` kept
46
+ neither. The record held counts and findings, so the one question anybody
47
+ actually asks of a completed run ("what did it _do_?") had no answer anywhere,
48
+ even though the answer had been computed and thrown away a moment earlier.
49
+
50
+ `VirtualUserRunOutcome` now carries both, and `VirtualUserRunStore` gains a
51
+ `steps(runId, options?)` read. Intents ride on the run record: there are a
52
+ handful of them and every read of the run wants them. Steps get their own
53
+ `virtualUserRunStep` table, because a run at a 500-step budget carries more
54
+ transcript than every other column together and `list()` would pay for it on
55
+ every row.
56
+
57
+ Three things the kysely store had to get right, all of them driver differences
58
+ rather than design:
59
+
60
+ - steps are inserted in chunks of 50, because a bare sqlite driver binds at
61
+ most 999 variables per statement and ten columns times a 500-step budget is
62
+ five thousand — an un-chunked insert fails on long runs, which are the
63
+ interesting ones;
64
+ - `ok` is stored as 0 or 1, since a bare driver cannot bind a boolean at all
65
+ and `SerializePlugin` is not installed everywhere;
66
+ - `response` is stored JSON-encoded, because a truncated API response usually
67
+ starts with a brace and `SerializePlugin` would otherwise read it back as an
68
+ object rather than the string the engine saw.
69
+
70
+ Completing a run that does not exist no longer writes steps: there is no
71
+ foreign key to refuse them and nothing would ever read or reap them.
72
+
73
+ **This adds a table to the `virtualUser` schema**, and the runtime creates
74
+ nothing: a database that already has `virtualUserRun` gets the store's own
75
+ refusal at startup until `pikku db generate` writes the migration and
76
+ `pikku db migrate` applies it. Landing it now costs nothing, because
77
+ `scaffold.virtualUser` is not yet switched on anywhere.
78
+
79
+ - 05e47cf: feat(virtual-user): put each persona on its own clock
80
+
81
+ A budget says where one run stops. Nothing said how often a persona should use
82
+ the application, so in practice each one ran whenever somebody remembered — and
83
+ what actually tells you about a product is the same user coming back over a
84
+ fortnight.
85
+
86
+ Each persona now gets a row rather than a bigger budget. `virtualUserSchedule`
87
+ holds `enabled`, the disposition and goals to run with, an interval **range**,
88
+ and `nextRunAt`. `tickVirtualUserSchedules` acts on whichever rows are due:
89
+
90
+ ```ts
91
+ wireScheduler({
92
+ name: 'virtualUsers',
93
+ schedule: '0 * * * *',
94
+ func: tickVirtualUserSchedules,
95
+ })
96
+ ```
97
+
98
+ The tick is generated and wired by nobody, deliberately. A scaffolded
99
+ `wireScheduler` would start spending an application's model budget the moment
100
+ somebody ran `pikku all`, on a host that may not run schedulers at all. Tick
101
+ resolution bounds how late a due persona is, never how often it runs.
102
+
103
+ Three things it does that are easy to leave out:
104
+
105
+ - The next due time is written **before** the run is dispatched, so a tick that
106
+ dies halfway cannot hand the same persona to the next one. A dispatch that
107
+ throws waits a full interval instead of retrying every minute for a week.
108
+ That write is a compare-and-set against the `nextRunAt` the tick read, so it
109
+ is also how a tick _wins_ the persona: two processes on the same cron see the
110
+ same due row, and only the one whose claim lands dispatches.
111
+ - A persona whose previous run is still `running` is skipped, not queued. Two
112
+ copies of the same user acting at once is a different test, and its findings
113
+ do not reproduce.
114
+ - A run still `running` after two hours is failed. Without that, one restart
115
+ mid-run blocks that persona's schedule permanently — which is where the
116
+ stranded-record cost of not using a queue finally gets paid.
117
+
118
+ Reschedule-on-completion was the other candidate and is worse in exactly one
119
+ way, fatally: a crash between finishing and scheduling ends the persona forever,
120
+ and the evidence is an absence.
121
+
122
+ New: `VirtualUserScheduleStore` in core (with the tick, `isDue` and `nextRunAt`
123
+ as pure functions), `KyselyVirtualUserScheduleStore` and its own schema —
124
+ its own rather than a third table in `virtualUserSchema`, and owned by its own
125
+ store, so a project that records runs and never wants them unattended carries no
126
+ cadence table. `scaffold.virtualUser` gains `setVirtualUserSchedule`,
127
+ `listVirtualUserSchedules` and the tick, behind a new `virtualUser:schedule`
128
+ scope: starting a run spends money once with a caller watching, while writing a
129
+ schedule spends it repeatedly with nobody there.
130
+
131
+ The console's Virtual Users screen gains a **Run now** button beside a persona's
132
+ run history, gated on `pikku:console:virtualUsers:run`. It dispatches the
133
+ project's own `runVirtualUser` rather than starting a run itself, so a run the
134
+ application would refuse — an acted-upon persona, a non-accountable disposition
135
+ in production — is still refused.
136
+
137
+ - Updated dependencies [3c0012c]
138
+ - Updated dependencies [05e47cf]
139
+ - Updated dependencies [cfd364a]
140
+ - Updated dependencies [05e47cf]
141
+ - Updated dependencies [05e47cf]
142
+ - Updated dependencies [05e47cf]
143
+ - @pikku/core@0.12.90
144
+
1
145
  ## 0.13.20
2
146
 
3
147
  ### Patch Changes
@@ -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';
@@ -90,7 +90,7 @@ export class KyselySecretService {
90
90
  .where('key', '=', key)
91
91
  .executeTakeFirst();
92
92
  if (!row)
93
- throw new Error('Requested secret not found');
93
+ throw new Error(`Requested secret not found: ${key}`);
94
94
  const kek = await this.getKEK(row.keyVersion);
95
95
  const result = await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek);
96
96
  await this.logAudit(key, 'read');
@@ -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
- .execute();
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
  /**
@@ -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;