@pikku/core 0.12.51 → 0.12.53
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 +74 -0
- package/dist/services/http-scenario-actors.d.ts +67 -0
- package/dist/services/http-scenario-actors.js +193 -0
- package/dist/services/http-user-flow-actors.d.ts +20 -0
- package/dist/services/http-user-flow-actors.js +100 -0
- package/dist/services/index.d.ts +5 -2
- package/dist/services/index.js +4 -1
- package/dist/services/istanbul-coverage-service.d.ts +12 -0
- package/dist/services/istanbul-coverage-service.js +41 -0
- package/dist/services/meta-service.d.ts +4 -4
- package/dist/services/meta-service.js +8 -8
- package/dist/services/scenario-actors-service.d.ts +21 -0
- package/dist/services/scenario-actors-service.js +1 -0
- package/dist/services/stub-tracker.d.ts +43 -0
- package/dist/services/stub-tracker.js +146 -0
- package/dist/services/user-flow-actors-service.d.ts +11 -1
- package/dist/services/v8-coverage-service.d.ts +41 -0
- package/dist/services/v8-coverage-service.js +63 -0
- package/dist/types/core.types.d.ts +13 -4
- package/dist/wirings/actor-flow/actor-flow.types.d.ts +68 -0
- package/dist/wirings/actor-flow/actor-flow.types.js +1 -0
- package/dist/wirings/actor-flow/index.d.ts +11 -0
- package/dist/wirings/actor-flow/index.js +1 -0
- package/dist/wirings/actor-flow/run-conversation.d.ts +36 -0
- package/dist/wirings/actor-flow/run-conversation.js +181 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +22 -6
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +4 -2
- package/dist/wirings/workflow/pikku-workflow-service.js +92 -2
- package/dist/wirings/workflow/workflow.types.d.ts +7 -7
- package/package.json +2 -1
- package/src/services/http-scenario-actors-converse.test.ts +222 -0
- package/src/services/{http-user-flow-actors.test.ts → http-scenario-actors.test.ts} +17 -7
- package/src/services/http-scenario-actors.ts +269 -0
- package/src/services/index.ts +27 -8
- package/src/services/istanbul-coverage-service.test.ts +66 -0
- package/src/services/istanbul-coverage-service.ts +65 -0
- package/src/services/meta-service.ts +9 -9
- package/src/services/scenario-actors-service.ts +27 -0
- package/src/services/stub-tracker.test.ts +104 -0
- package/src/services/stub-tracker.ts +185 -0
- package/src/services/v8-coverage-service.test.ts +69 -0
- package/src/services/v8-coverage-service.ts +121 -0
- package/src/types/core.types.ts +13 -4
- package/src/wirings/actor-flow/actor-flow.types.ts +73 -0
- package/src/wirings/actor-flow/index.ts +22 -0
- package/src/wirings/actor-flow/run-conversation.test.ts +176 -0
- package/src/wirings/actor-flow/run-conversation.ts +285 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +35 -6
- package/src/wirings/workflow/pikku-workflow-service.ts +144 -5
- package/src/wirings/workflow/{user-flow-step.test.ts → scenario-step.test.ts} +19 -14
- package/src/wirings/workflow/workflow.types.ts +8 -6
- package/tsconfig.tsbuildinfo +1 -1
- package/src/services/http-user-flow-actors.ts +0 -132
- package/src/services/user-flow-actors-service.ts +0 -31
|
@@ -2,7 +2,7 @@ import { describe, test, after } from 'node:test'
|
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
3
|
import { createServer, type Server } from 'node:http'
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { createHttpScenarioActors } from './http-scenario-actors.js'
|
|
6
6
|
|
|
7
7
|
// Minimal target app: actor sign-in endpoint + exposed RPC endpoint, session
|
|
8
8
|
// via cookie. Mirrors the Better Auth actor plugin's contract.
|
|
@@ -13,10 +13,14 @@ const startTarget = async () => {
|
|
|
13
13
|
const chunks: Buffer[] = []
|
|
14
14
|
req.on('data', (c) => chunks.push(c))
|
|
15
15
|
req.on('end', () => {
|
|
16
|
-
const body = chunks.length
|
|
16
|
+
const body = chunks.length
|
|
17
|
+
? JSON.parse(Buffer.concat(chunks).toString())
|
|
18
|
+
: {}
|
|
17
19
|
if (req.url === '/api/auth/sign-in/actor') {
|
|
18
20
|
if (body.secret !== 'impersonation-secret') {
|
|
19
|
-
res
|
|
21
|
+
res
|
|
22
|
+
.writeHead(401)
|
|
23
|
+
.end(JSON.stringify({ message: 'bad actor secret' }))
|
|
20
24
|
return
|
|
21
25
|
}
|
|
22
26
|
logins++
|
|
@@ -58,12 +62,12 @@ const startTarget = async () => {
|
|
|
58
62
|
}
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
describe('
|
|
65
|
+
describe('HttpScenarioActor', async () => {
|
|
62
66
|
const target = await startTarget()
|
|
63
67
|
after(() => target.server.close())
|
|
64
68
|
|
|
65
69
|
const makeActors = (secret = 'impersonation-secret') =>
|
|
66
|
-
|
|
70
|
+
createHttpScenarioActors({
|
|
67
71
|
apiUrl: target.apiUrl,
|
|
68
72
|
secret,
|
|
69
73
|
actors: {
|
|
@@ -82,14 +86,20 @@ describe('HttpUserFlowActor', async () => {
|
|
|
82
86
|
test('logs in lazily once and replays the session cookie on RPCs', async () => {
|
|
83
87
|
const actors = makeActors()
|
|
84
88
|
|
|
85
|
-
const first = (await actors.customer!.invoke('createTodo', {
|
|
89
|
+
const first = (await actors.customer!.invoke('createTodo', {
|
|
90
|
+
title: 'x',
|
|
91
|
+
})) as any
|
|
86
92
|
const second = (await actors.customer!.invoke('listTodos', {})) as any
|
|
87
93
|
|
|
88
94
|
assert.equal(first.rpcName, 'createTodo')
|
|
89
95
|
assert.deepEqual(first.echoed, { title: 'x' })
|
|
90
96
|
assert.match(first.cookie, /session=s1/)
|
|
91
97
|
assert.match(first.cookie, /csrf=c1/)
|
|
92
|
-
assert.match(
|
|
98
|
+
assert.match(
|
|
99
|
+
second.cookie,
|
|
100
|
+
/session=s1/,
|
|
101
|
+
'session is cached, not re-minted'
|
|
102
|
+
)
|
|
93
103
|
assert.equal(target.loginCount(), 1)
|
|
94
104
|
})
|
|
95
105
|
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ScenarioActor,
|
|
3
|
+
ScenarioActorConfig,
|
|
4
|
+
ScenarioActors,
|
|
5
|
+
} from './scenario-actors-service.js'
|
|
6
|
+
import type {
|
|
7
|
+
ConverseOptions,
|
|
8
|
+
ActorFlowVerdict,
|
|
9
|
+
TargetAgentReply,
|
|
10
|
+
} from '../wirings/actor-flow/actor-flow.types.js'
|
|
11
|
+
import { runConversation } from '../wirings/actor-flow/run-conversation.js'
|
|
12
|
+
import { getSingletonServices } from '../pikku-state.js'
|
|
13
|
+
import { AIProviderNotConfiguredError } from '../errors/errors.js'
|
|
14
|
+
|
|
15
|
+
export interface HttpScenarioActorsConfig {
|
|
16
|
+
/**
|
|
17
|
+
* Base API URL of the target app, INCLUDING the HTTP prefix — e.g.
|
|
18
|
+
* `https://app.example.com/api` or `http://localhost:4000/api`. Actor
|
|
19
|
+
* sign-in is reached at `${apiUrl}${signInPath}` and exposed RPCs at
|
|
20
|
+
* `${apiUrl}${rpcPath}/:rpcName`.
|
|
21
|
+
*/
|
|
22
|
+
apiUrl: string
|
|
23
|
+
/**
|
|
24
|
+
* The actor impersonation secret. Sign-in only ever works for user rows
|
|
25
|
+
* flagged `actor: true` — knowing the secret never impersonates real users.
|
|
26
|
+
*/
|
|
27
|
+
secret: string
|
|
28
|
+
/** Actor name → config (usually from pikku.config.json's actor registry). */
|
|
29
|
+
actors: Record<string, ScenarioActorConfig>
|
|
30
|
+
/** Sign-in path under apiUrl. Default: the actor plugin's `/auth/sign-in/actor`. */
|
|
31
|
+
signInPath?: string
|
|
32
|
+
/** Exposed-RPC path prefix under apiUrl. Default `/rpc`. */
|
|
33
|
+
rpcPath?: string
|
|
34
|
+
/**
|
|
35
|
+
* Default model the persona uses when `actor.converse(...)` is called without
|
|
36
|
+
* an explicit `model`. The persona's own turns/approvals/evaluation run
|
|
37
|
+
* in-process via the configured `aiAgentRunner`.
|
|
38
|
+
*/
|
|
39
|
+
model?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Default HTTP-backed actor. Signs in lazily on first invoke via the Better
|
|
44
|
+
* Auth actor plugin (`POST /auth/sign-in/actor` with `{ email, secret }` —
|
|
45
|
+
* the plugin upserts the actor-flagged user row and mints a session whose
|
|
46
|
+
* `actor` flag flows into audits/analytics). Holds the session cookies for
|
|
47
|
+
* its lifetime; a 401 mid-run re-logs-in once (long health-check runs can
|
|
48
|
+
* outlive a session).
|
|
49
|
+
*/
|
|
50
|
+
export class HttpScenarioActor implements ScenarioActor {
|
|
51
|
+
private cookie: string | null = null
|
|
52
|
+
private origin: string
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
readonly name: string,
|
|
56
|
+
private actorConfig: ScenarioActorConfig,
|
|
57
|
+
private config: HttpScenarioActorsConfig
|
|
58
|
+
) {
|
|
59
|
+
this.origin = new URL(config.apiUrl).origin
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get email(): string {
|
|
63
|
+
return this.actorConfig.email
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async invoke(rpcName: string, data: unknown): Promise<unknown> {
|
|
67
|
+
const cookie = this.cookie ?? (await this.login())
|
|
68
|
+
const res = await this.postRpc(rpcName, data, cookie)
|
|
69
|
+
if (res.status === 401) {
|
|
70
|
+
// Session expired mid-run — re-login once and retry.
|
|
71
|
+
this.cookie = null
|
|
72
|
+
return this.readRpcResponse(
|
|
73
|
+
rpcName,
|
|
74
|
+
await this.postRpc(rpcName, data, await this.login())
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
return this.readRpcResponse(rpcName, res)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async converse(options: ConverseOptions): Promise<ActorFlowVerdict> {
|
|
81
|
+
const { aiAgentRunner } = getSingletonServices()
|
|
82
|
+
if (!aiAgentRunner) {
|
|
83
|
+
throw new AIProviderNotConfiguredError()
|
|
84
|
+
}
|
|
85
|
+
const model = options.model ?? this.config.model
|
|
86
|
+
if (!model) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`[scenario] actor '${this.name}' converse needs a model — pass options.model or set 'model' on the actors service`
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
const threadId = globalThis.crypto.randomUUID()
|
|
92
|
+
const resourceId = `actor:${this.name}`
|
|
93
|
+
|
|
94
|
+
return runConversation({
|
|
95
|
+
persona: this.actorConfig,
|
|
96
|
+
personaName: this.actorConfig.name ?? this.name,
|
|
97
|
+
agentName: options.agent,
|
|
98
|
+
task: options.task,
|
|
99
|
+
evaluate: options.evaluate,
|
|
100
|
+
approvals: options.approvals,
|
|
101
|
+
model,
|
|
102
|
+
maxTurns: options.maxTurns,
|
|
103
|
+
llm: (params) => aiAgentRunner.run(params),
|
|
104
|
+
target: {
|
|
105
|
+
run: (message) =>
|
|
106
|
+
this.agentRun(options.agent, message, threadId, resourceId),
|
|
107
|
+
approve: (runId, decisions) =>
|
|
108
|
+
this.agentApprove(options.agent, runId, decisions),
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Start/continue the target agent's run over HTTP as this actor. */
|
|
114
|
+
private async agentRun(
|
|
115
|
+
agentName: string,
|
|
116
|
+
message: string,
|
|
117
|
+
threadId: string,
|
|
118
|
+
resourceId: string
|
|
119
|
+
): Promise<TargetAgentReply> {
|
|
120
|
+
const raw = await this.postAgent(`agent/${agentName}`, {
|
|
121
|
+
message,
|
|
122
|
+
threadId,
|
|
123
|
+
resourceId,
|
|
124
|
+
})
|
|
125
|
+
return normalizeAgentReply(raw)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Answer the target agent's pending approvals over HTTP and continue. */
|
|
129
|
+
private async agentApprove(
|
|
130
|
+
agentName: string,
|
|
131
|
+
runId: string,
|
|
132
|
+
decisions: { toolCallId: string; approved: boolean }[]
|
|
133
|
+
): Promise<TargetAgentReply> {
|
|
134
|
+
const raw = await this.postAgent(`agent/${agentName}/approve`, {
|
|
135
|
+
runId,
|
|
136
|
+
approvals: decisions,
|
|
137
|
+
})
|
|
138
|
+
return normalizeAgentReply(raw)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* POST an agent HTTP route (raw body, not RPC-wrapped). Signs in lazily: the
|
|
143
|
+
* first call goes out with whatever cookie we hold (none, for a no-auth
|
|
144
|
+
* agent), and only a 401 triggers `login()` + one retry. This lets an actor
|
|
145
|
+
* converse with a no-auth agent without any sign-in wiring, while still
|
|
146
|
+
* authenticating against agents that require a session.
|
|
147
|
+
*/
|
|
148
|
+
private async postAgent(subPath: string, body: unknown): Promise<unknown> {
|
|
149
|
+
const rpcPath = this.config.rpcPath ?? '/rpc'
|
|
150
|
+
const url = `${this.config.apiUrl}${rpcPath}/${subPath}`
|
|
151
|
+
const send = (cookie: string | null) =>
|
|
152
|
+
fetch(url, {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: {
|
|
155
|
+
'content-type': 'application/json',
|
|
156
|
+
origin: this.origin,
|
|
157
|
+
...(cookie ? { cookie } : {}),
|
|
158
|
+
},
|
|
159
|
+
body: JSON.stringify(body),
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
let res = await send(this.cookie)
|
|
163
|
+
if (res.status === 401) {
|
|
164
|
+
this.cookie = null
|
|
165
|
+
res = await send(await this.login())
|
|
166
|
+
}
|
|
167
|
+
if (!res.ok) {
|
|
168
|
+
const text = (await res.text().catch(() => '')).slice(0, 300)
|
|
169
|
+
throw new Error(
|
|
170
|
+
`[scenario] agent call '${subPath}' as '${this.name}' returned ${res.status}: ${text}`
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
if (res.status === 204) return undefined
|
|
174
|
+
const text = await res.text()
|
|
175
|
+
return text ? JSON.parse(text) : undefined
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private async postRpc(rpcName: string, data: unknown, cookie: string) {
|
|
179
|
+
const rpcPath = this.config.rpcPath ?? '/rpc'
|
|
180
|
+
return fetch(`${this.config.apiUrl}${rpcPath}/${rpcName}`, {
|
|
181
|
+
method: 'POST',
|
|
182
|
+
headers: {
|
|
183
|
+
'content-type': 'application/json',
|
|
184
|
+
origin: this.origin,
|
|
185
|
+
cookie,
|
|
186
|
+
},
|
|
187
|
+
body: JSON.stringify({ data }),
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async readRpcResponse(rpcName: string, res: Response) {
|
|
192
|
+
if (!res.ok) {
|
|
193
|
+
const body = (await res.text().catch(() => '')).slice(0, 300)
|
|
194
|
+
throw new Error(
|
|
195
|
+
`[scenario] '${rpcName}' as '${this.name}' returned ${res.status}: ${body}`
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
if (res.status === 204) return undefined
|
|
199
|
+
const text = await res.text()
|
|
200
|
+
return text ? JSON.parse(text) : undefined
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async login(): Promise<string> {
|
|
204
|
+
const signInPath = this.config.signInPath ?? '/auth/sign-in/actor'
|
|
205
|
+
const res = await fetch(`${this.config.apiUrl}${signInPath}`, {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
headers: { 'content-type': 'application/json', origin: this.origin },
|
|
208
|
+
body: JSON.stringify({
|
|
209
|
+
email: this.actorConfig.email,
|
|
210
|
+
name: this.actorConfig.name ?? this.name,
|
|
211
|
+
secret: this.config.secret,
|
|
212
|
+
}),
|
|
213
|
+
})
|
|
214
|
+
if (!res.ok) {
|
|
215
|
+
const body = (await res.text().catch(() => '')).slice(0, 300)
|
|
216
|
+
throw new Error(
|
|
217
|
+
`[scenario] actor sign-in failed for '${this.name}' (${res.status}): ${body}`
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
const setCookies = res.headers.getSetCookie?.() ?? []
|
|
221
|
+
const cookie = setCookies
|
|
222
|
+
.map((c) => c.split(';')[0]!)
|
|
223
|
+
.filter(Boolean)
|
|
224
|
+
.join('; ')
|
|
225
|
+
if (!cookie) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`[scenario] actor sign-in for '${this.name}' returned no session cookie`
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
this.cookie = cookie
|
|
231
|
+
return cookie
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Normalize an agentRun/agentApprove HTTP response into a TargetAgentReply. */
|
|
236
|
+
function normalizeAgentReply(raw: unknown): TargetAgentReply {
|
|
237
|
+
const r = (raw ?? {}) as Record<string, unknown>
|
|
238
|
+
const pending = Array.isArray(r.pendingApprovals)
|
|
239
|
+
? (r.pendingApprovals as Array<Record<string, unknown>>).map((p) => ({
|
|
240
|
+
toolCallId: String(p.toolCallId),
|
|
241
|
+
toolName: String(p.toolName),
|
|
242
|
+
args: p.args,
|
|
243
|
+
reason: typeof p.reason === 'string' ? p.reason : undefined,
|
|
244
|
+
}))
|
|
245
|
+
: undefined
|
|
246
|
+
return {
|
|
247
|
+
text: typeof r.text === 'string' ? r.text : '',
|
|
248
|
+
runId: typeof r.runId === 'string' ? r.runId : '',
|
|
249
|
+
status:
|
|
250
|
+
r.status === 'completed' || r.status === 'suspended'
|
|
251
|
+
? r.status
|
|
252
|
+
: undefined,
|
|
253
|
+
pendingApprovals: pending,
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Build the injected `actors` service from the config registry: actor name →
|
|
259
|
+
* lazy HTTP actor. Wire the result as the `actors` singleton service.
|
|
260
|
+
*/
|
|
261
|
+
export function createHttpScenarioActors(
|
|
262
|
+
config: HttpScenarioActorsConfig
|
|
263
|
+
): ScenarioActors {
|
|
264
|
+
const actors: ScenarioActors = {}
|
|
265
|
+
for (const [name, actorConfig] of Object.entries(config.actors)) {
|
|
266
|
+
actors[name] = new HttpScenarioActor(name, actorConfig, config)
|
|
267
|
+
}
|
|
268
|
+
return actors
|
|
269
|
+
}
|
package/src/services/index.ts
CHANGED
|
@@ -37,15 +37,15 @@ export type {
|
|
|
37
37
|
CopyFileArgs,
|
|
38
38
|
} from './content-service.js'
|
|
39
39
|
export type {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
} from './
|
|
40
|
+
ScenarioActor,
|
|
41
|
+
ScenarioActorConfig,
|
|
42
|
+
ScenarioActors,
|
|
43
|
+
} from './scenario-actors-service.js'
|
|
44
44
|
export {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
type
|
|
48
|
-
} from './http-
|
|
45
|
+
HttpScenarioActor,
|
|
46
|
+
createHttpScenarioActors,
|
|
47
|
+
type HttpScenarioActorsConfig,
|
|
48
|
+
} from './http-scenario-actors.js'
|
|
49
49
|
export type { JWTService } from './jwt-service.js'
|
|
50
50
|
export type {
|
|
51
51
|
EmailService,
|
|
@@ -154,3 +154,22 @@ export type {
|
|
|
154
154
|
EmailTemplateLocaleMeta,
|
|
155
155
|
EmailTemplateAssets,
|
|
156
156
|
} from './meta-service.js'
|
|
157
|
+
export {
|
|
158
|
+
V8CoverageService,
|
|
159
|
+
type CoverageService,
|
|
160
|
+
type CoverageSnapshot,
|
|
161
|
+
type LineHits,
|
|
162
|
+
type ScriptCoverage,
|
|
163
|
+
type FunctionCoverage,
|
|
164
|
+
type CoverageRange,
|
|
165
|
+
} from './v8-coverage-service.js'
|
|
166
|
+
export { IstanbulCoverageService } from './istanbul-coverage-service.js'
|
|
167
|
+
export {
|
|
168
|
+
StubTracker,
|
|
169
|
+
createStubProxy,
|
|
170
|
+
getStubTracker,
|
|
171
|
+
isTestRun,
|
|
172
|
+
stub,
|
|
173
|
+
spy,
|
|
174
|
+
type StubCall,
|
|
175
|
+
} from './stub-tracker.js'
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, test, afterEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { IstanbulCoverageService } from './istanbul-coverage-service.js'
|
|
4
|
+
|
|
5
|
+
const FILE = '/proj/src/things.function.ts'
|
|
6
|
+
|
|
7
|
+
const seedCoverage = () => {
|
|
8
|
+
;(globalThis as any).__coverage__ = {
|
|
9
|
+
[FILE]: {
|
|
10
|
+
path: FILE,
|
|
11
|
+
statementMap: {
|
|
12
|
+
'0': { start: { line: 2, column: 2 }, end: { line: 2, column: 20 } },
|
|
13
|
+
'1': { start: { line: 3, column: 4 }, end: { line: 3, column: 30 } },
|
|
14
|
+
'2': { start: { line: 5, column: 2 }, end: { line: 6, column: 18 } },
|
|
15
|
+
},
|
|
16
|
+
s: { '0': 3, '1': 0, '2': 3 },
|
|
17
|
+
branchMap: {},
|
|
18
|
+
b: {},
|
|
19
|
+
fnMap: {},
|
|
20
|
+
f: {},
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
delete (globalThis as any).__coverage__
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('IstanbulCoverageService', () => {
|
|
30
|
+
test('takeCoverage converts __coverage__ statement counts into line hits', async () => {
|
|
31
|
+
seedCoverage()
|
|
32
|
+
const service = new IstanbulCoverageService()
|
|
33
|
+
await service.start()
|
|
34
|
+
const snapshot = await service.takeCoverage()
|
|
35
|
+
assert.equal(snapshot.kind, 'line-hits')
|
|
36
|
+
if (snapshot.kind !== 'line-hits') return
|
|
37
|
+
const hits = snapshot.lineHits.get(FILE)
|
|
38
|
+
assert.ok(hits, 'file should have line hits')
|
|
39
|
+
assert.equal(hits.get(2), 3)
|
|
40
|
+
assert.equal(hits.get(3), 0)
|
|
41
|
+
assert.equal(hits.get(5), 3)
|
|
42
|
+
assert.equal(
|
|
43
|
+
hits.get(6),
|
|
44
|
+
undefined,
|
|
45
|
+
'counts attach to statement start lines only'
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('reset zeroes counters so per-scenario attribution is possible', async () => {
|
|
50
|
+
seedCoverage()
|
|
51
|
+
const service = new IstanbulCoverageService()
|
|
52
|
+
await service.reset()
|
|
53
|
+
const snapshot = await service.takeCoverage()
|
|
54
|
+
if (snapshot.kind !== 'line-hits') return assert.fail('expected line-hits')
|
|
55
|
+
const hits = snapshot.lineHits.get(FILE)
|
|
56
|
+
assert.equal(hits?.get(2), 0)
|
|
57
|
+
assert.equal(hits?.get(5), 0)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('takeCoverage with no __coverage__ global returns an empty snapshot', async () => {
|
|
61
|
+
const service = new IstanbulCoverageService()
|
|
62
|
+
const snapshot = await service.takeCoverage()
|
|
63
|
+
if (snapshot.kind !== 'line-hits') return assert.fail('expected line-hits')
|
|
64
|
+
assert.equal(snapshot.lineHits.size, 0)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CoverageService,
|
|
3
|
+
CoverageSnapshot,
|
|
4
|
+
LineHits,
|
|
5
|
+
} from './v8-coverage-service.js'
|
|
6
|
+
|
|
7
|
+
interface IstanbulStatementLocation {
|
|
8
|
+
start: { line: number }
|
|
9
|
+
end: { line: number }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface IstanbulFileCoverage {
|
|
13
|
+
path: string
|
|
14
|
+
statementMap: Record<string, IstanbulStatementLocation>
|
|
15
|
+
s: Record<string, number>
|
|
16
|
+
b: Record<string, number[]>
|
|
17
|
+
f: Record<string, number>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type IstanbulCoverageGlobal = Record<string, IstanbulFileCoverage>
|
|
21
|
+
|
|
22
|
+
const getCoverageGlobal = (): IstanbulCoverageGlobal | undefined =>
|
|
23
|
+
(globalThis as { __coverage__?: IstanbulCoverageGlobal }).__coverage__
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reads istanbul-instrumented counters from the `__coverage__` global —
|
|
27
|
+
* the coverage backend for runtimes without V8 precise coverage (e.g. Bun,
|
|
28
|
+
* where source files are instrumented at load time by a Bun loader plugin).
|
|
29
|
+
*/
|
|
30
|
+
export class IstanbulCoverageService implements CoverageService {
|
|
31
|
+
async start(): Promise<void> {}
|
|
32
|
+
|
|
33
|
+
async takeCoverage(): Promise<CoverageSnapshot> {
|
|
34
|
+
const coverage = getCoverageGlobal()
|
|
35
|
+
const lineHits: LineHits = new Map()
|
|
36
|
+
for (const file of Object.values(coverage ?? {})) {
|
|
37
|
+
let hits = lineHits.get(file.path)
|
|
38
|
+
if (!hits) {
|
|
39
|
+
hits = new Map()
|
|
40
|
+
lineHits.set(file.path, hits)
|
|
41
|
+
}
|
|
42
|
+
// istanbul line semantics: a statement's count belongs to its start
|
|
43
|
+
// line only, so an enclosing multi-line statement never masks an
|
|
44
|
+
// unexecuted inner statement (e.g. a throw inside a taken if).
|
|
45
|
+
for (const [id, location] of Object.entries(file.statementMap)) {
|
|
46
|
+
const count = file.s[id] ?? 0
|
|
47
|
+
const line = location.start.line
|
|
48
|
+
hits.set(line, Math.max(hits.get(line) ?? 0, count))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { kind: 'line-hits', lineHits }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async reset(): Promise<void> {
|
|
55
|
+
for (const file of Object.values(getCoverageGlobal() ?? {})) {
|
|
56
|
+
for (const id of Object.keys(file.s)) file.s[id] = 0
|
|
57
|
+
for (const id of Object.keys(file.f)) file.f[id] = 0
|
|
58
|
+
for (const id of Object.keys(file.b)) {
|
|
59
|
+
file.b[id] = file.b[id]!.map(() => 0)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async stop(): Promise<void> {}
|
|
65
|
+
}
|
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
MCPPromptMeta,
|
|
13
13
|
} from '../wirings/mcp/mcp.types.js'
|
|
14
14
|
import type { WorkflowsMeta } from '../wirings/workflow/workflow.types.js'
|
|
15
|
-
import type {
|
|
15
|
+
import type { ScenarioActorConfig } from './scenario-actors-service.js'
|
|
16
16
|
import type {
|
|
17
17
|
TriggerMeta,
|
|
18
18
|
TriggerSourceMeta,
|
|
@@ -171,7 +171,7 @@ export interface MetaService {
|
|
|
171
171
|
getGatewayMeta(): Promise<GatewaysMeta>
|
|
172
172
|
getRpcMeta(): Promise<RPCMetaRecord>
|
|
173
173
|
getWorkflowMeta(): Promise<WorkflowsMeta>
|
|
174
|
-
|
|
174
|
+
getScenarioActorsMeta(): Promise<Record<string, ScenarioActorConfig>>
|
|
175
175
|
getTriggerMeta(): Promise<TriggerMeta>
|
|
176
176
|
getTriggerSourceMeta(): Promise<TriggerSourceMeta>
|
|
177
177
|
getFunctionsMeta(): Promise<FunctionsMeta>
|
|
@@ -210,7 +210,7 @@ export class LocalMetaService implements MetaService {
|
|
|
210
210
|
private gatewayMetaCache: GatewaysMeta | null = null
|
|
211
211
|
private rpcMetaCache: RPCMetaRecord | null = null
|
|
212
212
|
private workflowMetaCache: WorkflowsMeta | null = null
|
|
213
|
-
private
|
|
213
|
+
private scenarioActorsMetaCache: Record<string, ScenarioActorConfig> | null =
|
|
214
214
|
null
|
|
215
215
|
private triggerMetaCache: TriggerMeta | null = null
|
|
216
216
|
private triggerSourceMetaCache: TriggerSourceMeta | null = null
|
|
@@ -262,7 +262,7 @@ export class LocalMetaService implements MetaService {
|
|
|
262
262
|
this.gatewayMetaCache = null
|
|
263
263
|
this.rpcMetaCache = null
|
|
264
264
|
this.workflowMetaCache = null
|
|
265
|
-
this.
|
|
265
|
+
this.scenarioActorsMetaCache = null
|
|
266
266
|
this.triggerMetaCache = null
|
|
267
267
|
this.triggerSourceMetaCache = null
|
|
268
268
|
this.functionsMetaCache = null
|
|
@@ -429,12 +429,12 @@ export class LocalMetaService implements MetaService {
|
|
|
429
429
|
}
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
-
async
|
|
433
|
-
if (this.
|
|
432
|
+
async getScenarioActorsMeta(): Promise<Record<string, ScenarioActorConfig>> {
|
|
433
|
+
if (this.scenarioActorsMetaCache) return this.scenarioActorsMetaCache
|
|
434
434
|
|
|
435
|
-
const content = await this.readFile('workflow/
|
|
436
|
-
this.
|
|
437
|
-
return this.
|
|
435
|
+
const content = await this.readFile('workflow/scenario-actors.gen.json')
|
|
436
|
+
this.scenarioActorsMetaCache = content ? JSON.parse(content) : {}
|
|
437
|
+
return this.scenarioActorsMetaCache!
|
|
438
438
|
}
|
|
439
439
|
|
|
440
440
|
async getTriggerMeta(): Promise<TriggerMeta> {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConverseOptions,
|
|
3
|
+
ActorFlowVerdict,
|
|
4
|
+
} from '../wirings/actor-flow/actor-flow.types.js'
|
|
5
|
+
|
|
6
|
+
/** A synthetic user (a user row flagged `actor`) that workflow steps run as over the real transport */
|
|
7
|
+
export interface ScenarioActor<TAgentName extends string = string> {
|
|
8
|
+
/** Stable actor name (the key in pikku.config.json's actor registry). */
|
|
9
|
+
readonly name: string
|
|
10
|
+
/** The actor's user email — flows use it for invites/lookups. */
|
|
11
|
+
readonly email: string
|
|
12
|
+
/** Invoke an exposed RPC as this actor over the real transport. */
|
|
13
|
+
invoke(rpcName: string, data: unknown): Promise<unknown>
|
|
14
|
+
/** Converse with a Pikku AI agent in this actor's persona and return its verdict */
|
|
15
|
+
converse(options: ConverseOptions<TAgentName>): Promise<ActorFlowVerdict>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Display/config metadata for an actor (from pikku.config.json) */
|
|
19
|
+
export interface ScenarioActorConfig {
|
|
20
|
+
email: string
|
|
21
|
+
name?: string
|
|
22
|
+
jobTitle?: string
|
|
23
|
+
personality?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The injected `actors` service: actor name → actor. */
|
|
27
|
+
export type ScenarioActors = Record<string, ScenarioActor>
|