@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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, test, beforeEach, afterEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {
|
|
4
|
+
StubTracker,
|
|
5
|
+
stub,
|
|
6
|
+
spy,
|
|
7
|
+
isTestRun,
|
|
8
|
+
getStubTracker,
|
|
9
|
+
} from './stub-tracker.js'
|
|
10
|
+
|
|
11
|
+
describe('StubTracker', () => {
|
|
12
|
+
test('records calls and exposes them via getCalls', () => {
|
|
13
|
+
const tracker = new StubTracker()
|
|
14
|
+
tracker.record('email', 'send', [{ to: 'a@b.c' }])
|
|
15
|
+
tracker.record('payments', 'charge', [{ amount: 5 }])
|
|
16
|
+
|
|
17
|
+
assert.equal(tracker.getCalls().length, 2)
|
|
18
|
+
const emailCalls = tracker.getCalls('email')
|
|
19
|
+
assert.equal(emailCalls.length, 1)
|
|
20
|
+
assert.deepEqual(emailCalls[0], {
|
|
21
|
+
service: 'email',
|
|
22
|
+
method: 'send',
|
|
23
|
+
args: [{ to: 'a@b.c' }],
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('reset clears recorded calls and strict-mode touches', () => {
|
|
28
|
+
const tracker = new StubTracker()
|
|
29
|
+
tracker.record('email', 'send', [])
|
|
30
|
+
tracker.reset()
|
|
31
|
+
assert.equal(tracker.getCalls().length, 0)
|
|
32
|
+
tracker.verify()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('legacy stub() proxy records through the same tracker', async () => {
|
|
36
|
+
const tracker = new StubTracker()
|
|
37
|
+
const email = tracker.stub<{ send: (msg: unknown) => Promise<void> }>(
|
|
38
|
+
'email'
|
|
39
|
+
)
|
|
40
|
+
await email.send({ to: 'x@y.z' })
|
|
41
|
+
assert.deepEqual(tracker.getCalls('email')[0]?.args, [{ to: 'x@y.z' }])
|
|
42
|
+
tracker.assert('email', 'send')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('stub/spy core utils', () => {
|
|
47
|
+
beforeEach(() => getStubTracker().reset())
|
|
48
|
+
|
|
49
|
+
test('stub() records into the default tracker and uses the implementation', async () => {
|
|
50
|
+
const email = stub<{ send: (msg: unknown) => Promise<{ ok: boolean }> }>(
|
|
51
|
+
'email',
|
|
52
|
+
{ send: async () => ({ ok: true }) }
|
|
53
|
+
)
|
|
54
|
+
const result = await email.send({ to: 'a@b.c' })
|
|
55
|
+
assert.deepEqual(result, { ok: true })
|
|
56
|
+
assert.deepEqual(getStubTracker().getCalls('email')[0], {
|
|
57
|
+
service: 'email',
|
|
58
|
+
method: 'send',
|
|
59
|
+
args: [{ to: 'a@b.c' }],
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('stub() without an implementation resolves undefined', async () => {
|
|
64
|
+
const payments = stub<{ charge: (x: unknown) => Promise<unknown> }>(
|
|
65
|
+
'payments'
|
|
66
|
+
)
|
|
67
|
+
assert.equal(await payments.charge({ amount: 5 }), undefined)
|
|
68
|
+
assert.equal(getStubTracker().getCalls('payments').length, 1)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('spy() records calls and passes through to the real service', async () => {
|
|
72
|
+
let realCalled = 0
|
|
73
|
+
const real = {
|
|
74
|
+
config: { retries: 3 },
|
|
75
|
+
send: async (msg: { to: string }) => {
|
|
76
|
+
realCalled++
|
|
77
|
+
return { delivered: msg.to }
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
const email = spy('email', real)
|
|
81
|
+
|
|
82
|
+
assert.deepEqual(await email.send({ to: 'a@b.c' }), {
|
|
83
|
+
delivered: 'a@b.c',
|
|
84
|
+
})
|
|
85
|
+
assert.equal(realCalled, 1)
|
|
86
|
+
assert.equal(email.config.retries, 3)
|
|
87
|
+
assert.equal(getStubTracker().getCalls('email').length, 1)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('isTestRun', () => {
|
|
92
|
+
const original = process.env.PIKKU_TEST_RUN
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
if (original === undefined) delete process.env.PIKKU_TEST_RUN
|
|
95
|
+
else process.env.PIKKU_TEST_RUN = original
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('reflects the PIKKU_TEST_RUN environment variable', () => {
|
|
99
|
+
delete process.env.PIKKU_TEST_RUN
|
|
100
|
+
assert.equal(isTestRun(), false)
|
|
101
|
+
process.env.PIKKU_TEST_RUN = 'true'
|
|
102
|
+
assert.equal(isTestRun(), true)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
export type StubCall = { service: string; method: string; args: unknown[] }
|
|
2
|
+
|
|
3
|
+
type Call = { method: string; args: unknown[]; verified: boolean }
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tracks calls made to stubbed services and enforces strict mode:
|
|
7
|
+
* once a scenario asserts any call on a service, every recorded call on that
|
|
8
|
+
* service must be verified by end of scenario — otherwise the After hook fails.
|
|
9
|
+
* Services never touched by an assertion stay lenient.
|
|
10
|
+
*/
|
|
11
|
+
export class StubTracker {
|
|
12
|
+
private readonly calls = new Map<string, Call[]>()
|
|
13
|
+
private readonly touched = new Set<string>()
|
|
14
|
+
|
|
15
|
+
record(service: string, method: string, args: unknown[]): void {
|
|
16
|
+
const list = this.calls.get(service) ?? []
|
|
17
|
+
list.push({ method, args, verified: false })
|
|
18
|
+
this.calls.set(service, list)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
getCalls(service?: string): StubCall[] {
|
|
22
|
+
const result: StubCall[] = []
|
|
23
|
+
for (const [name, list] of this.calls) {
|
|
24
|
+
if (service && name !== service) continue
|
|
25
|
+
for (const call of list) {
|
|
26
|
+
result.push({ service: name, method: call.method, args: call.args })
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return result
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
reset(): void {
|
|
33
|
+
this.calls.clear()
|
|
34
|
+
this.touched.clear()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
stub<T>(service: string): T {
|
|
38
|
+
const self = this
|
|
39
|
+
return new Proxy(Object.create(null) as object, {
|
|
40
|
+
get(_, method: string) {
|
|
41
|
+
return (...args: unknown[]) => {
|
|
42
|
+
self.record(service, method, args)
|
|
43
|
+
return Promise.resolve()
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
}) as unknown as T
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
assert(service: string, method: string): void {
|
|
50
|
+
this.touched.add(service)
|
|
51
|
+
const list = this.calls.get(service) ?? []
|
|
52
|
+
const idx = list.findIndex((c) => c.method === method && !c.verified)
|
|
53
|
+
if (idx === -1) {
|
|
54
|
+
const seen = list.map((c) => c.method).join(', ') || '(none)'
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Expected "${service}.${method}" to have been called. Recorded: ${seen}`
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
list[idx]!.verified = true
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
assertCall(
|
|
63
|
+
service: string,
|
|
64
|
+
method: string,
|
|
65
|
+
predicate: (args: unknown[]) => boolean,
|
|
66
|
+
description: string
|
|
67
|
+
): void {
|
|
68
|
+
this.touched.add(service)
|
|
69
|
+
const list = this.calls.get(service) ?? []
|
|
70
|
+
const idx = list.findIndex(
|
|
71
|
+
(c) => c.method === method && !c.verified && predicate(c.args)
|
|
72
|
+
)
|
|
73
|
+
if (idx === -1) {
|
|
74
|
+
const seen =
|
|
75
|
+
list
|
|
76
|
+
.filter((c) => c.method === method)
|
|
77
|
+
.map((c) => JSON.stringify(c.args[0]))
|
|
78
|
+
.join('\n ') || '(none)'
|
|
79
|
+
throw new Error(`Expected ${description} but found:\n ${seen}`)
|
|
80
|
+
}
|
|
81
|
+
list[idx]!.verified = true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
assertNoCalls(
|
|
85
|
+
service: string,
|
|
86
|
+
method?: string,
|
|
87
|
+
predicate?: (args: unknown[]) => boolean,
|
|
88
|
+
description?: string
|
|
89
|
+
): void {
|
|
90
|
+
this.touched.add(service)
|
|
91
|
+
const list = this.calls.get(service) ?? []
|
|
92
|
+
const relevant = (
|
|
93
|
+
method ? list.filter((c) => c.method === method) : list
|
|
94
|
+
).filter((c) => !predicate || predicate(c.args))
|
|
95
|
+
if (relevant.length > 0) {
|
|
96
|
+
const calls = relevant
|
|
97
|
+
.map(
|
|
98
|
+
(c) =>
|
|
99
|
+
`${c.method}(${c.args.map((a) => JSON.stringify(a)).join(', ')})`
|
|
100
|
+
)
|
|
101
|
+
.join('\n ')
|
|
102
|
+
const what = description ?? `"${service}${method ? '.' + method : ''}"`
|
|
103
|
+
throw new Error(`Expected no ${what} calls but got:\n ${calls}`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
verify(): void {
|
|
108
|
+
const errors: string[] = []
|
|
109
|
+
for (const service of this.touched) {
|
|
110
|
+
const unverified = (this.calls.get(service) ?? []).filter(
|
|
111
|
+
(c) => !c.verified
|
|
112
|
+
)
|
|
113
|
+
for (const c of unverified) {
|
|
114
|
+
const argStr = c.args.map((a) => JSON.stringify(a)).join(', ')
|
|
115
|
+
errors.push(` ${service}.${c.method}(${argStr})`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (errors.length) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Unexpected stub calls — assert them in the scenario or remove the side effect:\n${errors.join('\n')}`
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Creates a Proxy suitable for passing as `existingServices` to
|
|
128
|
+
* `createSingletonServices`. Every property returns `tracker.stub(prop)`
|
|
129
|
+
* EXCEPT `schema`, which returns `undefined` so the service factory creates
|
|
130
|
+
* a real schema service. Stubbing the schema makes validation a no-op —
|
|
131
|
+
* required fields pass silently and tests validate nothing.
|
|
132
|
+
*/
|
|
133
|
+
export function createStubProxy(tracker: StubTracker): Record<string, unknown> {
|
|
134
|
+
return new Proxy({} as Record<string, unknown>, {
|
|
135
|
+
get(_, prop: string) {
|
|
136
|
+
if (prop === 'schema') return undefined
|
|
137
|
+
return tracker.stub(prop)
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const defaultStubTracker = new StubTracker()
|
|
143
|
+
|
|
144
|
+
/** The process-wide tracker that `stub()`/`spy()` record into — read by the console's getStubCalls/resetStubs RPCs */
|
|
145
|
+
export const getStubTracker = (): StubTracker => defaultStubTracker
|
|
146
|
+
|
|
147
|
+
/** True when the server was started by `pikku dev --test` (sets PIKKU_TEST_RUN) */
|
|
148
|
+
export const isTestRun = (): boolean =>
|
|
149
|
+
(globalThis as { process?: { env?: Record<string, string | undefined> } })
|
|
150
|
+
.process?.env?.PIKKU_TEST_RUN === 'true'
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Creates a recording fake for a service. Methods present on `impl` run and
|
|
154
|
+
* their result is returned; missing methods resolve `undefined`. Every call
|
|
155
|
+
* is recorded so scenarios can assert via workflow.expectService().
|
|
156
|
+
*/
|
|
157
|
+
export function stub<T = any>(service: string, impl?: Partial<T>): T {
|
|
158
|
+
return new Proxy((impl ?? {}) as object, {
|
|
159
|
+
get(target: any, method: string | symbol) {
|
|
160
|
+
if (typeof method === 'symbol') return target[method]
|
|
161
|
+
const real = target[method]
|
|
162
|
+
if (typeof real !== 'function' && real !== undefined) return real
|
|
163
|
+
return (...args: unknown[]) => {
|
|
164
|
+
defaultStubTracker.record(service, method, args)
|
|
165
|
+
return real ? real.apply(target, args) : Promise.resolve(undefined)
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
}) as T
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Wraps a real service so every method call is recorded and passed through */
|
|
172
|
+
export function spy<T extends object>(service: string, real: T): T {
|
|
173
|
+
return new Proxy(real, {
|
|
174
|
+
get(target: any, method: string | symbol) {
|
|
175
|
+
const value = target[method]
|
|
176
|
+
if (typeof method === 'symbol' || typeof value !== 'function') {
|
|
177
|
+
return value
|
|
178
|
+
}
|
|
179
|
+
return (...args: unknown[]) => {
|
|
180
|
+
defaultStubTracker.record(service, method, args)
|
|
181
|
+
return value.apply(target, args)
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
}) as T
|
|
185
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, test, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { V8CoverageService } from './v8-coverage-service.js'
|
|
4
|
+
|
|
5
|
+
const service = new V8CoverageService()
|
|
6
|
+
|
|
7
|
+
function coveredProbe(n: number): number {
|
|
8
|
+
return n * 2
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('V8CoverageService', () => {
|
|
12
|
+
after(async () => {
|
|
13
|
+
await service.stop()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('start + takeCoverage reports call counts for functions invoked after start', async () => {
|
|
17
|
+
await service.start()
|
|
18
|
+
coveredProbe(21)
|
|
19
|
+
|
|
20
|
+
const snapshot = await service.takeCoverage()
|
|
21
|
+
assert.equal(snapshot.kind, 'v8-scripts')
|
|
22
|
+
if (snapshot.kind !== 'v8-scripts') return
|
|
23
|
+
const own = snapshot.scripts.find((s) =>
|
|
24
|
+
s.url.includes('v8-coverage-service.test')
|
|
25
|
+
)
|
|
26
|
+
assert.ok(own, 'own test script should appear in precise coverage')
|
|
27
|
+
const probe = own.functions.find((f) =>
|
|
28
|
+
f.functionName.includes('coveredProbe')
|
|
29
|
+
)
|
|
30
|
+
assert.ok(probe, 'coveredProbe should be tracked')
|
|
31
|
+
assert.ok(
|
|
32
|
+
probe.ranges[0].count >= 1,
|
|
33
|
+
`coveredProbe should have been counted, got ${probe.ranges[0].count}`
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('reset clears call counts so attribution per run is possible', async () => {
|
|
38
|
+
coveredProbe(1)
|
|
39
|
+
await service.reset()
|
|
40
|
+
const snapshot = await service.takeCoverage()
|
|
41
|
+
if (snapshot.kind !== 'v8-scripts') return assert.fail('expected scripts')
|
|
42
|
+
const own = snapshot.scripts.find((s) =>
|
|
43
|
+
s.url.includes('v8-coverage-service.test')
|
|
44
|
+
)
|
|
45
|
+
const probe = own?.functions.find((f) =>
|
|
46
|
+
f.functionName.includes('coveredProbe')
|
|
47
|
+
)
|
|
48
|
+
const count = probe?.ranges[0].count ?? 0
|
|
49
|
+
assert.equal(
|
|
50
|
+
count,
|
|
51
|
+
0,
|
|
52
|
+
`after reset coveredProbe count should be 0, got ${count}`
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('getScriptSource returns the executed source for mapping', async () => {
|
|
57
|
+
const snapshot = await service.takeCoverage()
|
|
58
|
+
if (snapshot.kind !== 'v8-scripts') return assert.fail('expected scripts')
|
|
59
|
+
const own = snapshot.scripts.find((s) =>
|
|
60
|
+
s.url.includes('v8-coverage-service.test')
|
|
61
|
+
)
|
|
62
|
+
assert.ok(own)
|
|
63
|
+
const source = await snapshot.getScriptSource(own.scriptId)
|
|
64
|
+
assert.ok(
|
|
65
|
+
source.includes('coveredProbe'),
|
|
66
|
+
'script source should contain the probe function'
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// node:inspector is imported lazily inside start() so this module stays
|
|
2
|
+
// loadable on runtimes without it (e.g. Cloudflare Workers).
|
|
3
|
+
|
|
4
|
+
export interface CoverageRange {
|
|
5
|
+
startOffset: number
|
|
6
|
+
endOffset: number
|
|
7
|
+
count: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface FunctionCoverage {
|
|
11
|
+
functionName: string
|
|
12
|
+
isBlockCoverage: boolean
|
|
13
|
+
ranges: CoverageRange[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ScriptCoverage {
|
|
17
|
+
scriptId: string
|
|
18
|
+
url: string
|
|
19
|
+
functions: FunctionCoverage[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type LineHits = Map<string, Map<number, number>>
|
|
23
|
+
|
|
24
|
+
export type CoverageSnapshot =
|
|
25
|
+
| {
|
|
26
|
+
kind: 'v8-scripts'
|
|
27
|
+
scripts: ScriptCoverage[]
|
|
28
|
+
getScriptSource: (scriptId: string) => Promise<string>
|
|
29
|
+
}
|
|
30
|
+
| { kind: 'line-hits'; lineHits: LineHits }
|
|
31
|
+
|
|
32
|
+
export interface CoverageService {
|
|
33
|
+
start(): Promise<void>
|
|
34
|
+
takeCoverage(): Promise<CoverageSnapshot>
|
|
35
|
+
reset(): Promise<void>
|
|
36
|
+
stop(): Promise<void>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type InspectorSession = {
|
|
40
|
+
connect(): void
|
|
41
|
+
disconnect(): void
|
|
42
|
+
post(
|
|
43
|
+
method: string,
|
|
44
|
+
params?: unknown,
|
|
45
|
+
callback?: (err: Error | null, result?: any) => void
|
|
46
|
+
): void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class V8CoverageService implements CoverageService {
|
|
50
|
+
private session: InspectorSession | null = null
|
|
51
|
+
private startPromise: Promise<void> | null = null
|
|
52
|
+
|
|
53
|
+
private post<T>(method: string, params?: unknown): Promise<T> {
|
|
54
|
+
const session = this.session
|
|
55
|
+
if (!session) {
|
|
56
|
+
throw new Error('V8CoverageService not started — call start() first')
|
|
57
|
+
}
|
|
58
|
+
return new Promise<T>((resolve, reject) => {
|
|
59
|
+
session.post(method, params, (err, result) =>
|
|
60
|
+
err ? reject(err) : resolve(result as T)
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
start(): Promise<void> {
|
|
66
|
+
this.startPromise ??= this.doStart()
|
|
67
|
+
return this.startPromise
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private async doStart(): Promise<void> {
|
|
71
|
+
const inspector = await import('node:inspector')
|
|
72
|
+
this.session = new inspector.Session() as unknown as InspectorSession
|
|
73
|
+
this.session.connect()
|
|
74
|
+
await this.post('Profiler.enable')
|
|
75
|
+
await this.post('Debugger.enable')
|
|
76
|
+
await this.post('Profiler.startPreciseCoverage', {
|
|
77
|
+
callCount: true,
|
|
78
|
+
detailed: true,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async takeCoverage(): Promise<CoverageSnapshot> {
|
|
83
|
+
const { result } = await this.post<{ result: ScriptCoverage[] }>(
|
|
84
|
+
'Profiler.takePreciseCoverage'
|
|
85
|
+
)
|
|
86
|
+
return {
|
|
87
|
+
kind: 'v8-scripts',
|
|
88
|
+
scripts: result.filter((script) => script.url.startsWith('file://')),
|
|
89
|
+
getScriptSource: (scriptId) => this.getScriptSource(scriptId),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async getScriptSource(scriptId: string): Promise<string> {
|
|
94
|
+
const { scriptSource } = await this.post<{ scriptSource: string }>(
|
|
95
|
+
'Debugger.getScriptSource',
|
|
96
|
+
{ scriptId }
|
|
97
|
+
)
|
|
98
|
+
return scriptSource
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async reset(): Promise<void> {
|
|
102
|
+
await this.post('Profiler.stopPreciseCoverage')
|
|
103
|
+
await this.post('Profiler.startPreciseCoverage', {
|
|
104
|
+
callCount: true,
|
|
105
|
+
detailed: true,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async stop(): Promise<void> {
|
|
110
|
+
if (!this.session) return
|
|
111
|
+
try {
|
|
112
|
+
await this.post('Profiler.stopPreciseCoverage')
|
|
113
|
+
await this.post('Profiler.disable')
|
|
114
|
+
await this.post('Debugger.disable')
|
|
115
|
+
} finally {
|
|
116
|
+
this.session.disconnect()
|
|
117
|
+
this.session = null
|
|
118
|
+
this.startPromise = null
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/types/core.types.ts
CHANGED
|
@@ -29,7 +29,7 @@ import type { DeploymentService } from '../services/deployment-service.js'
|
|
|
29
29
|
import type { AIStorageService } from '../services/ai-storage-service.js'
|
|
30
30
|
|
|
31
31
|
import type { ContentService } from '../services/content-service.js'
|
|
32
|
-
import type {
|
|
32
|
+
import type { ScenarioActors } from '../services/scenario-actors-service.js'
|
|
33
33
|
import type { AIAgentRunnerService } from '../services/ai-agent-runner-service.js'
|
|
34
34
|
import type { AIRunStateService } from '../services/ai-run-state-service.js'
|
|
35
35
|
import type { AgentRunService } from '../wirings/ai-agent/ai-agent.types.js'
|
|
@@ -38,6 +38,7 @@ import type { WorkflowRunService } from '../wirings/workflow/workflow.types.js'
|
|
|
38
38
|
import type { CredentialService } from '../services/credential-service.js'
|
|
39
39
|
import type { EmailService } from '../services/email-service.js'
|
|
40
40
|
import type { MetaService } from '../services/meta-service.js'
|
|
41
|
+
import type { CoverageService } from '../services/v8-coverage-service.js'
|
|
41
42
|
import type { SessionStore } from '../services/session-store.js'
|
|
42
43
|
import type {
|
|
43
44
|
AuditDurability,
|
|
@@ -150,6 +151,12 @@ export type FunctionMeta = FunctionRuntimeMeta &
|
|
|
150
151
|
isDirectFunction: boolean
|
|
151
152
|
sourceFile: string
|
|
152
153
|
exportedName: string
|
|
154
|
+
/** File containing the handler body when it differs from sourceFile (imported handlers) */
|
|
155
|
+
bodySourceFile?: string
|
|
156
|
+
/** 1-indexed first line of the handler body (verbose meta; coverage mapping) */
|
|
157
|
+
bodyStart: number
|
|
158
|
+
/** 1-indexed last line of the handler body (verbose meta; coverage mapping) */
|
|
159
|
+
bodyEnd: number
|
|
153
160
|
} & CommonWireMeta
|
|
154
161
|
>
|
|
155
162
|
|
|
@@ -244,7 +251,7 @@ export type CoreConfig<Config extends Record<string, unknown> = {}> = {
|
|
|
244
251
|
export interface CoreUserSession {
|
|
245
252
|
userId?: string
|
|
246
253
|
orgId?: string
|
|
247
|
-
/** True when the session belongs to a synthetic
|
|
254
|
+
/** True when the session belongs to a synthetic scenario actor — lets audits/analytics address synthetic traffic */
|
|
248
255
|
actor?: boolean
|
|
249
256
|
}
|
|
250
257
|
|
|
@@ -293,6 +300,8 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
|
|
|
293
300
|
emailService?: EmailService
|
|
294
301
|
/** Meta service for reading .pikku metadata files (filesystem on Node, R2/KV on CF) */
|
|
295
302
|
metaService?: MetaService
|
|
303
|
+
/** V8 precise-coverage collector (`pikku dev --coverage` only) */
|
|
304
|
+
coverageService?: CoverageService
|
|
296
305
|
/** Audit service for durable or staged audit event capture */
|
|
297
306
|
audit?: AuditService
|
|
298
307
|
/**
|
|
@@ -339,8 +348,8 @@ export type PikkuWire<
|
|
|
339
348
|
queue: PikkuQueue
|
|
340
349
|
cli: PikkuCLI
|
|
341
350
|
workflow: TypedWorkflow
|
|
342
|
-
/**
|
|
343
|
-
actors:
|
|
351
|
+
/** Scenario actor registry (scenario runs only) — pass into workflow.do as `{ actor: actors.x }` */
|
|
352
|
+
actors: ScenarioActors
|
|
344
353
|
workflowStep: WorkflowStepWire
|
|
345
354
|
graph: PikkuGraphWire
|
|
346
355
|
trigger: PikkuTrigger<TriggerOutput>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How an actor agent answers the target agent's tool-approval requests during
|
|
3
|
+
* a conversation.
|
|
4
|
+
* - `'in-persona'` — the actor agent decides as the persona would (default).
|
|
5
|
+
* - `'always'` — approve every request (stress the happy path).
|
|
6
|
+
* - `'never'` — deny every request (exercise refusal handling).
|
|
7
|
+
*/
|
|
8
|
+
export type ActorFlowApprovalPolicy = 'in-persona' | 'always' | 'never'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Options for `actor.converse(...)` — a dynamic conversation an actor holds
|
|
12
|
+
* with a target Pikku AI agent, in the actor's own persona. `TAgentName` is
|
|
13
|
+
* bound to the generated union of agent names in a typed project.
|
|
14
|
+
*/
|
|
15
|
+
export interface ConverseOptions<TAgentName extends string = string> {
|
|
16
|
+
/** Target Pikku AI agent name to converse with. */
|
|
17
|
+
agent: TAgentName
|
|
18
|
+
/** What the actor is trying to get the agent to accomplish. */
|
|
19
|
+
task: string
|
|
20
|
+
/** Natural-language success criterion the actor evaluates at the end. */
|
|
21
|
+
evaluate: string
|
|
22
|
+
/** How the actor answers the agent's tool-approval requests. Default `'in-persona'`. */
|
|
23
|
+
approvals?: ActorFlowApprovalPolicy
|
|
24
|
+
/** Model the persona uses for its own turns/decisions. Falls back to the actor service default. */
|
|
25
|
+
model?: string
|
|
26
|
+
/** Hard cap on conversation turns before forcing evaluation. Default 12. */
|
|
27
|
+
maxTurns?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The verdict a conversation produces: the persona's LLM self-evaluation of
|
|
32
|
+
* whether the task was met. Deterministic checks are the caller's job — they
|
|
33
|
+
* already hold the actor and can `actor.invoke(...)` afterwards.
|
|
34
|
+
*/
|
|
35
|
+
export interface ActorFlowVerdict {
|
|
36
|
+
/** Whether the actor judged the task accomplished. */
|
|
37
|
+
passed: boolean
|
|
38
|
+
/** The actor's reasoning for its verdict. */
|
|
39
|
+
reasoning: string
|
|
40
|
+
/** The conversation transcript, for debugging/reporting. */
|
|
41
|
+
transcript: string[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A pending tool-approval request surfaced by the target agent. */
|
|
45
|
+
export interface TargetPendingApproval {
|
|
46
|
+
toolCallId: string
|
|
47
|
+
toolName: string
|
|
48
|
+
args: unknown
|
|
49
|
+
reason?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A normalized reply from the target agent, independent of transport. */
|
|
53
|
+
export interface TargetAgentReply {
|
|
54
|
+
text: string
|
|
55
|
+
runId: string
|
|
56
|
+
status?: 'completed' | 'suspended'
|
|
57
|
+
pendingApprovals?: TargetPendingApproval[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Drives the target agent. In production this is HTTP-backed (the actor's
|
|
62
|
+
* `agentRun` / `agentApprove` calls as the signed-in actor); the conversation
|
|
63
|
+
* engine only sees this transport-agnostic contract.
|
|
64
|
+
*/
|
|
65
|
+
export interface TargetAgentDriver {
|
|
66
|
+
/** Send a message, starting or continuing the target agent's run. */
|
|
67
|
+
run(message: string): Promise<TargetAgentReply>
|
|
68
|
+
/** Answer the target agent's pending approvals and continue its run. */
|
|
69
|
+
approve(
|
|
70
|
+
runId: string,
|
|
71
|
+
decisions: { toolCallId: string; approved: boolean }[]
|
|
72
|
+
): Promise<TargetAgentReply>
|
|
73
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Actor flow module exports.
|
|
3
|
+
*
|
|
4
|
+
* An actor holds a dynamic conversation with a target Pikku AI agent via
|
|
5
|
+
* `actor.converse(...)` — playing its own persona, approving the agent's tool
|
|
6
|
+
* requests in-persona, and evaluating whether the task was accomplished. The
|
|
7
|
+
* conversation engine here is transport-agnostic; the actor drives the target
|
|
8
|
+
* over HTTP.
|
|
9
|
+
*/
|
|
10
|
+
export type {
|
|
11
|
+
ActorFlowApprovalPolicy,
|
|
12
|
+
ActorFlowVerdict,
|
|
13
|
+
ConverseOptions,
|
|
14
|
+
TargetAgentReply,
|
|
15
|
+
TargetPendingApproval,
|
|
16
|
+
TargetAgentDriver,
|
|
17
|
+
} from './actor-flow.types.js'
|
|
18
|
+
export {
|
|
19
|
+
runConversation,
|
|
20
|
+
type RunConversationParams,
|
|
21
|
+
type PersonaLLM,
|
|
22
|
+
} from './run-conversation.js'
|