@pikku/core 0.12.69 → 0.12.70
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 +334 -0
- package/README.md +34 -2
- package/dist/function/functions.types.d.ts +27 -0
- package/dist/index.d.ts +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.js +1 -1
- package/dist/pikku-state.js +1 -0
- package/dist/services/http-scenario-actors.d.ts +12 -4
- package/dist/services/http-scenario-actors.js +47 -45
- package/dist/services/index.d.ts +2 -1
- package/dist/services/index.js +1 -0
- package/dist/services/meta-service.d.ts +5 -1
- package/dist/services/meta-service.js +44 -18
- package/dist/services/scenario-actors-service.d.ts +108 -2
- package/dist/services/scenario-actors-service.js +40 -1
- package/dist/types/core.types.d.ts +21 -3
- package/dist/types/state.types.d.ts +3 -1
- package/dist/wirings/actor-flow/actor-flow.types.d.ts +1 -1
- package/dist/wirings/actor-flow/index.d.ts +1 -1
- package/dist/wirings/actor-flow/run-conversation.d.ts +10 -10
- package/dist/wirings/actor-flow/run-conversation.js +27 -27
- package/dist/wirings/cli/command-parser.js +11 -1
- package/dist/wirings/rpc/rpc-runner.js +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +52 -3
- package/dist/wirings/workflow/feature.d.ts +28 -0
- package/dist/wirings/workflow/feature.js +57 -0
- package/dist/wirings/workflow/index.d.ts +13 -2
- package/dist/wirings/workflow/index.js +15 -0
- package/dist/wirings/workflow/pikku-scenario-service.d.ts +121 -0
- package/dist/wirings/workflow/pikku-scenario-service.js +419 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +103 -10
- package/dist/wirings/workflow/pikku-workflow-service.js +80 -135
- package/dist/wirings/workflow/scenario-cookie-jar.d.ts +29 -0
- package/dist/wirings/workflow/scenario-cookie-jar.js +51 -0
- package/dist/wirings/workflow/scenario-poll.d.ts +20 -0
- package/dist/wirings/workflow/scenario-poll.js +25 -0
- package/dist/wirings/workflow/scenario-prose.d.ts +38 -0
- package/dist/wirings/workflow/scenario-prose.js +45 -0
- package/dist/wirings/workflow/scenario-step-guards.d.ts +16 -0
- package/dist/wirings/workflow/scenario-step-guards.js +29 -0
- package/dist/wirings/workflow/scenario-step.types.d.ts +148 -0
- package/dist/wirings/workflow/scenario-step.types.js +1 -0
- package/dist/wirings/workflow/workflow.types.d.ts +81 -2
- package/package.json +3 -1
- package/src/function/functions.types.ts +32 -0
- package/src/index.ts +1 -0
- package/src/internal.ts +5 -1
- package/src/pikku-state.ts +1 -0
- package/src/services/http-scenario-actors.test.ts +85 -1
- package/src/services/http-scenario-actors.ts +65 -51
- package/src/services/index.ts +5 -0
- package/src/services/meta-service.test.ts +79 -0
- package/src/services/meta-service.ts +61 -26
- package/src/services/scenario-actors-service.ts +157 -2
- package/src/types/core.types.ts +27 -2
- package/src/types/state.types.ts +3 -0
- package/src/wirings/actor-flow/actor-flow.types.ts +1 -1
- package/src/wirings/actor-flow/index.ts +1 -1
- package/src/wirings/actor-flow/run-conversation.test.ts +12 -6
- package/src/wirings/actor-flow/run-conversation.ts +36 -41
- package/src/wirings/cli/command-parser.test.ts +60 -0
- package/src/wirings/cli/command-parser.ts +12 -1
- package/src/wirings/rpc/rpc-runner.test.ts +28 -5
- package/src/wirings/rpc/rpc-runner.ts +1 -1
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +86 -2
- package/src/wirings/workflow/feature.test.ts +131 -0
- package/src/wirings/workflow/feature.ts +78 -0
- package/src/wirings/workflow/index.ts +73 -0
- package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +55 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +196 -208
- package/src/wirings/workflow/scenario-cookie-jar.test.ts +108 -0
- package/src/wirings/workflow/scenario-cookie-jar.ts +65 -0
- package/src/wirings/workflow/scenario-hooks.test.ts +212 -0
- package/src/wirings/workflow/scenario-poll.test.ts +66 -0
- package/src/wirings/workflow/scenario-poll.ts +36 -0
- package/src/wirings/workflow/scenario-prose.test.ts +152 -0
- package/src/wirings/workflow/scenario-prose.ts +79 -0
- package/src/wirings/workflow/scenario-service.test.ts +155 -0
- package/src/wirings/workflow/scenario-step-guards.ts +43 -0
- package/src/wirings/workflow/scenario-step.test.ts +441 -8
- package/src/wirings/workflow/scenario-step.types.ts +157 -0
- package/src/wirings/workflow/workflow.types.ts +98 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, test, before, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import { LocalMetaService } from './meta-service.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Scenarios and their steps are generated into `.pikku/scenarios/` so that no
|
|
11
|
+
* app-facing module imports them. Everything that reads meta off disk — the
|
|
12
|
+
* console's scenario list among them — must still see them.
|
|
13
|
+
*/
|
|
14
|
+
describe('LocalMetaService reads the scenario meta alongside the app meta', () => {
|
|
15
|
+
let pikkuDir: string
|
|
16
|
+
|
|
17
|
+
before(async () => {
|
|
18
|
+
pikkuDir = await mkdtemp(join(tmpdir(), 'pikku-meta-'))
|
|
19
|
+
await mkdir(join(pikkuDir, 'workflow', 'meta'), { recursive: true })
|
|
20
|
+
await mkdir(join(pikkuDir, 'scenarios', 'meta'), { recursive: true })
|
|
21
|
+
await mkdir(join(pikkuDir, 'function'), { recursive: true })
|
|
22
|
+
await mkdir(join(pikkuDir, 'scenarios'), { recursive: true })
|
|
23
|
+
|
|
24
|
+
await writeFile(
|
|
25
|
+
join(pikkuDir, 'workflow', 'meta', 'orderWorkflow.gen.json'),
|
|
26
|
+
JSON.stringify({ name: 'orderWorkflow', source: 'dsl' })
|
|
27
|
+
)
|
|
28
|
+
await writeFile(
|
|
29
|
+
join(pikkuDir, 'scenarios', 'meta', 'codeEditorScenario.gen.json'),
|
|
30
|
+
JSON.stringify({ name: 'codeEditorScenario', source: 'scenario' })
|
|
31
|
+
)
|
|
32
|
+
await writeFile(
|
|
33
|
+
join(pikkuDir, 'function', 'pikku-functions-meta.gen.json'),
|
|
34
|
+
JSON.stringify({ createTodo: { pikkuFuncId: 'createTodo' } })
|
|
35
|
+
)
|
|
36
|
+
await writeFile(
|
|
37
|
+
join(pikkuDir, 'scenarios', 'pikku-scenario-functions-meta.gen.json'),
|
|
38
|
+
JSON.stringify({
|
|
39
|
+
opensPage: { pikkuFuncId: 'opensPage', scenarioStep: true },
|
|
40
|
+
})
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
after(async () => {
|
|
45
|
+
await rm(pikkuDir, { recursive: true, force: true })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('a scenario is still a workflow to whoever reads the meta', async () => {
|
|
49
|
+
const meta = await new LocalMetaService(pikkuDir).getWorkflowMeta()
|
|
50
|
+
assert.deepEqual(Object.keys(meta).sort(), [
|
|
51
|
+
'codeEditorScenario',
|
|
52
|
+
'orderWorkflow',
|
|
53
|
+
])
|
|
54
|
+
assert.equal(meta['codeEditorScenario']!.source, 'scenario')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('a scenario step is still a function to whoever reads the meta', async () => {
|
|
58
|
+
const meta = await new LocalMetaService(pikkuDir).getFunctionsMeta()
|
|
59
|
+
assert.deepEqual(Object.keys(meta).sort(), ['createTodo', 'opensPage'])
|
|
60
|
+
assert.equal(meta['opensPage']!.scenarioStep, true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('a project with no scenarios reads exactly what it has', async () => {
|
|
64
|
+
const empty = await mkdtemp(join(tmpdir(), 'pikku-meta-empty-'))
|
|
65
|
+
await mkdir(join(empty, 'workflow', 'meta'), { recursive: true })
|
|
66
|
+
await writeFile(
|
|
67
|
+
join(empty, 'workflow', 'meta', 'orderWorkflow.gen.json'),
|
|
68
|
+
JSON.stringify({ name: 'orderWorkflow', source: 'dsl' })
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const service = new LocalMetaService(empty)
|
|
72
|
+
assert.deepEqual(Object.keys(await service.getWorkflowMeta()), [
|
|
73
|
+
'orderWorkflow',
|
|
74
|
+
])
|
|
75
|
+
assert.deepEqual(Object.keys(await service.getFunctionsMeta()), [])
|
|
76
|
+
|
|
77
|
+
await rm(empty, { recursive: true, force: true })
|
|
78
|
+
})
|
|
79
|
+
})
|
|
@@ -13,7 +13,10 @@ import type {
|
|
|
13
13
|
MCPToolMeta,
|
|
14
14
|
MCPPromptMeta,
|
|
15
15
|
} from '../wirings/mcp/mcp.types.js'
|
|
16
|
-
import type {
|
|
16
|
+
import type {
|
|
17
|
+
FeaturesMeta,
|
|
18
|
+
WorkflowsMeta,
|
|
19
|
+
} from '../wirings/workflow/workflow.types.js'
|
|
17
20
|
import type { ScenarioActorConfig } from './scenario-actors-service.js'
|
|
18
21
|
import type {
|
|
19
22
|
TriggerMeta,
|
|
@@ -186,6 +189,7 @@ export interface MetaService {
|
|
|
186
189
|
getRpcMeta(): Promise<RPCMetaRecord>
|
|
187
190
|
getWorkflowMeta(): Promise<WorkflowsMeta>
|
|
188
191
|
getScenarioActorsMeta(): Promise<Record<string, ScenarioActorConfig>>
|
|
192
|
+
getFeaturesMeta(): Promise<FeaturesMeta>
|
|
189
193
|
getTriggerMeta(): Promise<TriggerMeta>
|
|
190
194
|
getTriggerSourceMeta(): Promise<TriggerSourceMeta>
|
|
191
195
|
getFunctionsMeta(): Promise<FunctionsMeta>
|
|
@@ -226,6 +230,7 @@ export class LocalMetaService implements MetaService {
|
|
|
226
230
|
private workflowMetaCache: WorkflowsMeta | null = null
|
|
227
231
|
private scenarioActorsMetaCache: Record<string, ScenarioActorConfig> | null =
|
|
228
232
|
null
|
|
233
|
+
private featuresMetaCache: FeaturesMeta | null = null
|
|
229
234
|
private triggerMetaCache: TriggerMeta | null = null
|
|
230
235
|
private triggerSourceMetaCache: TriggerSourceMeta | null = null
|
|
231
236
|
private functionsMetaCache: FunctionsMeta | null = null
|
|
@@ -328,6 +333,7 @@ export class LocalMetaService implements MetaService {
|
|
|
328
333
|
this.rpcMetaCache = null
|
|
329
334
|
this.workflowMetaCache = null
|
|
330
335
|
this.scenarioActorsMetaCache = null
|
|
336
|
+
this.featuresMetaCache = null
|
|
331
337
|
this.triggerMetaCache = null
|
|
332
338
|
this.triggerSourceMetaCache = null
|
|
333
339
|
this.functionsMetaCache = null
|
|
@@ -456,34 +462,47 @@ export class LocalMetaService implements MetaService {
|
|
|
456
462
|
}
|
|
457
463
|
}
|
|
458
464
|
|
|
465
|
+
private async readWorkflowMetaDir(
|
|
466
|
+
dir: string,
|
|
467
|
+
into: WorkflowsMeta
|
|
468
|
+
): Promise<void> {
|
|
469
|
+
const files = await this.readDir(dir)
|
|
470
|
+
const jsonFiles = files.filter((f) => f.endsWith('.gen.json'))
|
|
471
|
+
const verboseFiles = jsonFiles.filter((f) => f.includes('-verbose'))
|
|
472
|
+
const minimalFiles = jsonFiles.filter((f) => !f.includes('-verbose'))
|
|
473
|
+
const verboseNames = new Set(
|
|
474
|
+
verboseFiles.map((f) => f.replace('-verbose.gen.json', ''))
|
|
475
|
+
)
|
|
476
|
+
const filesToRead = [
|
|
477
|
+
...verboseFiles,
|
|
478
|
+
...minimalFiles.filter(
|
|
479
|
+
(f) => !verboseNames.has(f.replace('.gen.json', ''))
|
|
480
|
+
),
|
|
481
|
+
]
|
|
482
|
+
|
|
483
|
+
await Promise.all(
|
|
484
|
+
filesToRead.map(async (file) => {
|
|
485
|
+
const content = await this.readFile(`${dir}/${file}`)
|
|
486
|
+
if (content) {
|
|
487
|
+
const meta = JSON.parse(content)
|
|
488
|
+
into[meta.name] = meta
|
|
489
|
+
}
|
|
490
|
+
})
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
|
|
459
494
|
async getWorkflowMeta(): Promise<WorkflowsMeta> {
|
|
460
495
|
if (this.workflowMetaCache) return this.workflowMetaCache
|
|
461
496
|
|
|
462
497
|
try {
|
|
463
|
-
const files = await this.readDir('workflow/meta')
|
|
464
|
-
const jsonFiles = files.filter((f) => f.endsWith('.gen.json'))
|
|
465
|
-
const verboseFiles = jsonFiles.filter((f) => f.includes('-verbose'))
|
|
466
|
-
const minimalFiles = jsonFiles.filter((f) => !f.includes('-verbose'))
|
|
467
|
-
const verboseNames = new Set(
|
|
468
|
-
verboseFiles.map((f) => f.replace('-verbose.gen.json', ''))
|
|
469
|
-
)
|
|
470
|
-
const filesToRead = [
|
|
471
|
-
...verboseFiles,
|
|
472
|
-
...minimalFiles.filter(
|
|
473
|
-
(f) => !verboseNames.has(f.replace('.gen.json', ''))
|
|
474
|
-
),
|
|
475
|
-
]
|
|
476
|
-
|
|
477
498
|
const result: WorkflowsMeta = {}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
})
|
|
486
|
-
)
|
|
499
|
+
// Scenarios keep their meta in `scenarios/meta` so nothing app-facing
|
|
500
|
+
// imports them, but they are still workflows to anything reading meta off
|
|
501
|
+
// disk — the console's scenario list among them.
|
|
502
|
+
await Promise.all([
|
|
503
|
+
this.readWorkflowMetaDir('workflow/meta', result),
|
|
504
|
+
this.readWorkflowMetaDir('scenarios/meta', result),
|
|
505
|
+
])
|
|
487
506
|
|
|
488
507
|
this.workflowMetaCache = result
|
|
489
508
|
return this.workflowMetaCache
|
|
@@ -502,6 +521,14 @@ export class LocalMetaService implements MetaService {
|
|
|
502
521
|
return this.scenarioActorsMetaCache!
|
|
503
522
|
}
|
|
504
523
|
|
|
524
|
+
async getFeaturesMeta(): Promise<FeaturesMeta> {
|
|
525
|
+
if (this.featuresMetaCache) return this.featuresMetaCache
|
|
526
|
+
|
|
527
|
+
const content = await this.readFile('scenarios/features.gen.json')
|
|
528
|
+
this.featuresMetaCache = content ? JSON.parse(content) : {}
|
|
529
|
+
return this.featuresMetaCache!
|
|
530
|
+
}
|
|
531
|
+
|
|
505
532
|
async getTriggerMeta(): Promise<TriggerMeta> {
|
|
506
533
|
if (this.triggerMetaCache) return this.triggerMetaCache
|
|
507
534
|
|
|
@@ -527,8 +554,16 @@ export class LocalMetaService implements MetaService {
|
|
|
527
554
|
async getFunctionsMeta(): Promise<FunctionsMeta> {
|
|
528
555
|
if (this.functionsMetaCache) return this.functionsMetaCache
|
|
529
556
|
|
|
530
|
-
const content = await
|
|
531
|
-
|
|
557
|
+
const [content, scenarioContent] = await Promise.all([
|
|
558
|
+
this.readMetaJson('function', 'pikku-functions-meta'),
|
|
559
|
+
// Scenario steps register only into the scenario bootstrap, but they are
|
|
560
|
+
// still functions to anything reading meta off disk.
|
|
561
|
+
this.readMetaJson('scenarios', 'pikku-scenario-functions-meta'),
|
|
562
|
+
])
|
|
563
|
+
this.functionsMetaCache = {
|
|
564
|
+
...(content ? JSON.parse(content) : {}),
|
|
565
|
+
...(scenarioContent ? JSON.parse(scenarioContent) : {}),
|
|
566
|
+
}
|
|
532
567
|
return this.functionsMetaCache!
|
|
533
568
|
}
|
|
534
569
|
|
|
@@ -3,14 +3,154 @@ import type {
|
|
|
3
3
|
ActorFlowVerdict,
|
|
4
4
|
} from '../wirings/actor-flow/actor-flow.types.js'
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* What the transport answered, for a step that treats the status as data.
|
|
8
|
+
*
|
|
9
|
+
* An HTTP response with its body already drained: the stream can only be read
|
|
10
|
+
* once, and a step's return value crosses into the run record, so the response
|
|
11
|
+
* object itself cannot travel. This is the shape every caller ends up with.
|
|
12
|
+
*/
|
|
13
|
+
export interface ScenarioHttpResponse<T = unknown> {
|
|
14
|
+
status: number
|
|
15
|
+
ok: boolean
|
|
16
|
+
/**
|
|
17
|
+
* The parsed JSON body — or, when the body was not JSON, the raw text it was
|
|
18
|
+
* parsed from, so an HTML error page is still readable rather than lost.
|
|
19
|
+
* `undefined` for an empty response.
|
|
20
|
+
*
|
|
21
|
+
* `T` is a claim the caller makes, not one the transport checked: a step that
|
|
22
|
+
* knows the route's payload names it here instead of casting at every use.
|
|
23
|
+
*/
|
|
24
|
+
body: T
|
|
25
|
+
/**
|
|
26
|
+
* The whole body as text, so an assertion can search it without knowing the
|
|
27
|
+
* payload's shape — and so an error body that is HTML rather than JSON still
|
|
28
|
+
* says what went wrong.
|
|
29
|
+
*/
|
|
30
|
+
serialized: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Drain a response into the shape a step can carry: the parsed body (an empty
|
|
35
|
+
* one counting as no body at all) alongside the text it was parsed from.
|
|
36
|
+
*
|
|
37
|
+
* `invokeRaw` returns this, and a step that has to reach past an actor — a
|
|
38
|
+
* route with no RPC, an identity no actor can hold — reaches for this rather
|
|
39
|
+
* than writing the same record by hand.
|
|
40
|
+
*/
|
|
41
|
+
export const readScenarioHttpResponse = async <T = unknown>(
|
|
42
|
+
res: Response
|
|
43
|
+
): Promise<ScenarioHttpResponse<T>> => {
|
|
44
|
+
const text = res.status === 204 ? '' : await res.text().catch(() => '')
|
|
45
|
+
return {
|
|
46
|
+
status: res.status,
|
|
47
|
+
ok: res.ok,
|
|
48
|
+
body: (text ? parseJsonBody(text) : undefined) as T,
|
|
49
|
+
serialized: text,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const parseJsonBody = (text: string): unknown => {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(text)
|
|
56
|
+
} catch {
|
|
57
|
+
return text
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** How to send one JSON request, for `postScenarioJson`. */
|
|
62
|
+
export interface ScenarioJsonRequest {
|
|
63
|
+
/** Serialised as the JSON body. Omit for a request that carries none. */
|
|
64
|
+
body?: unknown
|
|
65
|
+
/** Sent alongside `content-type: application/json`, and may override it. */
|
|
66
|
+
headers?: Record<string, string>
|
|
67
|
+
/** Defaults to `POST` — the method every scenario route here answers. */
|
|
68
|
+
method?: string
|
|
69
|
+
/**
|
|
70
|
+
* The `fetch` to send it with. Pass a `ScenarioCookieJar`'s to keep the
|
|
71
|
+
* session; the global `fetch` otherwise, which is what a step asserting on a
|
|
72
|
+
* sessionless call wants.
|
|
73
|
+
*/
|
|
74
|
+
fetch?: typeof fetch
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* POST JSON somewhere and report what came back, without throwing on a 4xx/5xx.
|
|
79
|
+
*
|
|
80
|
+
* Every scenario that reaches past an actor was writing this by hand — the same
|
|
81
|
+
* `content-type`, the same `JSON.stringify`, the same drain — and the copies had
|
|
82
|
+
* drifted: some returned `res.json()`, which loses the status and throws
|
|
83
|
+
* outright when the target answers an empty body or an HTML error page. A
|
|
84
|
+
* refusal is the expected outcome of a permissions scenario, so it has to
|
|
85
|
+
* survive as data.
|
|
86
|
+
*/
|
|
87
|
+
export const postScenarioJson = async <T = unknown>(
|
|
88
|
+
url: string,
|
|
89
|
+
{
|
|
90
|
+
body,
|
|
91
|
+
headers,
|
|
92
|
+
method = 'POST',
|
|
93
|
+
fetch: send = fetch,
|
|
94
|
+
}: ScenarioJsonRequest = {}
|
|
95
|
+
): Promise<ScenarioHttpResponse<T>> =>
|
|
96
|
+
readScenarioHttpResponse<T>(
|
|
97
|
+
await send(url, {
|
|
98
|
+
method,
|
|
99
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
100
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
101
|
+
})
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
/** Per-call transport options. */
|
|
105
|
+
export interface ScenarioInvokeOptions {
|
|
106
|
+
/**
|
|
107
|
+
* Headers to send alongside the actor's own session. This is how a step
|
|
108
|
+
* expresses an identity the actor registry cannot — an impersonation header,
|
|
109
|
+
* or one of the header-shim principals a credential scenario invents.
|
|
110
|
+
*/
|
|
111
|
+
headers?: Record<string, string>
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The RPC surface an actor can reach, as name → input/output. A project binds
|
|
116
|
+
* its generated exposed RPC map here; the default leaves every name open, which
|
|
117
|
+
* is what an actor built by hand (or by a third-party driver) gets.
|
|
118
|
+
*/
|
|
119
|
+
export type ScenarioRpcMap = Record<string, { input: any; output: any }>
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The actor a step wire carries, for a project whose actor registry is known.
|
|
123
|
+
* An empty registry keeps the open actor type rather than collapsing to
|
|
124
|
+
* `never` — a project may still build actors itself.
|
|
125
|
+
*/
|
|
126
|
+
export type ScenarioActorOf<TActors> = [keyof TActors] extends [never]
|
|
127
|
+
? ScenarioActor
|
|
128
|
+
: TActors[keyof TActors]
|
|
129
|
+
|
|
6
130
|
/** A synthetic user (a user row flagged `actor`) that workflow steps run as over the real transport */
|
|
7
|
-
export interface ScenarioActor<
|
|
131
|
+
export interface ScenarioActor<
|
|
132
|
+
TAgentName extends string = string,
|
|
133
|
+
TRpcMap extends ScenarioRpcMap = ScenarioRpcMap,
|
|
134
|
+
> {
|
|
8
135
|
/** Stable actor name (the key in pikku.config.json's actor registry). */
|
|
9
136
|
readonly name: string
|
|
10
137
|
/** The actor's user email — flows use it for invites/lookups. */
|
|
11
138
|
readonly email: string
|
|
12
139
|
/** Invoke an exposed RPC as this actor over the real transport. */
|
|
13
|
-
invoke
|
|
140
|
+
invoke<TName extends keyof TRpcMap & string>(
|
|
141
|
+
rpcName: TName,
|
|
142
|
+
data: TRpcMap[TName]['input']
|
|
143
|
+
): Promise<TRpcMap[TName]['output']>
|
|
144
|
+
/**
|
|
145
|
+
* The same call, reporting what the transport answered rather than throwing.
|
|
146
|
+
* A refusal is the expected outcome of a permissions or scopes scenario, and
|
|
147
|
+
* `invoke`'s error truncates the body that names which scope was missing.
|
|
148
|
+
*/
|
|
149
|
+
invokeRaw<TName extends keyof TRpcMap & string>(
|
|
150
|
+
rpcName: TName,
|
|
151
|
+
data: TRpcMap[TName]['input'],
|
|
152
|
+
options?: ScenarioInvokeOptions
|
|
153
|
+
): Promise<ScenarioHttpResponse>
|
|
14
154
|
/** Converse with a Pikku AI agent in this actor's persona and return its verdict */
|
|
15
155
|
converse(options: ConverseOptions<TAgentName>): Promise<ActorFlowVerdict>
|
|
16
156
|
}
|
|
@@ -21,6 +161,21 @@ export interface ScenarioActorConfig {
|
|
|
21
161
|
name?: string
|
|
22
162
|
jobTitle?: string
|
|
23
163
|
personality?: string
|
|
164
|
+
/**
|
|
165
|
+
* The persona this body is one of — the KIND of person, declared in
|
|
166
|
+
* `scenarios.personas`. Most personas have exactly one actor and it is
|
|
167
|
+
* materialised for them; a second body of the same persona is what tenant
|
|
168
|
+
* isolation and peer-sharing scenarios are made of.
|
|
169
|
+
*/
|
|
170
|
+
persona?: string
|
|
171
|
+
/**
|
|
172
|
+
* Scopes this actor holds, granted directly rather than through a role, and
|
|
173
|
+
* the roles it belongs to. Pikku carries them; it never applies them — which
|
|
174
|
+
* scope store exists and which roles have been created is the app's own, so
|
|
175
|
+
* the app's seed reads these back off `scenarioActorConfigs` and grants them.
|
|
176
|
+
*/
|
|
177
|
+
scopes?: readonly string[]
|
|
178
|
+
roles?: readonly string[]
|
|
24
179
|
}
|
|
25
180
|
|
|
26
181
|
/** The injected `actors` service: actor name → actor. */
|
package/src/types/core.types.ts
CHANGED
|
@@ -22,6 +22,10 @@ import type {
|
|
|
22
22
|
WorkflowServiceConfig,
|
|
23
23
|
WorkflowStepWire,
|
|
24
24
|
} from '../wirings/workflow/workflow.types.js'
|
|
25
|
+
import type {
|
|
26
|
+
PikkuBrowserWire,
|
|
27
|
+
PikkuScenarioStepWire,
|
|
28
|
+
} from '../wirings/workflow/scenario-step.types.js'
|
|
25
29
|
import type { PikkuGraphWire } from '../wirings/workflow/graph/workflow-graph.types.js'
|
|
26
30
|
import type { PikkuTrigger } from '../wirings/trigger/trigger.types.js'
|
|
27
31
|
import type { PikkuGateway } from '../wirings/gateway/gateway.types.js'
|
|
@@ -30,7 +34,10 @@ import type { DeploymentService } from '../services/deployment-service.js'
|
|
|
30
34
|
import type { AIStorageService } from '../services/ai-storage-service.js'
|
|
31
35
|
|
|
32
36
|
import type { ContentService } from '../services/content-service.js'
|
|
33
|
-
import type {
|
|
37
|
+
import type {
|
|
38
|
+
ScenarioActorOf,
|
|
39
|
+
ScenarioActors,
|
|
40
|
+
} from '../services/scenario-actors-service.js'
|
|
34
41
|
import type { AIAgentRunnerService } from '../services/ai-agent-runner-service.js'
|
|
35
42
|
import type { AIEmbeddingService } from '../services/ai-embedding-service.js'
|
|
36
43
|
import type { AIRunStateService } from '../services/ai-run-state-service.js'
|
|
@@ -123,6 +130,15 @@ export type FunctionRuntimeMeta = {
|
|
|
123
130
|
scopes?: string[]
|
|
124
131
|
expose?: boolean
|
|
125
132
|
remote?: boolean
|
|
133
|
+
/**
|
|
134
|
+
* A step RPC: a name dispatched by a scenario run and refused everywhere
|
|
135
|
+
* else. It sits alongside `expose` (public) and `remote` as a kind of RPC
|
|
136
|
+
* rather than a separate concept — a step is invoked by name exactly as an
|
|
137
|
+
* RPC is, which is why a run records the step function in its `rpcName`.
|
|
138
|
+
* What makes it its own kind is that it is never network-callable: a step
|
|
139
|
+
* may drive a browser or assert against fixtures.
|
|
140
|
+
*/
|
|
141
|
+
scenarioStep?: boolean
|
|
126
142
|
mcp?: boolean
|
|
127
143
|
readonly?: boolean
|
|
128
144
|
deploy?: 'serverless' | 'server' | 'auto'
|
|
@@ -133,6 +149,10 @@ export type FunctionRuntimeMeta = {
|
|
|
133
149
|
workflowRetries?: number
|
|
134
150
|
/** Timeout when this function is used as a workflow step (e.g. '30s', '5m'). */
|
|
135
151
|
workflowTimeout?: string
|
|
152
|
+
/** Scenario steps only: this step drives a browser, so the runner must provision one and an actor is mandatory. */
|
|
153
|
+
scenarioStepBrowser?: boolean
|
|
154
|
+
/** Scenario steps only: the prose a reporter renders, with `{placeholders}` filled from the step's recorded input. */
|
|
155
|
+
scenarioStepTemplate?: string
|
|
136
156
|
version?: number
|
|
137
157
|
approvalRequired?: boolean
|
|
138
158
|
approvalDescription?: string
|
|
@@ -380,6 +400,7 @@ export type PikkuWire<
|
|
|
380
400
|
TypedWorkflow extends PikkuWorkflowWire | never = PikkuWorkflowWire,
|
|
381
401
|
TriggerOutput = unknown,
|
|
382
402
|
TypedScenario extends PikkuScenarioWire | never = PikkuScenarioWire,
|
|
403
|
+
TypedActors extends ScenarioActors = ScenarioActors,
|
|
383
404
|
> = {
|
|
384
405
|
/** Always present — lazily initialised on first access for every function invocation */
|
|
385
406
|
rpc: TypedRPC
|
|
@@ -402,7 +423,11 @@ export type PikkuWire<
|
|
|
402
423
|
cli: PikkuCLI
|
|
403
424
|
workflow: TypedWorkflow
|
|
404
425
|
scenario: TypedScenario
|
|
405
|
-
actors:
|
|
426
|
+
actors: TypedActors
|
|
427
|
+
/** Present on every scenario step invocation */
|
|
428
|
+
scenarioStep: PikkuScenarioStepWire<ScenarioActorOf<TypedActors>>
|
|
429
|
+
/** Present only when the runner provisioned a browser for this step */
|
|
430
|
+
browser: PikkuBrowserWire
|
|
406
431
|
workflowStep: WorkflowStepWire
|
|
407
432
|
graph: PikkuGraphWire
|
|
408
433
|
trigger: PikkuTrigger<TriggerOutput>
|
package/src/types/state.types.ts
CHANGED
|
@@ -40,6 +40,7 @@ import type {
|
|
|
40
40
|
} from '../wirings/scheduler/scheduler.types.js'
|
|
41
41
|
import type {
|
|
42
42
|
CoreWorkflow,
|
|
43
|
+
CoreFeature,
|
|
43
44
|
WorkflowsRuntimeMeta,
|
|
44
45
|
} from '../wirings/workflow/workflow.types.js'
|
|
45
46
|
import type {
|
|
@@ -129,6 +130,8 @@ export interface PikkuPackageState {
|
|
|
129
130
|
}
|
|
130
131
|
workflows: {
|
|
131
132
|
registrations: Map<string, CoreWorkflow>
|
|
133
|
+
/** Scenario groups declared with `pikkuFeature`, keyed by export name. */
|
|
134
|
+
features: Map<string, CoreFeature>
|
|
132
135
|
meta: WorkflowsRuntimeMeta
|
|
133
136
|
}
|
|
134
137
|
trigger: {
|
|
@@ -21,7 +21,7 @@ export interface ConverseOptions<TAgentName extends string = string> {
|
|
|
21
21
|
evaluate: string
|
|
22
22
|
/** How the actor answers the agent's tool-approval requests. Default `'in-persona'`. */
|
|
23
23
|
approvals?: ActorFlowApprovalPolicy
|
|
24
|
-
/** Model the
|
|
24
|
+
/** Model the actor uses for its own turns/decisions. Falls back to the actor service default. */
|
|
25
25
|
model?: string
|
|
26
26
|
/** Hard cap on conversation turns before forcing evaluation. Default 12. */
|
|
27
27
|
maxTurns?: number
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, test } from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
3
|
|
|
4
|
-
import { runConversation, type
|
|
4
|
+
import { runConversation, type ActorLLM } from './run-conversation.js'
|
|
5
5
|
import type {
|
|
6
6
|
TargetAgentDriver,
|
|
7
7
|
TargetAgentReply,
|
|
@@ -23,10 +23,10 @@ const scriptedLLM = (script: {
|
|
|
23
23
|
turns: Array<{ message: string; done: boolean }>
|
|
24
24
|
decisions: Array<{ toolCallId: string; approved: boolean }>
|
|
25
25
|
evaluation: { passed: boolean; reasoning: string }
|
|
26
|
-
}): { llm:
|
|
26
|
+
}): { llm: ActorLLM; calls: string[] } => {
|
|
27
27
|
let turn = 0
|
|
28
28
|
const calls: string[] = []
|
|
29
|
-
const llm:
|
|
29
|
+
const llm: ActorLLM = async (params) => {
|
|
30
30
|
const props =
|
|
31
31
|
(params.outputSchema as { properties?: Record<string, unknown> })
|
|
32
32
|
?.properties ?? {}
|
|
@@ -109,8 +109,8 @@ const alwaysSuspendingTarget = (): {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
const base = {
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
actor: { email: 'pm@example.com', name: 'Priya', personality: 'concise' },
|
|
113
|
+
actorName: 'Priya',
|
|
114
114
|
agentName: 'todoBot',
|
|
115
115
|
task: 'Get a todo created',
|
|
116
116
|
evaluate: 'A todo now exists',
|
|
@@ -168,7 +168,13 @@ describe('runConversation', () => {
|
|
|
168
168
|
const { target, approveCalls } = alwaysSuspendingTarget()
|
|
169
169
|
|
|
170
170
|
await assert.rejects(
|
|
171
|
-
runConversation({
|
|
171
|
+
runConversation({
|
|
172
|
+
...base,
|
|
173
|
+
approvals: 'always',
|
|
174
|
+
maxApprovalRounds: 3,
|
|
175
|
+
llm,
|
|
176
|
+
target,
|
|
177
|
+
}),
|
|
172
178
|
/approval rounds/
|
|
173
179
|
)
|
|
174
180
|
assert.equal(approveCalls(), 3)
|