@pikku/core 0.12.88 → 0.12.90

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +196 -0
  2. package/dist/services/http-personas.js +8 -0
  3. package/dist/types/core.types.d.ts +8 -1
  4. package/dist/wirings/rpc/rpc-runner.js +5 -6
  5. package/dist/wirings/virtual-user/index.d.ts +3 -1
  6. package/dist/wirings/virtual-user/index.js +1 -0
  7. package/dist/wirings/virtual-user/virtual-user-agents.d.ts +7 -2
  8. package/dist/wirings/virtual-user/virtual-user-agents.js +8 -2
  9. package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +32 -1
  10. package/dist/wirings/virtual-user/virtual-user-schedule-store.d.ts +89 -0
  11. package/dist/wirings/virtual-user/virtual-user-schedule-store.js +1 -0
  12. package/dist/wirings/virtual-user/virtual-user-schedule.d.ts +71 -0
  13. package/dist/wirings/virtual-user/virtual-user-schedule.js +101 -0
  14. package/dist/wirings/workflow/pikku-workflow-service.d.ts +3 -2
  15. package/dist/wirings/workflow/pikku-workflow-service.js +7 -4
  16. package/dist/wirings/workflow/workflow-constants.d.ts +17 -0
  17. package/dist/wirings/workflow/workflow-constants.js +17 -0
  18. package/dist/wirings/workflow/workflow-recovery.d.ts +18 -1
  19. package/dist/wirings/workflow/workflow-recovery.js +30 -2
  20. package/knowledge/decisions/internals/a-virtual-user-cadence-is-a-row-not-a-timer.md +66 -0
  21. package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +8 -3
  22. package/knowledge/decisions/internals/index.md +1 -0
  23. package/knowledge/decisions/internals/the-ecosystem-entry-point-carries-the-adapter-surface.md +7 -6
  24. package/package.json +1 -1
  25. package/src/app-leaf-surface.test.ts +2 -2
  26. package/src/ecosystem-tier-removed.test.ts +69 -0
  27. package/src/public-surface.json +6 -0
  28. package/src/services/http-personas-converse.test.ts +16 -2
  29. package/src/services/http-personas.ts +8 -0
  30. package/src/types/core.types.ts +8 -1
  31. package/src/wirings/rpc/rpc-runner.test.ts +106 -1
  32. package/src/wirings/rpc/rpc-runner.ts +9 -6
  33. package/src/wirings/virtual-user/index.ts +18 -0
  34. package/src/wirings/virtual-user/virtual-user-agents.test.ts +8 -4
  35. package/src/wirings/virtual-user/virtual-user-agents.ts +8 -3
  36. package/src/wirings/virtual-user/virtual-user-run-store.ts +33 -0
  37. package/src/wirings/virtual-user/virtual-user-schedule-store.ts +93 -0
  38. package/src/wirings/virtual-user/virtual-user-schedule.test.ts +280 -0
  39. package/src/wirings/virtual-user/virtual-user-schedule.ts +156 -0
  40. package/src/wirings/workflow/pikku-workflow-service.ts +6 -2
  41. package/src/wirings/workflow/workflow-constants.ts +19 -0
  42. package/src/wirings/workflow/workflow-recovery.ts +31 -1
  43. package/src/wirings/workflow/workflow-stalled-recovery.test.ts +46 -0
  44. package/src/wirings/workflow/workflow-terminal-run-guard.test.ts +105 -0
  45. package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,199 @@
1
+ ## 0.12.90
2
+
3
+ ### Patch Changes
4
+
5
+ - 3c0012c: Gate console agent-thread reads and deletes on thread ownership, claim MongoDB workflow steps atomically, and reach the deployment fallback from `rpcWithWire`
6
+
7
+ `getAgentThreadMessages` and `deleteAgentThread` in the console addon took a
8
+ caller-supplied `threadId` straight to storage, while their siblings
9
+ `getAgentThreads` and `getAgentThreadRuns` already filtered to what the session
10
+ owns. Both now carry an `isThreadOwner` permission: an admin reaches any thread,
11
+ everyone else only their own, and a missing thread is refused rather than 404'd
12
+ so it is indistinguishable from someone else's.
13
+
14
+ `MongoDBWorkflowService` claimed a step by reading its status and then writing
15
+ it, under a `withStepLock` that is a pass-through — so two dispatches racing for
16
+ the same step could both proceed and run a side-effecting step twice. The claim
17
+ is now a single status-guarded update, atomic on one document.
18
+
19
+ `rpcWithWire` threw `RPCNotFoundError` for any unresolved namespaced call
20
+ instead of falling through to the deployment service, so a namespaced RPC that
21
+ `rpc()` would have dispatched remotely failed when called with an explicit wire.
22
+
23
+ - 05e47cf: fix(personas): sign a persona in before it converses, not after a 401
24
+
25
+ `HttpPersona.converse` left authentication to `postAgent`'s 401-retry, which
26
+ only fires on a route that refuses an anonymous caller. An agent route wired
27
+ without `auth: true` never refuses one: turn one is accepted and the thread is
28
+ minted under a fresh anonymous id, turn two arrives under a different anonymous
29
+ id, and the persona is told the thread belongs to somebody else — intermittently,
30
+ because it depends on which turn the retry happened to run on.
31
+
32
+ The persona now logs in before the first turn if it has not already. A persona
33
+ is a declared account with real credentials in every case, so there was never a
34
+ run where conversing as nobody was what was wanted; the sign-in is the same one
35
+ `call` has always done, just no longer conditional on the server pushing back.
36
+
37
+ - cfd364a: Remove the last `@pikku/core/ecosystem` references and guard against new ones
38
+
39
+ `@pikku/kysely`'s workflow-service test still imported `StepState` from
40
+ `@pikku/core/ecosystem/workflow`, a subpath that no longer exists in
41
+ `@pikku/core`'s `exports`. Nothing caught it: the import is type-only, so tsx
42
+ erases it before it can fail at runtime, and the package tsconfig excludes
43
+ `**/*.test.ts`, so `yarn tsc` never saw it either. It now imports from
44
+ `@pikku/core/workflow`.
45
+
46
+ A new guard test in `@pikku/core` scans the repository for the dead specifier
47
+ and fails if one comes back, so the next stale import is a red test rather than
48
+ a silent `any`.
49
+
50
+ - 05e47cf: fix(virtual-user): offer agents under the name the server can resolve
51
+
52
+ `reachableAgents` named each offered agent `agent.name ?? id`, where `id` is the
53
+ key the agent is registered under and `agent.name` is the display label from its
54
+ config. Those are the same string only by coincidence. `addAgent` stores the
55
+ export's own name and `resolveAgent` looks the call up by it, so an agent
56
+ exported as `adminAgent` and declaring `name: 'admin-agent'` was advertised to a
57
+ virtual user as `admin-agent` — a name nothing has ever registered. The persona
58
+ took the offer on its first turn, the stage answered
59
+ `500 AI agent not found: admin-agent`, and the run died there. Every fixture in
60
+ the tests happened to use one string for both, so nothing caught it.
61
+
62
+ The offered name is now always the registration key. `AgentReachability.name` is
63
+ gone rather than ignored, so there is no longer a display label sitting in the
64
+ shape inviting the same mistake.
65
+
66
+ - 05e47cf: feat(virtual-user): keep the transcript a run already produced
67
+
68
+ The engine returns `intents` and `steps` on every run — what the user set out
69
+ to do, and every turn it took getting there — and `VirtualUserRunOutcome` kept
70
+ neither. The record held counts and findings, so the one question anybody
71
+ actually asks of a completed run ("what did it _do_?") had no answer anywhere,
72
+ even though the answer had been computed and thrown away a moment earlier.
73
+
74
+ `VirtualUserRunOutcome` now carries both, and `VirtualUserRunStore` gains a
75
+ `steps(runId, options?)` read. Intents ride on the run record: there are a
76
+ handful of them and every read of the run wants them. Steps get their own
77
+ `virtualUserRunStep` table, because a run at a 500-step budget carries more
78
+ transcript than every other column together and `list()` would pay for it on
79
+ every row.
80
+
81
+ Three things the kysely store had to get right, all of them driver differences
82
+ rather than design:
83
+
84
+ - steps are inserted in chunks of 50, because a bare sqlite driver binds at
85
+ most 999 variables per statement and ten columns times a 500-step budget is
86
+ five thousand — an un-chunked insert fails on long runs, which are the
87
+ interesting ones;
88
+ - `ok` is stored as 0 or 1, since a bare driver cannot bind a boolean at all
89
+ and `SerializePlugin` is not installed everywhere;
90
+ - `response` is stored JSON-encoded, because a truncated API response usually
91
+ starts with a brace and `SerializePlugin` would otherwise read it back as an
92
+ object rather than the string the engine saw.
93
+
94
+ Completing a run that does not exist no longer writes steps: there is no
95
+ foreign key to refuse them and nothing would ever read or reap them.
96
+
97
+ **This adds a table to the `virtualUser` schema**, and the runtime creates
98
+ nothing: a database that already has `virtualUserRun` gets the store's own
99
+ refusal at startup until `pikku db generate` writes the migration and
100
+ `pikku db migrate` applies it. Landing it now costs nothing, because
101
+ `scaffold.virtualUser` is not yet switched on anywhere.
102
+
103
+ - 05e47cf: feat(virtual-user): put each persona on its own clock
104
+
105
+ A budget says where one run stops. Nothing said how often a persona should use
106
+ the application, so in practice each one ran whenever somebody remembered — and
107
+ what actually tells you about a product is the same user coming back over a
108
+ fortnight.
109
+
110
+ Each persona now gets a row rather than a bigger budget. `virtualUserSchedule`
111
+ holds `enabled`, the disposition and goals to run with, an interval **range**,
112
+ and `nextRunAt`. `tickVirtualUserSchedules` acts on whichever rows are due:
113
+
114
+ ```ts
115
+ wireScheduler({
116
+ name: 'virtualUsers',
117
+ schedule: '0 * * * *',
118
+ func: tickVirtualUserSchedules,
119
+ })
120
+ ```
121
+
122
+ The tick is generated and wired by nobody, deliberately. A scaffolded
123
+ `wireScheduler` would start spending an application's model budget the moment
124
+ somebody ran `pikku all`, on a host that may not run schedulers at all. Tick
125
+ resolution bounds how late a due persona is, never how often it runs.
126
+
127
+ Three things it does that are easy to leave out:
128
+
129
+ - The next due time is written **before** the run is dispatched, so a tick that
130
+ dies halfway cannot hand the same persona to the next one. A dispatch that
131
+ throws waits a full interval instead of retrying every minute for a week.
132
+ That write is a compare-and-set against the `nextRunAt` the tick read, so it
133
+ is also how a tick _wins_ the persona: two processes on the same cron see the
134
+ same due row, and only the one whose claim lands dispatches.
135
+ - A persona whose previous run is still `running` is skipped, not queued. Two
136
+ copies of the same user acting at once is a different test, and its findings
137
+ do not reproduce.
138
+ - A run still `running` after two hours is failed. Without that, one restart
139
+ mid-run blocks that persona's schedule permanently — which is where the
140
+ stranded-record cost of not using a queue finally gets paid.
141
+
142
+ Reschedule-on-completion was the other candidate and is worse in exactly one
143
+ way, fatally: a crash between finishing and scheduling ends the persona forever,
144
+ and the evidence is an absence.
145
+
146
+ New: `VirtualUserScheduleStore` in core (with the tick, `isDue` and `nextRunAt`
147
+ as pure functions), `KyselyVirtualUserScheduleStore` and its own schema —
148
+ its own rather than a third table in `virtualUserSchema`, and owned by its own
149
+ store, so a project that records runs and never wants them unattended carries no
150
+ cadence table. `scaffold.virtualUser` gains `setVirtualUserSchedule`,
151
+ `listVirtualUserSchedules` and the tick, behind a new `virtualUser:schedule`
152
+ scope: starting a run spends money once with a caller watching, while writing a
153
+ schedule spends it repeatedly with nobody there.
154
+
155
+ The console's Virtual Users screen gains a **Run now** button beside a persona's
156
+ run history, gated on `pikku:console:virtualUsers:run`. It dispatches the
157
+ project's own `runVirtualUser` rather than starting a run itself, so a run the
158
+ application would refuse — an acted-upon persona, a non-accountable disposition
159
+ in production — is still refused.
160
+
161
+ ## 0.12.89
162
+
163
+ ### Patch Changes
164
+
165
+ - 32616af: Carry the trace id across a remote RPC hop
166
+
167
+ `ContextAwareRPCService` sent the wire's trace id as `x-trace-id`, but the HTTP
168
+ runner on the receiving end reads `x-request-id` — the header every other sender
169
+ uses, including `buildRemoteHeaders`, which every deployment service goes
170
+ through. The receiving side therefore ignored the incoming id and generated a
171
+ fresh one, so a trace broke at each remote RPC boundary instead of spanning it.
172
+ Remote RPC now sends `x-request-id` too.
173
+
174
+ - 6848cd9: fix(workflow): back off the stalled-run sweep, and skip runs that cannot move
175
+
176
+ `sweepUndispatchedSteps` has always consulted a per-run backoff so a genuine
177
+ queue backlog is not amplified by a tick that keeps firing at the steps the
178
+ backlog is already delaying. `sweepStalledRuns` — its sibling, doing the same
179
+ re-drive through the same orchestrator queue — had none, and re-resumed every
180
+ stalled run on every tick. A resume does not clear whatever wedged a run, so
181
+ the same runs came back on the next tick and the next: in production seven
182
+ permanently stuck runs refilled a purged orchestrator queue at seven messages a
183
+ minute, and a backlog of six thousand could never drain because each pass added
184
+ work the previous pass had not finished. It now takes the same backoff, and
185
+ both sweeps share one instance — the record belongs to the re-drive, not to the
186
+ signal that asked for it, so a run the relay nudged a moment ago is not nudged
187
+ again by the sweep.
188
+
189
+ `runWorkflowJob` also now returns immediately for a run in a terminal state
190
+ instead of taking the run lock and replaying the workflow body. The orchestrator
191
+ queue is at-least-once and the relay re-dispatches on purpose, so a message for
192
+ a run that already settled is routine — and replaying one could park the body on
193
+ a wait that nothing would ever satisfy, holding the run lock, and the pooled
194
+ connection under it, until something external gave up. `suspended` is
195
+ deliberately not included: it ends a pass, not the run.
196
+
1
197
  ## 0.12.88
2
198
 
3
199
  ### Patch Changes
@@ -57,6 +57,14 @@ export class HttpPersona {
57
57
  if (!agentRunner) {
58
58
  throw new AIProviderNotConfiguredError();
59
59
  }
60
+ // Signed in here rather than left to postAgent's 401 retry, which a public
61
+ // agent route never triggers. An unowned thread is minted under a fresh
62
+ // anonymous id per request, so turn one succeeds and turn two is refused as
63
+ // somebody else's — and a persona is a real account with real credentials,
64
+ // so there is no case where conversing as nobody is the intent.
65
+ if (!this.signedIn) {
66
+ await this.login();
67
+ }
60
68
  const model = options.model ?? this.config.model;
61
69
  if (!model) {
62
70
  throw new Error(`[scenario] persona '${this.name}' converse needs a model — pass options.model or set 'model' on the personas service`);
@@ -29,6 +29,7 @@ import type { AgentRunService } from '../wirings/agent/agent.types.js';
29
29
  import type { MiddlewareMetadata } from '../middleware/middleware.types.js';
30
30
  import type { PermissionMetadata } from '../function/function-meta.types.js';
31
31
  import type { VirtualUserRunStore } from '../wirings/virtual-user/virtual-user-run-store.js';
32
+ import type { VirtualUserScheduleStore } from '../wirings/virtual-user/virtual-user-schedule-store.js';
32
33
  import type { WorkflowRunService } from '../wirings/workflow/workflow.types.js';
33
34
  import type { CredentialService } from '../services/credential-service.js';
34
35
  import type { EmailService } from '../services/email-service.js';
@@ -135,6 +136,12 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
135
136
  * {@link VirtualUserRunStore}.
136
137
  */
137
138
  virtualUserRunStore?: VirtualUserRunStore;
139
+ /**
140
+ * Each persona's cadence, for apps that want their virtual users to keep
141
+ * going without being asked. Separate from the run store on purpose: wiring
142
+ * nothing is how an app says it only wants the runs it starts itself.
143
+ */
144
+ virtualUserScheduleStore?: VirtualUserScheduleStore;
138
145
  /** V8 precise-coverage collector (`pikku dev --coverage` only) */
139
146
  coverageService?: CoverageService;
140
147
  audit?: AuditService;
@@ -182,7 +189,7 @@ export type PikkuWire<In = unknown, Out = unknown, HasInitialSession extends boo
182
189
  * sets it; services that log fall back to the singleton logger.
183
190
  */
184
191
  logger: Logger;
185
- /** Trace ID for distributed tracing — propagated across remote RPC calls via x-trace-id header */
192
+ /** Trace ID for distributed tracing — propagated across remote RPC calls via the x-request-id header */
186
193
  traceId: string;
187
194
  functionId: string;
188
195
  addonNamespace: string;
@@ -245,7 +245,7 @@ export class ContextAwareRPCService {
245
245
  headers.authorization = `Bearer ${token}`;
246
246
  }
247
247
  if (this.wire.traceId) {
248
- headers['x-trace-id'] = this.wire.traceId;
248
+ headers['x-request-id'] = this.wire.traceId;
249
249
  }
250
250
  const base = serverUrl.replace(/\/+$/, '');
251
251
  const res = await fetch(`${base}/remote/rpc/${encodeURIComponent(remoteFn)}`, {
@@ -270,10 +270,9 @@ export class ContextAwareRPCService {
270
270
  };
271
271
  if (rpcName.includes(':')) {
272
272
  const addonCall = this.resolveAddonFunction(rpcName);
273
- if (addonCall === NOT_RESOLVED) {
274
- throw new RPCNotFoundError(rpcName);
273
+ if (addonCall !== NOT_RESOLVED) {
274
+ return await this.executeAddonFunction(addonCall, data, mergedWire);
275
275
  }
276
- return this.executeAddonFunction(addonCall, data, mergedWire);
277
276
  }
278
277
  let resolved;
279
278
  try {
@@ -281,8 +280,8 @@ export class ContextAwareRPCService {
281
280
  }
282
281
  catch (e) {
283
282
  if (e instanceof RPCNotFoundError && this.services.deploymentService) {
284
- const session = await resolveWireSession(this.wire);
285
- return this.services.deploymentService.invoke(rpcName, data, session, this.wire.traceId);
283
+ const session = await resolveWireSession(mergedWire);
284
+ return this.services.deploymentService.invoke(rpcName, data, session, mergedWire.traceId);
286
285
  }
287
286
  throw e;
288
287
  }
@@ -16,11 +16,13 @@
16
16
  * {@link VirtualUserTarget}, which in production is an `HttpPersona`
17
17
  * signed in as a real user against staging or production.
18
18
  */
19
- export type { ApiCatalogueEntry, IntentSource, VirtualUserDisposition, VirtualUserFinding, VirtualUserRunResult, VirtualUserTarget, } from './virtual-user.types.js';
19
+ export type { ApiCatalogueEntry, IntentRecord, IntentSource, StepRecord, VirtualUserBudget, VirtualUserDisposition, VirtualUserFinding, VirtualUserRunResult, VirtualUserTarget, } from './virtual-user.types.js';
20
20
  export { PRODUCTION_DISPOSITION } from './virtual-user.types.js';
21
21
  export { runVirtualUser, type RunVirtualUserParams, } from './run-virtual-user.js';
22
22
  export { personaScopes, prepareVirtualUserRun, type VirtualUserPreparation, } from './prepare-virtual-user-run.js';
23
23
  export type { VirtualUserRunOutcome, VirtualUserRunRecord, VirtualUserRunStart, VirtualUserRunStore, } from './virtual-user-run-store.js';
24
+ export type { VirtualUserScheduleInput, VirtualUserScheduleRecord, VirtualUserScheduleStore, } from './virtual-user-schedule-store.js';
25
+ export { DEFAULT_MAX_INTERVAL_MS, DEFAULT_MIN_INTERVAL_MS, isDue, nextRunAt, STALE_RUN_AFTER_MS, tickVirtualUserSchedules, type VirtualUserTickParams, type VirtualUserTickResult, } from './virtual-user-schedule.js';
24
26
  export { DISPOSITIONS, dispositionProfile, type DispositionProfile, type VirtualUserTuning, } from './virtual-user-dispositions.js';
25
27
  export { catalogueClassification, catalogueLookup, isReadOnly, reachableCatalogue, unreachableCatalogue, } from './virtual-user-catalogue.js';
26
28
  export { type AgentReachability, type ReachableAgent, } from './virtual-user-agents.js';
@@ -1,6 +1,7 @@
1
1
  export { PRODUCTION_DISPOSITION } from './virtual-user.types.js';
2
2
  export { runVirtualUser, } from './run-virtual-user.js';
3
3
  export { personaScopes, prepareVirtualUserRun, } from './prepare-virtual-user-run.js';
4
+ export { DEFAULT_MAX_INTERVAL_MS, DEFAULT_MIN_INTERVAL_MS, isDue, nextRunAt, STALE_RUN_AFTER_MS, tickVirtualUserSchedules, } from './virtual-user-schedule.js';
4
5
  export { DISPOSITIONS, dispositionProfile, } from './virtual-user-dispositions.js';
5
6
  export { catalogueClassification, catalogueLookup, isReadOnly, reachableCatalogue, unreachableCatalogue, } from './virtual-user-catalogue.js';
6
7
  export { IntentStack, intentsForPersona } from './virtual-user-intents.js';
@@ -13,7 +13,6 @@
13
13
  */
14
14
  /** The part of an agent's meta this needs. */
15
15
  export interface AgentReachability {
16
- name?: string;
17
16
  description?: string;
18
17
  scopes?: readonly string[];
19
18
  auth?: boolean;
@@ -24,7 +23,13 @@ export interface ReachableAgent {
24
23
  description?: string;
25
24
  }
26
25
  /**
27
- * The agents to offer, keyed by the name they are declared under.
26
+ * The agents to offer, named by the key they are registered under.
27
+ *
28
+ * That key is the export's own name, which is what `addAgent` stores and what
29
+ * `resolveAgent` looks up. The `name` an agent declares in its config is a
30
+ * display label and is frequently something else entirely — offering that one
31
+ * hands the persona a name the server cannot resolve, and the run dies on a
32
+ * 500 the moment it takes the offer.
28
33
  *
29
34
  * Like {@link reachableCatalogue}, this narrows *what is offered* and never
30
35
  * what is enforced: the server decides who may talk to what, and an agent
@@ -1,6 +1,12 @@
1
1
  import { hasScopes } from '../../scopes.js';
2
2
  /**
3
- * The agents to offer, keyed by the name they are declared under.
3
+ * The agents to offer, named by the key they are registered under.
4
+ *
5
+ * That key is the export's own name, which is what `addAgent` stores and what
6
+ * `resolveAgent` looks up. The `name` an agent declares in its config is a
7
+ * display label and is frequently something else entirely — offering that one
8
+ * hands the persona a name the server cannot resolve, and the run dies on a
9
+ * 500 the moment it takes the offer.
4
10
  *
5
11
  * Like {@link reachableCatalogue}, this narrows *what is offered* and never
6
12
  * what is enforced: the server decides who may talk to what, and an agent
@@ -19,6 +25,6 @@ export const reachableAgents = (agents, scopes) => Object.entries(agents)
19
25
  return hasScopes(agent.scopes, scopes);
20
26
  })
21
27
  .map(([id, agent]) => ({
22
- name: agent.name ?? id,
28
+ name: id,
23
29
  ...(agent.description ? { description: agent.description } : {}),
24
30
  }));
@@ -1,4 +1,4 @@
1
- import type { VirtualUserDisposition, VirtualUserFinding, VirtualUserTally } from './virtual-user.types.js';
1
+ import type { IntentRecord, StepRecord, VirtualUserDisposition, VirtualUserFinding, VirtualUserTally } from './virtual-user.types.js';
2
2
  /**
3
3
  * One recorded run: who ran, what they were told, and what came back.
4
4
  *
@@ -34,6 +34,16 @@ export interface VirtualUserRunRecord {
34
34
  */
35
35
  memory: Record<string, string>;
36
36
  findings: VirtualUserFinding[];
37
+ /**
38
+ * What the user set out to do and how far each one got, which is the spine a
39
+ * transcript hangs off — the steps alone are a list of calls with no account
40
+ * of what they were for.
41
+ *
42
+ * Small and bounded, so it rides on the run row rather than in a table of its
43
+ * own: a run has as many intents as the app has scenarios, and every read of
44
+ * the run wants them.
45
+ */
46
+ intents: IntentRecord[];
37
47
  tally: VirtualUserTally | null;
38
48
  /** Which budget or stopping rule ended the run. */
39
49
  stoppedBy: string | null;
@@ -62,6 +72,16 @@ export interface VirtualUserRunOutcome {
62
72
  tally: VirtualUserTally;
63
73
  memory: Record<string, string>;
64
74
  stoppedBy: string | null;
75
+ intents: readonly IntentRecord[];
76
+ /**
77
+ * Every turn the run took. Kept because a finding is an assertion until you
78
+ * can see what the user did before it, and because a run that found nothing
79
+ * is only readable as work through its steps.
80
+ *
81
+ * Stored apart from the run — see {@link VirtualUserRunStore.steps} — so
82
+ * listing runs does not drag a budget's worth of turns along with it.
83
+ */
84
+ steps: readonly StepRecord[];
65
85
  }
66
86
  /**
67
87
  * Where runs are kept. Declared here rather than in a database package so the
@@ -87,4 +107,15 @@ export interface VirtualUserRunStore {
87
107
  limit?: number;
88
108
  offset?: number;
89
109
  }): Promise<VirtualUserRunRecord[]>;
110
+ /**
111
+ * One run's turns, in the order they happened.
112
+ *
113
+ * Its own call rather than a field on the record: a run at a 500-step budget
114
+ * carries more transcript than every other column put together, and `list`
115
+ * would pay for it on every row.
116
+ */
117
+ steps(runId: string, options?: {
118
+ limit?: number;
119
+ offset?: number;
120
+ }): Promise<StepRecord[]>;
90
121
  }
@@ -0,0 +1,89 @@
1
+ import type { VirtualUserBudget, VirtualUserDisposition } from './virtual-user.types.js';
2
+ /**
3
+ * One persona's standing instruction to keep using the app.
4
+ *
5
+ * A virtual user that runs once tells you about one afternoon. What an app
6
+ * actually wants to know is what a persona hits over a fortnight, and that is
7
+ * a cadence, not a longer run — a budget already caps how far a single run
8
+ * goes, and raising it only buys a more tired user.
9
+ *
10
+ * The row is the schedule. There is deliberately no timer, interval or
11
+ * in-memory loop anywhere near it: a process that holds the next run in its own
12
+ * heap forgets it on the next deploy, and a persona silently stops. Something
13
+ * outside asks which rows are due; the answer survives restarts because it is
14
+ * written down.
15
+ */
16
+ export interface VirtualUserScheduleRecord {
17
+ persona: string;
18
+ /**
19
+ * Off by default. A schedule that ran the moment it was written would start
20
+ * spending an app's model budget as a side effect of a migration.
21
+ */
22
+ enabled: boolean;
23
+ disposition: VirtualUserDisposition;
24
+ goals: string[];
25
+ budget: VirtualUserBudget | null;
26
+ /**
27
+ * The gap to the next run is drawn between these, not fixed. A persona that
28
+ * appears at exactly 09:00 every day exercises one cache state and one cron
29
+ * neighbourhood; a real one does not keep an appointment.
30
+ */
31
+ minIntervalMs: number;
32
+ maxIntervalMs: number;
33
+ /** When this persona is next allowed to run. The whole schedule, in one field. */
34
+ nextRunAt: Date;
35
+ lastRunId: string | null;
36
+ lastRunAt: Date | null;
37
+ }
38
+ /** A partial write — anything left out keeps whatever the row already had. */
39
+ export interface VirtualUserScheduleInput {
40
+ persona: string;
41
+ enabled?: boolean;
42
+ disposition?: VirtualUserDisposition;
43
+ goals?: readonly string[];
44
+ budget?: VirtualUserBudget | null;
45
+ minIntervalMs?: number;
46
+ maxIntervalMs?: number;
47
+ nextRunAt?: Date;
48
+ }
49
+ /**
50
+ * Where cadences are kept, alongside {@link VirtualUserRunStore} and separate
51
+ * from it: a host can want the history of runs it started by hand without
52
+ * wanting unattended ones, and wiring nothing is how it says so.
53
+ *
54
+ * SECURITY: writing a row here spends money on every future tick, without a
55
+ * caller present to see it happen. The scaffold gates writes behind a scope of
56
+ * their own for that reason — reading what the virtual users found is a much
57
+ * smaller permission than deciding they should keep going.
58
+ */
59
+ export interface VirtualUserScheduleStore {
60
+ /** Creates or updates one persona's cadence. Returns the row as it now stands. */
61
+ set(schedule: VirtualUserScheduleInput): Promise<VirtualUserScheduleRecord>;
62
+ get(persona: string): Promise<VirtualUserScheduleRecord | null>;
63
+ list(): Promise<VirtualUserScheduleRecord[]>;
64
+ /** Enabled rows whose `nextRunAt` has passed. */
65
+ due(now: Date): Promise<VirtualUserScheduleRecord[]>;
66
+ /**
67
+ * Pushes the persona's next run out, and records which run this was.
68
+ *
69
+ * Called *before* the run is dispatched, so a tick that dies halfway does not
70
+ * leave a row due and get re-dispatched by the next one. The cost is that a
71
+ * dispatch which throws waits a full interval rather than retrying, which is
72
+ * the right way round: a persona that is failing to start should not be
73
+ * retried every minute for a week.
74
+ *
75
+ * `from` is the `nextRunAt` the caller read, and the write must match it to
76
+ * land — the claim is how a tick wins the persona, not just how it records
77
+ * winning. Two processes on the same cron read the same due row, and without
78
+ * the compare-and-set both would dispatch: the same user acting twice over,
79
+ * at twice the budget, producing findings neither run can reproduce. The
80
+ * loser is told `false` and leaves the persona to whoever got there first.
81
+ */
82
+ claim(persona: string, claim: {
83
+ from: Date;
84
+ nextRunAt: Date;
85
+ runId: string | null;
86
+ at: Date;
87
+ }): Promise<boolean>;
88
+ remove(persona: string): Promise<void>;
89
+ }
@@ -0,0 +1,71 @@
1
+ import type { VirtualUserRunStore } from './virtual-user-run-store.js';
2
+ import type { VirtualUserScheduleRecord, VirtualUserScheduleStore } from './virtual-user-schedule-store.js';
3
+ /**
4
+ * How long a run may sit at `running` before it is read as dead rather than
5
+ * busy.
6
+ *
7
+ * A run holds no process across a restart — see {@link VirtualUserRunRecord} —
8
+ * so a deploy mid-run strands the record, and a stranded record would block its
9
+ * persona's schedule for good. Twice the longest duration budget anyone sets in
10
+ * practice, because the failure this guards against is cheap to recover from
11
+ * and expensive to trigger early: reaping a run that was still working loses
12
+ * its findings.
13
+ */
14
+ export declare const STALE_RUN_AFTER_MS: number;
15
+ /**
16
+ * The cadence a schedule gets when it is written without one: roughly a run a
17
+ * day, at an hour nobody can predict.
18
+ *
19
+ * Sparse on purpose. Every tick spends model budget with no caller present to
20
+ * notice, so the default is the one an app can leave switched on and forget,
21
+ * not the one that finds the most.
22
+ */
23
+ export declare const DEFAULT_MIN_INTERVAL_MS: number;
24
+ export declare const DEFAULT_MAX_INTERVAL_MS: number;
25
+ /** Whether a row is the tick's business, for stores that cannot ask in a query. */
26
+ export declare const isDue: (schedule: VirtualUserScheduleRecord, now: Date) => boolean;
27
+ /**
28
+ * When this persona should next appear, drawn from its own interval.
29
+ *
30
+ * Uniform between the two bounds. Reversed bounds are read as a range rather
31
+ * than rejected, because a schedule is configuration and a swapped pair is a
32
+ * typo, not an attack.
33
+ */
34
+ export declare const nextRunAt: (schedule: Pick<VirtualUserScheduleRecord, "minIntervalMs" | "maxIntervalMs">, now: Date, random: () => number) => Date;
35
+ export type VirtualUserSkipReason = 'in-flight' | 'dispatch-failed' | 'claimed-elsewhere';
36
+ export interface VirtualUserTickResult {
37
+ dispatched: {
38
+ persona: string;
39
+ runId: string;
40
+ }[];
41
+ skipped: {
42
+ persona: string;
43
+ reason: VirtualUserSkipReason;
44
+ }[];
45
+ /** Runs found stranded at `running` and marked failed. */
46
+ reaped: string[];
47
+ }
48
+ export interface VirtualUserTickParams {
49
+ schedules: VirtualUserScheduleStore;
50
+ runs: VirtualUserRunStore;
51
+ /** Starts one run and answers with its id. Nothing here knows how. */
52
+ dispatch: (schedule: VirtualUserScheduleRecord) => Promise<string>;
53
+ now?: Date;
54
+ random?: () => number;
55
+ staleAfterMs?: number;
56
+ }
57
+ /**
58
+ * Acts on whichever personas are due, once.
59
+ *
60
+ * The whole cadence lives in this one call, so what schedules it is the host's
61
+ * choice — a cron wiring, a platform scheduler, or a person clicking a button.
62
+ * Pikku does not start a timer on an app's behalf; a scaffold that did would
63
+ * begin spending model budget the moment a project ran `pikku all`.
64
+ *
65
+ * A persona is skipped, not queued, while its previous run is still going. Two
66
+ * copies of the same user acting at once is not a heavier test, it is a
67
+ * different one, and every finding it produces is unreproducible. Two ticks
68
+ * running at once are held to the same rule by the claim, which only lands for
69
+ * whichever of them still sees the `nextRunAt` it read.
70
+ */
71
+ export declare const tickVirtualUserSchedules: ({ schedules, runs, dispatch, now, random, staleAfterMs, }: VirtualUserTickParams) => Promise<VirtualUserTickResult>;