@pikku/core 0.12.89 → 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 +160 -0
- package/dist/services/http-personas.js +8 -0
- package/dist/types/core.types.d.ts +7 -0
- package/dist/wirings/rpc/rpc-runner.js +4 -5
- 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 +3 -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 +7 -0
- package/src/wirings/rpc/rpc-runner.test.ts +100 -0
- package/src/wirings/rpc/rpc-runner.ts +8 -5
- 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 +3 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: A virtual user cadence is a row, not a timer
|
|
4
|
+
description: How often a persona runs is stored as a due time per persona and acted on by a tick the project schedules — pikku never starts a timer, and a run never reschedules itself
|
|
5
|
+
tags: virtual-user, storage, scheduling
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# A virtual user cadence is a row, not a timer
|
|
9
|
+
|
|
10
|
+
A run has a budget; a persona has a cadence. The two get confused because both
|
|
11
|
+
answer "how often", and neither answers the other's question: a budget caps one
|
|
12
|
+
outing, and raising it only buys a more tired user. What tells you about a
|
|
13
|
+
product is the same person coming back over a fortnight.
|
|
14
|
+
|
|
15
|
+
That cadence is one row per persona in `virtualUserSchedule`, holding
|
|
16
|
+
`nextRunAt`. `tickVirtualUserSchedules` acts on whichever rows are due. There is
|
|
17
|
+
no timer, no interval, and no in-memory loop.
|
|
18
|
+
|
|
19
|
+
**Not a timer**, because a process holding the next run in its own heap forgets
|
|
20
|
+
it on the next deploy, and the persona silently stops — with nothing anywhere
|
|
21
|
+
saying it used to run. The row survives restarts, and any instance can act on
|
|
22
|
+
it.
|
|
23
|
+
|
|
24
|
+
**Not reschedule-on-completion**, which is the tempting shape: finish a run,
|
|
25
|
+
draw a delay, schedule the next. It has exactly one failure mode and it is
|
|
26
|
+
fatal — a crash between the two ends the persona forever, and the evidence is an
|
|
27
|
+
absence. A due time written down before the run starts cannot be lost by the run
|
|
28
|
+
failing.
|
|
29
|
+
|
|
30
|
+
**Not a scaffolded cron.** The tick is generated as an ordinary function and
|
|
31
|
+
wired by nobody. A `wireScheduler` emitted by codegen would start spending an
|
|
32
|
+
application's model budget the moment somebody ran `pikku all`, on a host that
|
|
33
|
+
may not even run schedulers. One line in the project turns it on:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
wireScheduler({ name: 'virtualUsers', schedule: '0 * * * *', func: tickVirtualUserSchedules })
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Tick resolution bounds how *late* a due persona is, never how often it runs — a
|
|
40
|
+
persona due at 09:07 under an hourly tick starts at 10:00. Running the tick more
|
|
41
|
+
often costs one indexed query and changes no cadence.
|
|
42
|
+
|
|
43
|
+
Three rules make a tick safe to run at any resolution, from any number of
|
|
44
|
+
instances:
|
|
45
|
+
|
|
46
|
+
- **The due time is written before the run is dispatched**, so a tick that dies
|
|
47
|
+
halfway cannot leave the row due for the next one to pick up again. A dispatch
|
|
48
|
+
that throws therefore waits a full interval, which is the right way round: a
|
|
49
|
+
persona failing to start should not be retried every minute for a week.
|
|
50
|
+
- **A persona whose previous run is still `running` is skipped, not queued.**
|
|
51
|
+
Two copies of the same user acting at once is not a heavier test, it is a
|
|
52
|
+
different one, and every finding it produces is unreproducible.
|
|
53
|
+
- **A run still `running` after `STALE_RUN_AFTER_MS` is failed and the persona
|
|
54
|
+
runs again.** This is where the stranded-record cost of
|
|
55
|
+
[a virtual user run being neither a workflow nor a queued job](a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md)
|
|
56
|
+
gets paid: without it, one restart mid-run would block that persona's schedule
|
|
57
|
+
permanently.
|
|
58
|
+
|
|
59
|
+
The interval is a range (`minIntervalMs`, `maxIntervalMs`), drawn per run. A
|
|
60
|
+
user who arrives at exactly 09:00 every day exercises one cache state and one
|
|
61
|
+
cron neighbourhood; a real one does not keep an appointment.
|
|
62
|
+
|
|
63
|
+
**What this rules out:** a `setTimeout` or interval anywhere in the run path;
|
|
64
|
+
the engine scheduling its own next run; a scaffolded scheduled task; a queue
|
|
65
|
+
holding the next run; and a cadence that lives only in a config file, which
|
|
66
|
+
cannot record when the persona last actually went.
|
package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md
CHANGED
|
@@ -37,9 +37,14 @@ left at `running` is neither.
|
|
|
37
37
|
|
|
38
38
|
The cost is real and is stated on the type: **a restart mid-run strands a record
|
|
39
39
|
at `running` with nothing left to finish it.** A run older than its budget
|
|
40
|
-
window and still `running` is dead, not working —
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
window and still `running` is dead, not working — a read-side rule, and cheaper
|
|
41
|
+
than the two dependencies avoided. Nothing retries; a stranded run is started
|
|
42
|
+
again, with its seed if the caller wants the same exploration.
|
|
43
|
+
|
|
44
|
+
Where that rule is actually applied is
|
|
45
|
+
[the schedule tick](a-virtual-user-cadence-is-a-row-not-a-timer.md), which has
|
|
46
|
+
to: a record stuck at `running` would otherwise block its persona's cadence
|
|
47
|
+
forever.
|
|
43
48
|
|
|
44
49
|
**What this rules out:** dispatching the run through `startWorkflow`; a
|
|
45
50
|
scaffolded queue worker; awaiting the engine inside the request (a run takes
|
|
@@ -16,6 +16,7 @@ caller is entitled to assume.
|
|
|
16
16
|
- [A scenario step's prose template is offered to a virtual user unfilled](a-scenario-step-template-is-offered-unfilled.md) — A reporter fills placeholders from a run that happened; there is no run yet, and the filled form would answer the question the user is there to answer
|
|
17
17
|
- [A secret that fails to decrypt fails the whole read](a-secret-that-fails-to-decrypt-fails-the-whole-read.md) — getSecrets throws naming the key and its key_version rather than omitting the row, because a silent omission surfaces as an unrelated failure much later
|
|
18
18
|
- [A virtual user decides whether to trust its notes once per turn, by one roll](a-virtual-user-decides-whether-to-trust-memory-once-per-turn.md) — The difference between the stale, newcomer and auditor dispositions is expressed as a single probability rather than as prose in each prompt
|
|
19
|
+
- [A virtual user cadence is a row, not a timer](a-virtual-user-cadence-is-a-row-not-a-timer.md) — how often a persona runs is stored as a due time per persona and acted on by a tick the project schedules — pikku never starts a timer, and a run never reschedules itself
|
|
19
20
|
- [A virtual user run is not a workflow and not a queued job](a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md) — runVirtualUser writes its record, dispatches the run without awaiting it, and returns the id — because an exploratory run has nothing to replay and the record already carries what a queue would be holding
|
|
20
21
|
- [A wall-clock threshold is a load test in disguise](a-wall-clock-threshold-is-a-load-test-in-disguise.md) — The KEK derivation test asserted a fixed 50ms budget for work that took 10ms, which went red about one run in five once the suite was large enough to compete for the machine
|
|
21
22
|
- [A workflow's wire is built from the run record, not from the RPC service](a-workflow-wire-is-built-from-the-run-not-from-the-rpc-service.md) — The RPC service exposes no wire, so every rpcService.wire read was undefined; the run record is the only thing that carries the caller across a step boundary
|
package/knowledge/decisions/internals/the-ecosystem-entry-point-carries-the-adapter-surface.md
CHANGED
|
@@ -32,12 +32,13 @@ The stability distinction the split was built to express is now carried by
|
|
|
32
32
|
exports. Moving a symbol across an area boundary is still a visible diff; it
|
|
33
33
|
just no longer requires a parallel tree of re-export files to be visible.
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
`bootstrap-compat/root.ts`
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
One module survives at an old specifier and is not an entry point:
|
|
36
|
+
`bootstrap-compat/root.ts` exists because `packages/cli` is generated by the
|
|
37
|
+
published CLI pinned in its `build.sh`, which still emits a bare `@pikku/core`.
|
|
38
|
+
The matching `bootstrap-compat/ecosystem.ts` has already gone, along with every
|
|
39
|
+
`@pikku/core/ecosystem` import in the repo — a guard test now fails if one comes
|
|
40
|
+
back. A test pins the root shim's exact contents so it cannot grow, and it goes
|
|
41
|
+
when the pin moves to a CLI released from this branch.
|
|
41
42
|
|
|
42
43
|
**What this rules out:** re-introducing any specifier that re-exports another
|
|
43
44
|
subpath's names. A curated facade over a module that is already published is the
|
package/package.json
CHANGED
|
@@ -28,8 +28,8 @@ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../..')
|
|
|
28
28
|
/**
|
|
29
29
|
* Membership is discovered, not listed: every directory holding a
|
|
30
30
|
* `pikku.config.json` is a Pikku project, so a project added later arrives
|
|
31
|
-
* guarded rather than invisible. Listing them by hand is how
|
|
32
|
-
* guard reported green on four packages it had never scanned.
|
|
31
|
+
* guarded rather than invisible. Listing them by hand is how an earlier
|
|
32
|
+
* version of this guard reported green on four packages it had never scanned.
|
|
33
33
|
*/
|
|
34
34
|
const skipped = new Set([
|
|
35
35
|
'node_modules',
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { join, dirname, relative } from 'node:path'
|
|
6
|
+
|
|
7
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../..')
|
|
8
|
+
|
|
9
|
+
const skipped = new Set([
|
|
10
|
+
'node_modules',
|
|
11
|
+
'dist',
|
|
12
|
+
'.pikku',
|
|
13
|
+
'.next',
|
|
14
|
+
'build',
|
|
15
|
+
'.git',
|
|
16
|
+
'.deploy',
|
|
17
|
+
'coverage',
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
const collectSourceFiles = (
|
|
21
|
+
directory: string,
|
|
22
|
+
out: string[] = []
|
|
23
|
+
): string[] => {
|
|
24
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
25
|
+
if (entry.isDirectory()) {
|
|
26
|
+
if (!skipped.has(entry.name)) {
|
|
27
|
+
collectSourceFiles(join(directory, entry.name), out)
|
|
28
|
+
}
|
|
29
|
+
} else if (/\.(ts|tsx|js|mjs|mts|cts)$/.test(entry.name)) {
|
|
30
|
+
out.push(join(directory, entry.name))
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A stale compiled `.test.js` next to this file would otherwise read as an
|
|
38
|
+
* offender of its own scan, which is how a sibling removal guard in this
|
|
39
|
+
* directory reports a phantom failure on a dirty tree. Comparing paths with
|
|
40
|
+
* the extension dropped excludes this file and its build artifacts without
|
|
41
|
+
* excluding a neighbour that merely shares the prefix.
|
|
42
|
+
*/
|
|
43
|
+
const withoutExtension = (file: string): string =>
|
|
44
|
+
file.replace(/\.(ts|tsx|js|mjs|mts|cts)$/, '')
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The `@pikku/core/ecosystem/*` tier was deleted in favour of one door per
|
|
48
|
+
* name. Nothing resolves those specifiers any more, but a dead one is easy to
|
|
49
|
+
* miss: a type-only import is erased before it can fail at runtime, and the
|
|
50
|
+
* service packages exclude `**\/*.test.ts` from their tsconfig, so neither the
|
|
51
|
+
* test run nor `yarn tsc` reports it.
|
|
52
|
+
*/
|
|
53
|
+
describe('the ecosystem entry-point tier is gone', () => {
|
|
54
|
+
test('no source file imports from @pikku/core/ecosystem', () => {
|
|
55
|
+
const self = withoutExtension(fileURLToPath(import.meta.url))
|
|
56
|
+
const offenders = collectSourceFiles(repoRoot)
|
|
57
|
+
.filter(
|
|
58
|
+
(file) =>
|
|
59
|
+
withoutExtension(file) !== self &&
|
|
60
|
+
/@pikku\/core\/ecosystem/.test(readFileSync(file, 'utf-8'))
|
|
61
|
+
)
|
|
62
|
+
.map((file) => relative(repoRoot, file))
|
|
63
|
+
assert.deepEqual(
|
|
64
|
+
offenders,
|
|
65
|
+
[],
|
|
66
|
+
`@pikku/core/ecosystem imports found in:\n${offenders.join('\n')}`
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
})
|
package/src/public-surface.json
CHANGED
|
@@ -97,21 +97,27 @@
|
|
|
97
97
|
"./workflow/types": [],
|
|
98
98
|
"./actor-flow": ["runConversation"],
|
|
99
99
|
"./virtual-user": [
|
|
100
|
+
"DEFAULT_MAX_INTERVAL_MS",
|
|
101
|
+
"DEFAULT_MIN_INTERVAL_MS",
|
|
100
102
|
"DISPOSITIONS",
|
|
101
103
|
"IntentStack",
|
|
102
104
|
"PRODUCTION_DISPOSITION",
|
|
105
|
+
"STALE_RUN_AFTER_MS",
|
|
103
106
|
"catalogueClassification",
|
|
104
107
|
"catalogueLookup",
|
|
105
108
|
"deriveCatalogue",
|
|
106
109
|
"deriveIntents",
|
|
107
110
|
"dispositionProfile",
|
|
108
111
|
"intentsForPersona",
|
|
112
|
+
"isDue",
|
|
109
113
|
"isReadOnly",
|
|
114
|
+
"nextRunAt",
|
|
110
115
|
"personaScopes",
|
|
111
116
|
"personaVirtualUserTarget",
|
|
112
117
|
"prepareVirtualUserRun",
|
|
113
118
|
"reachableCatalogue",
|
|
114
119
|
"runVirtualUser",
|
|
120
|
+
"tickVirtualUserSchedules",
|
|
115
121
|
"unreachableCatalogue"
|
|
116
122
|
],
|
|
117
123
|
"./channel/local": [
|
|
@@ -17,6 +17,7 @@ const startAgentTarget = async () => {
|
|
|
17
17
|
let logins = 0
|
|
18
18
|
let authRequired = false
|
|
19
19
|
let approvalsSeen: unknown[] = []
|
|
20
|
+
let firstAgentRequestAuthed: boolean | null = null
|
|
20
21
|
const server: Server = createServer((req, res) => {
|
|
21
22
|
const chunks: Buffer[] = []
|
|
22
23
|
req.on('data', (c) => chunks.push(c))
|
|
@@ -37,6 +38,11 @@ const startAgentTarget = async () => {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
const isAgentRoute = req.url?.startsWith('/api/rpc/agent/')
|
|
41
|
+
if (isAgentRoute && firstAgentRequestAuthed === null) {
|
|
42
|
+
firstAgentRequestAuthed = (req.headers.cookie ?? '').includes(
|
|
43
|
+
'session='
|
|
44
|
+
)
|
|
45
|
+
}
|
|
40
46
|
if (
|
|
41
47
|
isAgentRoute &&
|
|
42
48
|
authRequired &&
|
|
@@ -85,10 +91,13 @@ const startAgentTarget = async () => {
|
|
|
85
91
|
apiUrl: `http://127.0.0.1:${port}/api`,
|
|
86
92
|
loginCount: () => logins,
|
|
87
93
|
approvalsSeen: () => approvalsSeen,
|
|
94
|
+
/** Whether the very first agent call carried a session, not merely that one was minted. */
|
|
95
|
+
firstAgentRequestAuthed: () => firstAgentRequestAuthed,
|
|
88
96
|
reset: (opts?: { authRequired?: boolean }) => {
|
|
89
97
|
agentRuns = 0
|
|
90
98
|
logins = 0
|
|
91
99
|
approvalsSeen = []
|
|
100
|
+
firstAgentRequestAuthed = null
|
|
92
101
|
authRequired = opts?.authRequired ?? false
|
|
93
102
|
},
|
|
94
103
|
}
|
|
@@ -180,8 +189,13 @@ describe('HttpPersona.converse', async () => {
|
|
|
180
189
|
assert.deepEqual(target.approvalsSeen(), [
|
|
181
190
|
[{ toolCallId: 'tc1', approved: true }],
|
|
182
191
|
])
|
|
183
|
-
//
|
|
184
|
-
|
|
192
|
+
// Signed in even though the agent route is public: a thread minted under a
|
|
193
|
+
// fresh anonymous id per request belongs to nobody, so turn two comes back
|
|
194
|
+
// as somebody else's. A persona is a real account either way — and it is
|
|
195
|
+
// the *first* call that has to carry the session, which a login count
|
|
196
|
+
// alone would not show.
|
|
197
|
+
assert.equal(target.loginCount(), 1)
|
|
198
|
+
assert.equal(target.firstAgentRequestAuthed(), true)
|
|
185
199
|
})
|
|
186
200
|
|
|
187
201
|
test('signs in lazily and retries once when an agent route returns 401', async () => {
|
|
@@ -111,6 +111,14 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
111
111
|
if (!agentRunner) {
|
|
112
112
|
throw new AIProviderNotConfiguredError()
|
|
113
113
|
}
|
|
114
|
+
// Signed in here rather than left to postAgent's 401 retry, which a public
|
|
115
|
+
// agent route never triggers. An unowned thread is minted under a fresh
|
|
116
|
+
// anonymous id per request, so turn one succeeds and turn two is refused as
|
|
117
|
+
// somebody else's — and a persona is a real account with real credentials,
|
|
118
|
+
// so there is no case where conversing as nobody is the intent.
|
|
119
|
+
if (!this.signedIn) {
|
|
120
|
+
await this.login()
|
|
121
|
+
}
|
|
114
122
|
const model = options.model ?? this.config.model
|
|
115
123
|
if (!model) {
|
|
116
124
|
throw new Error(
|
package/src/types/core.types.ts
CHANGED
|
@@ -41,6 +41,7 @@ import type { AgentRunService } from '../wirings/agent/agent.types.js'
|
|
|
41
41
|
import type { MiddlewareMetadata } from '../middleware/middleware.types.js'
|
|
42
42
|
import type { PermissionMetadata } from '../function/function-meta.types.js'
|
|
43
43
|
import type { VirtualUserRunStore } from '../wirings/virtual-user/virtual-user-run-store.js'
|
|
44
|
+
import type { VirtualUserScheduleStore } from '../wirings/virtual-user/virtual-user-schedule-store.js'
|
|
44
45
|
import type { WorkflowRunService } from '../wirings/workflow/workflow.types.js'
|
|
45
46
|
import type { CredentialService } from '../services/credential-service.js'
|
|
46
47
|
import type { EmailService } from '../services/email-service.js'
|
|
@@ -173,6 +174,12 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
|
|
|
173
174
|
* {@link VirtualUserRunStore}.
|
|
174
175
|
*/
|
|
175
176
|
virtualUserRunStore?: VirtualUserRunStore
|
|
177
|
+
/**
|
|
178
|
+
* Each persona's cadence, for apps that want their virtual users to keep
|
|
179
|
+
* going without being asked. Separate from the run store on purpose: wiring
|
|
180
|
+
* nothing is how an app says it only wants the runs it starts itself.
|
|
181
|
+
*/
|
|
182
|
+
virtualUserScheduleStore?: VirtualUserScheduleStore
|
|
176
183
|
/** V8 precise-coverage collector (`pikku dev --coverage` only) */
|
|
177
184
|
coverageService?: CoverageService
|
|
178
185
|
audit?: AuditService
|
|
@@ -596,6 +596,106 @@ describe('ContextAwareRPCService.rpcWithWire', () => {
|
|
|
596
596
|
['missingRpc', { value: 2 }, { userId: 'user-2' }, 'trace-3'],
|
|
597
597
|
])
|
|
598
598
|
})
|
|
599
|
+
|
|
600
|
+
test('a missing namespaced rpc still reaches deploymentService through rpcWithWire', async () => {
|
|
601
|
+
const remoteCalls: unknown[][] = []
|
|
602
|
+
pikkuState(null, 'addons', 'packages').set('stripe', {
|
|
603
|
+
package: '@addon/stripe',
|
|
604
|
+
} as never)
|
|
605
|
+
|
|
606
|
+
const service = new ContextAwareRPCService(
|
|
607
|
+
createServices({
|
|
608
|
+
deploymentService: {
|
|
609
|
+
invoke: async (...args: unknown[]) => {
|
|
610
|
+
remoteCalls.push(args)
|
|
611
|
+
return { remote: true }
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
}),
|
|
615
|
+
{ traceId: 'trace-addon-wire' } as never,
|
|
616
|
+
{}
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
const result = await service.rpcWithWire(
|
|
620
|
+
'stripe:missingFunc',
|
|
621
|
+
{ value: 3 },
|
|
622
|
+
{ custom: 'wire' } as never
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
assert.deepEqual(result, { remote: true })
|
|
626
|
+
assert.deepEqual(remoteCalls, [
|
|
627
|
+
['stripe:missingFunc', { value: 3 }, undefined, 'trace-addon-wire'],
|
|
628
|
+
])
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
test('an unknown namespace still falls through to the local/remote lookup', async () => {
|
|
632
|
+
const remoteCalls: unknown[][] = []
|
|
633
|
+
const service = new ContextAwareRPCService(
|
|
634
|
+
createServices({
|
|
635
|
+
deploymentService: {
|
|
636
|
+
invoke: async (...args: unknown[]) => {
|
|
637
|
+
remoteCalls.push(args)
|
|
638
|
+
return { remote: true }
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
}),
|
|
642
|
+
{ traceId: 'trace-ns-wire' } as never,
|
|
643
|
+
{}
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
const result = await service.rpcWithWire(
|
|
647
|
+
'unknownNs:someFunc',
|
|
648
|
+
{ value: 1 },
|
|
649
|
+
{ custom: 'wire' } as never
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
assert.deepEqual(result, { remote: true })
|
|
653
|
+
assert.deepEqual(remoteCalls, [
|
|
654
|
+
['unknownNs:someFunc', { value: 1 }, undefined, 'trace-ns-wire'],
|
|
655
|
+
])
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
test('the deployment fallback runs under the wire the caller passed, not the ambient one', async () => {
|
|
659
|
+
const remoteCalls: unknown[][] = []
|
|
660
|
+
const service = new ContextAwareRPCService(
|
|
661
|
+
createServices({
|
|
662
|
+
deploymentService: {
|
|
663
|
+
invoke: async (...args: unknown[]) => {
|
|
664
|
+
remoteCalls.push(args)
|
|
665
|
+
return { remote: true }
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
}),
|
|
669
|
+
{
|
|
670
|
+
traceId: 'ambient-trace',
|
|
671
|
+
session: { userId: 'ambient-user' },
|
|
672
|
+
} as never,
|
|
673
|
+
{}
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
const result = await service.rpcWithWire(
|
|
677
|
+
'unknownNs:someFunc',
|
|
678
|
+
{ value: 1 },
|
|
679
|
+
{
|
|
680
|
+
traceId: 'caller-trace',
|
|
681
|
+
session: { userId: 'caller-user' },
|
|
682
|
+
} as never
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
assert.deepEqual(result, { remote: true })
|
|
686
|
+
assert.deepEqual(
|
|
687
|
+
remoteCalls,
|
|
688
|
+
[
|
|
689
|
+
[
|
|
690
|
+
'unknownNs:someFunc',
|
|
691
|
+
{ value: 1 },
|
|
692
|
+
{ userId: 'caller-user' },
|
|
693
|
+
'caller-trace',
|
|
694
|
+
],
|
|
695
|
+
],
|
|
696
|
+
'the remote hop ran under the ambient wire, so an explicit wire is honoured locally but dropped across the deployment boundary'
|
|
697
|
+
)
|
|
698
|
+
})
|
|
599
699
|
})
|
|
600
700
|
|
|
601
701
|
describe('ContextAwareRPCService.startWorkflow', () => {
|
|
@@ -393,10 +393,13 @@ export class ContextAwareRPCService {
|
|
|
393
393
|
|
|
394
394
|
if (rpcName.includes(':')) {
|
|
395
395
|
const addonCall = this.resolveAddonFunction(rpcName)
|
|
396
|
-
if (addonCall
|
|
397
|
-
|
|
396
|
+
if (addonCall !== NOT_RESOLVED) {
|
|
397
|
+
return await this.executeAddonFunction<In, Out>(
|
|
398
|
+
addonCall,
|
|
399
|
+
data,
|
|
400
|
+
mergedWire
|
|
401
|
+
)
|
|
398
402
|
}
|
|
399
|
-
return this.executeAddonFunction<In, Out>(addonCall, data, mergedWire)
|
|
400
403
|
}
|
|
401
404
|
|
|
402
405
|
let resolved: { pikkuFuncId: string; packageName: string | null }
|
|
@@ -404,12 +407,12 @@ export class ContextAwareRPCService {
|
|
|
404
407
|
resolved = resolvePikkuFunction(rpcName, this.packageName)
|
|
405
408
|
} catch (e) {
|
|
406
409
|
if (e instanceof RPCNotFoundError && this.services.deploymentService) {
|
|
407
|
-
const session = await resolveWireSession(
|
|
410
|
+
const session = await resolveWireSession(mergedWire)
|
|
408
411
|
return this.services.deploymentService.invoke(
|
|
409
412
|
rpcName,
|
|
410
413
|
data,
|
|
411
414
|
session,
|
|
412
|
-
|
|
415
|
+
mergedWire.traceId
|
|
413
416
|
) as Promise<Out>
|
|
414
417
|
}
|
|
415
418
|
throw e
|
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
*/
|
|
19
19
|
export type {
|
|
20
20
|
ApiCatalogueEntry,
|
|
21
|
+
IntentRecord,
|
|
21
22
|
IntentSource,
|
|
23
|
+
StepRecord,
|
|
24
|
+
VirtualUserBudget,
|
|
22
25
|
VirtualUserDisposition,
|
|
23
26
|
VirtualUserFinding,
|
|
24
27
|
VirtualUserRunResult,
|
|
@@ -40,6 +43,21 @@ export type {
|
|
|
40
43
|
VirtualUserRunStart,
|
|
41
44
|
VirtualUserRunStore,
|
|
42
45
|
} from './virtual-user-run-store.js'
|
|
46
|
+
export type {
|
|
47
|
+
VirtualUserScheduleInput,
|
|
48
|
+
VirtualUserScheduleRecord,
|
|
49
|
+
VirtualUserScheduleStore,
|
|
50
|
+
} from './virtual-user-schedule-store.js'
|
|
51
|
+
export {
|
|
52
|
+
DEFAULT_MAX_INTERVAL_MS,
|
|
53
|
+
DEFAULT_MIN_INTERVAL_MS,
|
|
54
|
+
isDue,
|
|
55
|
+
nextRunAt,
|
|
56
|
+
STALE_RUN_AFTER_MS,
|
|
57
|
+
tickVirtualUserSchedules,
|
|
58
|
+
type VirtualUserTickParams,
|
|
59
|
+
type VirtualUserTickResult,
|
|
60
|
+
} from './virtual-user-schedule.js'
|
|
43
61
|
export {
|
|
44
62
|
DISPOSITIONS,
|
|
45
63
|
dispositionProfile,
|
|
@@ -4,16 +4,13 @@ import { reachableAgents } from './virtual-user-agents.js'
|
|
|
4
4
|
|
|
5
5
|
const AGENTS = {
|
|
6
6
|
'router-agent': {
|
|
7
|
-
name: 'router-agent',
|
|
8
7
|
description: 'Routes requests to the right domain agent',
|
|
9
8
|
},
|
|
10
9
|
'social-poster': {
|
|
11
|
-
name: 'social-poster',
|
|
12
10
|
description: 'Drafts and schedules posts',
|
|
13
11
|
scopes: ['content:write'],
|
|
14
12
|
},
|
|
15
13
|
'refund-agent': {
|
|
16
|
-
name: 'refund-agent',
|
|
17
14
|
scopes: ['billing:write'],
|
|
18
15
|
},
|
|
19
16
|
}
|
|
@@ -58,7 +55,14 @@ describe('reachableAgents', () => {
|
|
|
58
55
|
assert.deepEqual(refund, { name: 'refund-agent' })
|
|
59
56
|
})
|
|
60
57
|
|
|
61
|
-
test('
|
|
58
|
+
test('offers the registration key, not the display name the agent declares', () => {
|
|
59
|
+
assert.deepEqual(
|
|
60
|
+
reachableAgents({ adminAgent: { description: 'Runs the place' } }),
|
|
61
|
+
[{ name: 'adminAgent', description: 'Runs the place' }]
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('an agent carrying nothing but a key is still offered under it', () => {
|
|
62
66
|
assert.deepEqual(reachableAgents({ orphan: {} }), [{ name: 'orphan' }])
|
|
63
67
|
})
|
|
64
68
|
|
|
@@ -16,7 +16,6 @@ import { hasScopes } from '../../scopes.js'
|
|
|
16
16
|
|
|
17
17
|
/** The part of an agent's meta this needs. */
|
|
18
18
|
export interface AgentReachability {
|
|
19
|
-
name?: string
|
|
20
19
|
description?: string
|
|
21
20
|
scopes?: readonly string[]
|
|
22
21
|
auth?: boolean
|
|
@@ -29,7 +28,13 @@ export interface ReachableAgent {
|
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
/**
|
|
32
|
-
* The agents to offer,
|
|
31
|
+
* The agents to offer, named by the key they are registered under.
|
|
32
|
+
*
|
|
33
|
+
* That key is the export's own name, which is what `addAgent` stores and what
|
|
34
|
+
* `resolveAgent` looks up. The `name` an agent declares in its config is a
|
|
35
|
+
* display label and is frequently something else entirely — offering that one
|
|
36
|
+
* hands the persona a name the server cannot resolve, and the run dies on a
|
|
37
|
+
* 500 the moment it takes the offer.
|
|
33
38
|
*
|
|
34
39
|
* Like {@link reachableCatalogue}, this narrows *what is offered* and never
|
|
35
40
|
* what is enforced: the server decides who may talk to what, and an agent
|
|
@@ -52,6 +57,6 @@ export const reachableAgents = (
|
|
|
52
57
|
return hasScopes(agent.scopes, scopes)
|
|
53
58
|
})
|
|
54
59
|
.map(([id, agent]) => ({
|
|
55
|
-
name:
|
|
60
|
+
name: id,
|
|
56
61
|
...(agent.description ? { description: agent.description } : {}),
|
|
57
62
|
}))
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
IntentRecord,
|
|
3
|
+
StepRecord,
|
|
2
4
|
VirtualUserDisposition,
|
|
3
5
|
VirtualUserFinding,
|
|
4
6
|
VirtualUserTally,
|
|
@@ -39,6 +41,16 @@ export interface VirtualUserRunRecord {
|
|
|
39
41
|
*/
|
|
40
42
|
memory: Record<string, string>
|
|
41
43
|
findings: VirtualUserFinding[]
|
|
44
|
+
/**
|
|
45
|
+
* What the user set out to do and how far each one got, which is the spine a
|
|
46
|
+
* transcript hangs off — the steps alone are a list of calls with no account
|
|
47
|
+
* of what they were for.
|
|
48
|
+
*
|
|
49
|
+
* Small and bounded, so it rides on the run row rather than in a table of its
|
|
50
|
+
* own: a run has as many intents as the app has scenarios, and every read of
|
|
51
|
+
* the run wants them.
|
|
52
|
+
*/
|
|
53
|
+
intents: IntentRecord[]
|
|
42
54
|
tally: VirtualUserTally | null
|
|
43
55
|
/** Which budget or stopping rule ended the run. */
|
|
44
56
|
stoppedBy: string | null
|
|
@@ -69,6 +81,16 @@ export interface VirtualUserRunOutcome {
|
|
|
69
81
|
tally: VirtualUserTally
|
|
70
82
|
memory: Record<string, string>
|
|
71
83
|
stoppedBy: string | null
|
|
84
|
+
intents: readonly IntentRecord[]
|
|
85
|
+
/**
|
|
86
|
+
* Every turn the run took. Kept because a finding is an assertion until you
|
|
87
|
+
* can see what the user did before it, and because a run that found nothing
|
|
88
|
+
* is only readable as work through its steps.
|
|
89
|
+
*
|
|
90
|
+
* Stored apart from the run — see {@link VirtualUserRunStore.steps} — so
|
|
91
|
+
* listing runs does not drag a budget's worth of turns along with it.
|
|
92
|
+
*/
|
|
93
|
+
steps: readonly StepRecord[]
|
|
72
94
|
}
|
|
73
95
|
|
|
74
96
|
/**
|
|
@@ -95,4 +117,15 @@ export interface VirtualUserRunStore {
|
|
|
95
117
|
limit?: number
|
|
96
118
|
offset?: number
|
|
97
119
|
}): Promise<VirtualUserRunRecord[]>
|
|
120
|
+
/**
|
|
121
|
+
* One run's turns, in the order they happened.
|
|
122
|
+
*
|
|
123
|
+
* Its own call rather than a field on the record: a run at a 500-step budget
|
|
124
|
+
* carries more transcript than every other column put together, and `list`
|
|
125
|
+
* would pay for it on every row.
|
|
126
|
+
*/
|
|
127
|
+
steps(
|
|
128
|
+
runId: string,
|
|
129
|
+
options?: { limit?: number; offset?: number }
|
|
130
|
+
): Promise<StepRecord[]>
|
|
98
131
|
}
|