@pikku/core 0.12.89 → 0.12.91
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 +181 -0
- package/dist/services/http-personas.d.ts +21 -7
- package/dist/services/http-personas.js +39 -28
- package/dist/services/persona-sign-in.d.ts +107 -0
- package/dist/services/persona-sign-in.js +179 -0
- package/dist/types/core.types.d.ts +7 -0
- package/dist/wirings/persona/index.d.ts +1 -0
- package/dist/wirings/persona/index.js +1 -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/dist/wirings/workflow/scenario-cookie-jar.js +10 -1
- 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 +10 -0
- package/src/services/http-personas-converse.test.ts +16 -2
- package/src/services/http-personas.ts +60 -33
- package/src/services/persona-sign-in.test.ts +209 -0
- package/src/services/persona-sign-in.ts +284 -0
- package/src/types/core.types.ts +7 -0
- package/src/wirings/persona/index.ts +9 -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/src/wirings/workflow/scenario-cookie-jar.ts +12 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import type { ResolvedPersona } from './personas-service.js'
|
|
2
|
+
import type { ScenarioCookieJar } from '../wirings/workflow/scenario-cookie-jar.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The header `resolveImpersonatedSession` reads the target user id from.
|
|
6
|
+
*
|
|
7
|
+
* A wire value rather than a shared import: the reader lives in
|
|
8
|
+
* `@pikku/services-better-auth`, which depends on core, so core cannot import
|
|
9
|
+
* it back. The two agree by protocol, the way an HTTP header always does.
|
|
10
|
+
*/
|
|
11
|
+
export const IMPERSONATE_USER_ID_HEADER = 'x-pikku-impersonate-user-id'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How a persona obtains a session on the target, and what every later request
|
|
15
|
+
* needs to carry to keep acting as them.
|
|
16
|
+
*
|
|
17
|
+
* Two answers exist because the two environments have opposite trust models,
|
|
18
|
+
* not because one is a fallback for the other. See {@link ActorSignIn} and
|
|
19
|
+
* {@link OperatorSignIn}.
|
|
20
|
+
*/
|
|
21
|
+
export interface PersonaSignIn {
|
|
22
|
+
/**
|
|
23
|
+
* Establish a session in `jar`. Throws on failure with a message naming the
|
|
24
|
+
* persona, since a run that continues unauthenticated fails later and
|
|
25
|
+
* somewhere less informative.
|
|
26
|
+
*/
|
|
27
|
+
login(jar: ScenarioCookieJar, persona: ResolvedPersona): Promise<void>
|
|
28
|
+
/** Headers every request after `login` must carry. */
|
|
29
|
+
headers(): Record<string, string>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const failed = async (
|
|
33
|
+
what: string,
|
|
34
|
+
personaId: string,
|
|
35
|
+
res: Response
|
|
36
|
+
): Promise<Error> => {
|
|
37
|
+
const body = (await res.text().catch(() => '')).slice(0, 300)
|
|
38
|
+
return new Error(
|
|
39
|
+
`[scenario] ${what} failed for '${personaId}' (${res.status}): ${body}`
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Sign a persona in through the Better Auth actor plugin — the local-development
|
|
45
|
+
* path.
|
|
46
|
+
*
|
|
47
|
+
* `POST /auth/sign-in/actor` upserts an `actor: true` row and mints a session
|
|
48
|
+
* for it. Passwordless by design and refused for any row not carrying that flag,
|
|
49
|
+
* so the secret can never reach a real user's account; the plugin still declines
|
|
50
|
+
* to serve the endpoint at all outside `pikku dev`.
|
|
51
|
+
*/
|
|
52
|
+
export class ActorSignIn implements PersonaSignIn {
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly apiUrl: string,
|
|
55
|
+
private readonly secret: string,
|
|
56
|
+
private readonly signInPath: string
|
|
57
|
+
) {}
|
|
58
|
+
|
|
59
|
+
async login(jar: ScenarioCookieJar, persona: ResolvedPersona): Promise<void> {
|
|
60
|
+
const res = await jar.fetch(`${this.apiUrl}${this.signInPath}`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'content-type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({
|
|
64
|
+
email: persona.email,
|
|
65
|
+
name: persona.name,
|
|
66
|
+
secret: this.secret,
|
|
67
|
+
}),
|
|
68
|
+
})
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
throw await failed('persona sign-in', persona.id, res)
|
|
71
|
+
}
|
|
72
|
+
// What proves a session was established is this response setting a cookie,
|
|
73
|
+
// not the jar being non-empty — the target may have set one earlier.
|
|
74
|
+
if (res.headers.getSetCookie().length === 0) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`[scenario] persona sign-in for '${persona.id}' returned no session cookie`
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
headers(): Record<string, string> {
|
|
82
|
+
return {}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface OperatorSignInOptions {
|
|
87
|
+
/**
|
|
88
|
+
* The short-lived RS256 operator token, or a function that mints one. Prefer
|
|
89
|
+
* the function: tokens expire, and a long run re-logs-in after a 401.
|
|
90
|
+
*/
|
|
91
|
+
token: string | (() => string | Promise<string>)
|
|
92
|
+
/**
|
|
93
|
+
* Create the persona's user row when the target has no account for that
|
|
94
|
+
* address.
|
|
95
|
+
*
|
|
96
|
+
* Off by default, which is the whole point of the deployed path: a persona is
|
|
97
|
+
* meant to be a real account somebody provisioned, and a test run that
|
|
98
|
+
* silently writes users into a live database is a side effect nobody asked
|
|
99
|
+
* for. Turn it on for throwaway stages.
|
|
100
|
+
*/
|
|
101
|
+
createMissing?: boolean
|
|
102
|
+
/** Admin endpoint prefix under apiUrl. Default `/auth/admin`. */
|
|
103
|
+
adminPath?: string
|
|
104
|
+
/** Fabric operator sign-in path under apiUrl. Default `/auth/sign-in/fabric`. */
|
|
105
|
+
signInPath?: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface AdminUser {
|
|
109
|
+
id?: unknown
|
|
110
|
+
email?: unknown
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** What an operator handshake yields: the session, and who to act as. */
|
|
114
|
+
export interface OperatorSessionResult {
|
|
115
|
+
/** `Set-Cookie` values the operator sign-in returned. */
|
|
116
|
+
setCookies: string[]
|
|
117
|
+
/** The target's own id for the persona, for the impersonation header. */
|
|
118
|
+
userId: string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Establish a Fabric operator session against `apiUrl` and resolve the target's
|
|
123
|
+
* own id for `persona`, which is what the impersonation header names.
|
|
124
|
+
*
|
|
125
|
+
* Takes the fetch to use rather than making one, because the two callers need
|
|
126
|
+
* the cookies to land in different places: an HTTP persona keeps them in its
|
|
127
|
+
* jar, a browser run plants them on a Playwright context. Both need the same
|
|
128
|
+
* handshake, and it is the kind of sequence that quietly diverges once it is
|
|
129
|
+
* written twice.
|
|
130
|
+
*/
|
|
131
|
+
export const establishOperatorSession = async (
|
|
132
|
+
fetchImpl: typeof fetch,
|
|
133
|
+
apiUrl: string,
|
|
134
|
+
persona: ResolvedPersona,
|
|
135
|
+
options: OperatorSignInOptions,
|
|
136
|
+
extraHeaders: Record<string, string> = {}
|
|
137
|
+
): Promise<OperatorSessionResult> => {
|
|
138
|
+
const signInPath = options.signInPath ?? '/auth/sign-in/fabric'
|
|
139
|
+
const token =
|
|
140
|
+
typeof options.token === 'function' ? await options.token() : options.token
|
|
141
|
+
|
|
142
|
+
const res = await fetchImpl(`${apiUrl}${signInPath}`, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: { 'content-type': 'application/json', ...extraHeaders },
|
|
145
|
+
body: JSON.stringify({ token }),
|
|
146
|
+
})
|
|
147
|
+
if (!res.ok) {
|
|
148
|
+
throw await failed('operator sign-in', persona.id, res)
|
|
149
|
+
}
|
|
150
|
+
const setCookies = res.headers.getSetCookie?.() ?? []
|
|
151
|
+
if (setCookies.length === 0) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`[scenario] operator sign-in for '${persona.id}' returned no session cookie`
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The lookup runs on the session this handshake just established, and a
|
|
158
|
+
// plain `fetch` keeps no cookies — the browser path in particular hands the
|
|
159
|
+
// jar's contents to Playwright only after this returns. Forwarding them
|
|
160
|
+
// explicitly is what keeps the admin calls authenticated for every caller.
|
|
161
|
+
const session = setCookies
|
|
162
|
+
.map((raw) => raw.split(';')[0])
|
|
163
|
+
.filter((pair): pair is string => Boolean(pair))
|
|
164
|
+
.join('; ')
|
|
165
|
+
|
|
166
|
+
const userId = await resolveUserId(fetchImpl, apiUrl, persona, options, {
|
|
167
|
+
...extraHeaders,
|
|
168
|
+
cookie: session,
|
|
169
|
+
})
|
|
170
|
+
return { setCookies, userId }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The target's own id for this persona's address, since impersonation names a
|
|
175
|
+
* user id and a persona only knows an email.
|
|
176
|
+
*
|
|
177
|
+
* Looked up before creating, so a persona that already exists is never
|
|
178
|
+
* duplicated and the run reads as "act as this person" rather than "make one".
|
|
179
|
+
*/
|
|
180
|
+
const resolveUserId = async (
|
|
181
|
+
fetchImpl: typeof fetch,
|
|
182
|
+
apiUrl: string,
|
|
183
|
+
persona: ResolvedPersona,
|
|
184
|
+
options: OperatorSignInOptions,
|
|
185
|
+
extraHeaders: Record<string, string>
|
|
186
|
+
): Promise<string> => {
|
|
187
|
+
const adminPath = options.adminPath ?? '/auth/admin'
|
|
188
|
+
const query = new URLSearchParams({
|
|
189
|
+
filterField: 'email',
|
|
190
|
+
filterValue: persona.email,
|
|
191
|
+
filterOperator: 'eq',
|
|
192
|
+
limit: '1',
|
|
193
|
+
})
|
|
194
|
+
const found = await fetchImpl(`${apiUrl}${adminPath}/list-users?${query}`, {
|
|
195
|
+
headers: { accept: 'application/json', ...extraHeaders },
|
|
196
|
+
})
|
|
197
|
+
if (!found.ok) {
|
|
198
|
+
throw await failed('persona lookup', persona.id, found)
|
|
199
|
+
}
|
|
200
|
+
const listed = (await found.json().catch(() => null)) as {
|
|
201
|
+
users?: AdminUser[]
|
|
202
|
+
} | null
|
|
203
|
+
const existing = listed?.users?.find((u) => u.email === persona.email)
|
|
204
|
+
if (existing?.id) {
|
|
205
|
+
return String(existing.id)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!options.createMissing) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`[scenario] no account on the target for persona '${persona.id}' (${persona.email}) — ` +
|
|
211
|
+
'provision it, or set createMissing on the operator credentials'
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const created = await fetchImpl(`${apiUrl}${adminPath}/create-user`, {
|
|
216
|
+
method: 'POST',
|
|
217
|
+
headers: { 'content-type': 'application/json', ...extraHeaders },
|
|
218
|
+
body: JSON.stringify({
|
|
219
|
+
email: persona.email,
|
|
220
|
+
name: persona.name,
|
|
221
|
+
// Never used and never returned: the run impersonates rather than signs
|
|
222
|
+
// in, so the account is reachable only by someone already holding an
|
|
223
|
+
// operator token. A derivable password would undo exactly that.
|
|
224
|
+
password: globalThis.crypto.randomUUID(),
|
|
225
|
+
...(persona.roles[0] ? { role: persona.roles[0] } : {}),
|
|
226
|
+
}),
|
|
227
|
+
})
|
|
228
|
+
if (!created.ok) {
|
|
229
|
+
throw await failed('persona creation', persona.id, created)
|
|
230
|
+
}
|
|
231
|
+
const body = (await created.json().catch(() => null)) as {
|
|
232
|
+
user?: AdminUser
|
|
233
|
+
} | null
|
|
234
|
+
const id = body?.user?.id
|
|
235
|
+
if (!id) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`[scenario] creating persona '${persona.id}' returned no user id`
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
return String(id)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Sign a persona in on a DEPLOYED stage, by having a Fabric operator act as
|
|
245
|
+
* them — the path that needs no test credential to exist anywhere.
|
|
246
|
+
*
|
|
247
|
+
* `POST /auth/sign-in/fabric` verifies an RS256 token against the stage's
|
|
248
|
+
* `FABRIC_AUTH_PUBLIC_KEY` and mints a session for a synthetic operator row
|
|
249
|
+
* granted the umbrella `admin` scope. Impersonation is then a header on each
|
|
250
|
+
* request rather than a second session, and its gate is that scope — not
|
|
251
|
+
* `user.role`, which is why this works without touching the app's roles.
|
|
252
|
+
*
|
|
253
|
+
* Asymmetric throughout: the stage can verify an operator token and never mint
|
|
254
|
+
* one, so nothing in a deployed environment is worth stealing. That is the
|
|
255
|
+
* property the actor secret cannot have, and the reason these are two classes
|
|
256
|
+
* instead of one with a flag.
|
|
257
|
+
*/
|
|
258
|
+
export class OperatorSignIn implements PersonaSignIn {
|
|
259
|
+
private userId: string | null = null
|
|
260
|
+
|
|
261
|
+
constructor(
|
|
262
|
+
private readonly apiUrl: string,
|
|
263
|
+
private readonly options: OperatorSignInOptions
|
|
264
|
+
) {}
|
|
265
|
+
|
|
266
|
+
async login(jar: ScenarioCookieJar, persona: ResolvedPersona): Promise<void> {
|
|
267
|
+
const { userId } = await establishOperatorSession(
|
|
268
|
+
jar.fetch,
|
|
269
|
+
this.apiUrl,
|
|
270
|
+
persona,
|
|
271
|
+
this.options
|
|
272
|
+
)
|
|
273
|
+
this.userId = userId
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
headers(): Record<string, string> {
|
|
277
|
+
if (!this.userId) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
'[scenario] operator session has no persona to act as — login() first'
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
return { [IMPERSONATE_USER_ID_HEADER]: this.userId }
|
|
283
|
+
}
|
|
284
|
+
}
|
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
|
|
@@ -45,6 +45,15 @@ export {
|
|
|
45
45
|
createHttpPersonas,
|
|
46
46
|
type HttpPersonasConfig,
|
|
47
47
|
} from '../../services/http-personas.js'
|
|
48
|
+
export {
|
|
49
|
+
ActorSignIn,
|
|
50
|
+
OperatorSignIn,
|
|
51
|
+
establishOperatorSession,
|
|
52
|
+
IMPERSONATE_USER_ID_HEADER,
|
|
53
|
+
type PersonaSignIn,
|
|
54
|
+
type OperatorSignInOptions,
|
|
55
|
+
type OperatorSessionResult,
|
|
56
|
+
} from '../../services/persona-sign-in.js'
|
|
48
57
|
export {
|
|
49
58
|
postScenarioJson,
|
|
50
59
|
readScenarioHttpResponse,
|
|
@@ -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
|
}
|