@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.
- package/CHANGELOG.md +196 -0
- package/dist/services/http-personas.js +8 -0
- package/dist/types/core.types.d.ts +8 -1
- package/dist/wirings/rpc/rpc-runner.js +5 -6
- package/dist/wirings/virtual-user/index.d.ts +3 -1
- package/dist/wirings/virtual-user/index.js +1 -0
- package/dist/wirings/virtual-user/virtual-user-agents.d.ts +7 -2
- package/dist/wirings/virtual-user/virtual-user-agents.js +8 -2
- package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +32 -1
- package/dist/wirings/virtual-user/virtual-user-schedule-store.d.ts +89 -0
- package/dist/wirings/virtual-user/virtual-user-schedule-store.js +1 -0
- package/dist/wirings/virtual-user/virtual-user-schedule.d.ts +71 -0
- package/dist/wirings/virtual-user/virtual-user-schedule.js +101 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +3 -2
- package/dist/wirings/workflow/pikku-workflow-service.js +7 -4
- package/dist/wirings/workflow/workflow-constants.d.ts +17 -0
- package/dist/wirings/workflow/workflow-constants.js +17 -0
- package/dist/wirings/workflow/workflow-recovery.d.ts +18 -1
- package/dist/wirings/workflow/workflow-recovery.js +30 -2
- package/knowledge/decisions/internals/a-virtual-user-cadence-is-a-row-not-a-timer.md +66 -0
- package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +8 -3
- package/knowledge/decisions/internals/index.md +1 -0
- package/knowledge/decisions/internals/the-ecosystem-entry-point-carries-the-adapter-surface.md +7 -6
- package/package.json +1 -1
- package/src/app-leaf-surface.test.ts +2 -2
- package/src/ecosystem-tier-removed.test.ts +69 -0
- package/src/public-surface.json +6 -0
- package/src/services/http-personas-converse.test.ts +16 -2
- package/src/services/http-personas.ts +8 -0
- package/src/types/core.types.ts +8 -1
- package/src/wirings/rpc/rpc-runner.test.ts +106 -1
- package/src/wirings/rpc/rpc-runner.ts +9 -6
- package/src/wirings/virtual-user/index.ts +18 -0
- package/src/wirings/virtual-user/virtual-user-agents.test.ts +8 -4
- package/src/wirings/virtual-user/virtual-user-agents.ts +8 -3
- package/src/wirings/virtual-user/virtual-user-run-store.ts +33 -0
- package/src/wirings/virtual-user/virtual-user-schedule-store.ts +93 -0
- package/src/wirings/virtual-user/virtual-user-schedule.test.ts +280 -0
- package/src/wirings/virtual-user/virtual-user-schedule.ts +156 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +6 -2
- package/src/wirings/workflow/workflow-constants.ts +19 -0
- package/src/wirings/workflow/workflow-recovery.ts +31 -1
- package/src/wirings/workflow/workflow-stalled-recovery.test.ts +46 -0
- package/src/wirings/workflow/workflow-terminal-run-guard.test.ts +105 -0
- 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-
|
|
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-
|
|
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
|
|
274
|
-
|
|
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(
|
|
285
|
-
return this.services.deploymentService.invoke(rpcName, data, session,
|
|
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,
|
|
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,
|
|
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:
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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>;
|