@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.
Files changed (84) hide show
  1. package/CHANGELOG.md +334 -0
  2. package/README.md +34 -2
  3. package/dist/function/functions.types.d.ts +27 -0
  4. package/dist/index.d.ts +1 -1
  5. package/dist/internal.d.ts +1 -1
  6. package/dist/internal.js +1 -1
  7. package/dist/pikku-state.js +1 -0
  8. package/dist/services/http-scenario-actors.d.ts +12 -4
  9. package/dist/services/http-scenario-actors.js +47 -45
  10. package/dist/services/index.d.ts +2 -1
  11. package/dist/services/index.js +1 -0
  12. package/dist/services/meta-service.d.ts +5 -1
  13. package/dist/services/meta-service.js +44 -18
  14. package/dist/services/scenario-actors-service.d.ts +108 -2
  15. package/dist/services/scenario-actors-service.js +40 -1
  16. package/dist/types/core.types.d.ts +21 -3
  17. package/dist/types/state.types.d.ts +3 -1
  18. package/dist/wirings/actor-flow/actor-flow.types.d.ts +1 -1
  19. package/dist/wirings/actor-flow/index.d.ts +1 -1
  20. package/dist/wirings/actor-flow/run-conversation.d.ts +10 -10
  21. package/dist/wirings/actor-flow/run-conversation.js +27 -27
  22. package/dist/wirings/cli/command-parser.js +11 -1
  23. package/dist/wirings/rpc/rpc-runner.js +1 -1
  24. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +52 -3
  25. package/dist/wirings/workflow/feature.d.ts +28 -0
  26. package/dist/wirings/workflow/feature.js +57 -0
  27. package/dist/wirings/workflow/index.d.ts +13 -2
  28. package/dist/wirings/workflow/index.js +15 -0
  29. package/dist/wirings/workflow/pikku-scenario-service.d.ts +121 -0
  30. package/dist/wirings/workflow/pikku-scenario-service.js +419 -0
  31. package/dist/wirings/workflow/pikku-workflow-service.d.ts +103 -10
  32. package/dist/wirings/workflow/pikku-workflow-service.js +80 -135
  33. package/dist/wirings/workflow/scenario-cookie-jar.d.ts +29 -0
  34. package/dist/wirings/workflow/scenario-cookie-jar.js +51 -0
  35. package/dist/wirings/workflow/scenario-poll.d.ts +20 -0
  36. package/dist/wirings/workflow/scenario-poll.js +25 -0
  37. package/dist/wirings/workflow/scenario-prose.d.ts +38 -0
  38. package/dist/wirings/workflow/scenario-prose.js +45 -0
  39. package/dist/wirings/workflow/scenario-step-guards.d.ts +16 -0
  40. package/dist/wirings/workflow/scenario-step-guards.js +29 -0
  41. package/dist/wirings/workflow/scenario-step.types.d.ts +148 -0
  42. package/dist/wirings/workflow/scenario-step.types.js +1 -0
  43. package/dist/wirings/workflow/workflow.types.d.ts +81 -2
  44. package/package.json +3 -1
  45. package/src/function/functions.types.ts +32 -0
  46. package/src/index.ts +1 -0
  47. package/src/internal.ts +5 -1
  48. package/src/pikku-state.ts +1 -0
  49. package/src/services/http-scenario-actors.test.ts +85 -1
  50. package/src/services/http-scenario-actors.ts +65 -51
  51. package/src/services/index.ts +5 -0
  52. package/src/services/meta-service.test.ts +79 -0
  53. package/src/services/meta-service.ts +61 -26
  54. package/src/services/scenario-actors-service.ts +157 -2
  55. package/src/types/core.types.ts +27 -2
  56. package/src/types/state.types.ts +3 -0
  57. package/src/wirings/actor-flow/actor-flow.types.ts +1 -1
  58. package/src/wirings/actor-flow/index.ts +1 -1
  59. package/src/wirings/actor-flow/run-conversation.test.ts +12 -6
  60. package/src/wirings/actor-flow/run-conversation.ts +36 -41
  61. package/src/wirings/cli/command-parser.test.ts +60 -0
  62. package/src/wirings/cli/command-parser.ts +12 -1
  63. package/src/wirings/rpc/rpc-runner.test.ts +28 -5
  64. package/src/wirings/rpc/rpc-runner.ts +1 -1
  65. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +86 -2
  66. package/src/wirings/workflow/feature.test.ts +131 -0
  67. package/src/wirings/workflow/feature.ts +78 -0
  68. package/src/wirings/workflow/index.ts +73 -0
  69. package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
  70. package/src/wirings/workflow/pikku-workflow-service.test.ts +55 -0
  71. package/src/wirings/workflow/pikku-workflow-service.ts +196 -208
  72. package/src/wirings/workflow/scenario-cookie-jar.test.ts +108 -0
  73. package/src/wirings/workflow/scenario-cookie-jar.ts +65 -0
  74. package/src/wirings/workflow/scenario-hooks.test.ts +212 -0
  75. package/src/wirings/workflow/scenario-poll.test.ts +66 -0
  76. package/src/wirings/workflow/scenario-poll.ts +36 -0
  77. package/src/wirings/workflow/scenario-prose.test.ts +152 -0
  78. package/src/wirings/workflow/scenario-prose.ts +79 -0
  79. package/src/wirings/workflow/scenario-service.test.ts +155 -0
  80. package/src/wirings/workflow/scenario-step-guards.ts +43 -0
  81. package/src/wirings/workflow/scenario-step.test.ts +441 -8
  82. package/src/wirings/workflow/scenario-step.types.ts +157 -0
  83. package/src/wirings/workflow/workflow.types.ts +98 -1
  84. 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 { WorkflowsMeta } from '../wirings/workflow/workflow.types.js'
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
- await Promise.all(
479
- filesToRead.map(async (file) => {
480
- const content = await this.readFile(`workflow/meta/${file}`)
481
- if (content) {
482
- const meta = JSON.parse(content)
483
- result[meta.name] = meta
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 this.readMetaJson('function', 'pikku-functions-meta')
531
- this.functionsMetaCache = content ? JSON.parse(content) : {}
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<TAgentName extends string = string> {
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(rpcName: string, data: unknown): Promise<unknown>
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. */
@@ -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 { ScenarioActors } from '../services/scenario-actors-service.js'
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: ScenarioActors
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>
@@ -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 persona uses for its own turns/decisions. Falls back to the actor service default. */
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
@@ -18,5 +18,5 @@ export type {
18
18
  export {
19
19
  runConversation,
20
20
  type RunConversationParams,
21
- type PersonaLLM,
21
+ type ActorLLM,
22
22
  } from './run-conversation.js'
@@ -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 PersonaLLM } from './run-conversation.js'
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: PersonaLLM; calls: string[] } => {
26
+ }): { llm: ActorLLM; calls: string[] } => {
27
27
  let turn = 0
28
28
  const calls: string[] = []
29
- const llm: PersonaLLM = async (params) => {
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
- persona: { email: 'pm@example.com', name: 'Priya', personality: 'concise' },
113
- personaName: 'Priya',
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({ ...base, approvals: 'always', maxApprovalRounds: 3, llm, target }),
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)