@pikku/core 0.12.67 → 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 (96) hide show
  1. package/CHANGELOG.md +397 -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/in-memory-workflow-service.d.ts +2 -2
  11. package/dist/services/in-memory-workflow-service.js +2 -2
  12. package/dist/services/index.d.ts +2 -1
  13. package/dist/services/index.js +1 -0
  14. package/dist/services/meta-service.d.ts +5 -1
  15. package/dist/services/meta-service.js +44 -18
  16. package/dist/services/scenario-actors-service.d.ts +108 -2
  17. package/dist/services/scenario-actors-service.js +40 -1
  18. package/dist/types/core.types.d.ts +21 -3
  19. package/dist/types/state.types.d.ts +3 -1
  20. package/dist/wirings/actor-flow/actor-flow.types.d.ts +1 -1
  21. package/dist/wirings/actor-flow/index.d.ts +1 -1
  22. package/dist/wirings/actor-flow/run-conversation.d.ts +10 -10
  23. package/dist/wirings/actor-flow/run-conversation.js +27 -27
  24. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +18 -1
  25. package/dist/wirings/ai-agent/ai-agent-prepare.js +26 -4
  26. package/dist/wirings/cli/command-parser.js +11 -1
  27. package/dist/wirings/queue/index.d.ts +1 -1
  28. package/dist/wirings/queue/queue.types.d.ts +30 -0
  29. package/dist/wirings/rpc/rpc-runner.js +1 -1
  30. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +52 -3
  31. package/dist/wirings/workflow/feature.d.ts +28 -0
  32. package/dist/wirings/workflow/feature.js +57 -0
  33. package/dist/wirings/workflow/index.d.ts +13 -2
  34. package/dist/wirings/workflow/index.js +15 -0
  35. package/dist/wirings/workflow/pikku-scenario-service.d.ts +121 -0
  36. package/dist/wirings/workflow/pikku-scenario-service.js +419 -0
  37. package/dist/wirings/workflow/pikku-workflow-service.d.ts +118 -12
  38. package/dist/wirings/workflow/pikku-workflow-service.js +166 -153
  39. package/dist/wirings/workflow/scenario-cookie-jar.d.ts +29 -0
  40. package/dist/wirings/workflow/scenario-cookie-jar.js +51 -0
  41. package/dist/wirings/workflow/scenario-poll.d.ts +20 -0
  42. package/dist/wirings/workflow/scenario-poll.js +25 -0
  43. package/dist/wirings/workflow/scenario-prose.d.ts +38 -0
  44. package/dist/wirings/workflow/scenario-prose.js +45 -0
  45. package/dist/wirings/workflow/scenario-step-guards.d.ts +16 -0
  46. package/dist/wirings/workflow/scenario-step-guards.js +29 -0
  47. package/dist/wirings/workflow/scenario-step.types.d.ts +148 -0
  48. package/dist/wirings/workflow/scenario-step.types.js +1 -0
  49. package/dist/wirings/workflow/workflow.types.d.ts +119 -2
  50. package/package.json +3 -1
  51. package/src/function/functions.types.ts +32 -0
  52. package/src/index.ts +1 -0
  53. package/src/internal.ts +5 -1
  54. package/src/pikku-state.ts +1 -0
  55. package/src/services/http-scenario-actors.test.ts +85 -1
  56. package/src/services/http-scenario-actors.ts +65 -51
  57. package/src/services/in-memory-workflow-service.test.ts +50 -1
  58. package/src/services/in-memory-workflow-service.ts +3 -2
  59. package/src/services/index.ts +5 -0
  60. package/src/services/meta-service.test.ts +79 -0
  61. package/src/services/meta-service.ts +61 -26
  62. package/src/services/scenario-actors-service.ts +157 -2
  63. package/src/types/core.types.ts +27 -2
  64. package/src/types/state.types.ts +3 -0
  65. package/src/wirings/actor-flow/actor-flow.types.ts +1 -1
  66. package/src/wirings/actor-flow/index.ts +1 -1
  67. package/src/wirings/actor-flow/run-conversation.test.ts +12 -6
  68. package/src/wirings/actor-flow/run-conversation.ts +36 -41
  69. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +29 -0
  70. package/src/wirings/ai-agent/ai-agent-prepare.ts +38 -4
  71. package/src/wirings/cli/command-parser.test.ts +60 -0
  72. package/src/wirings/cli/command-parser.ts +12 -1
  73. package/src/wirings/queue/index.ts +2 -0
  74. package/src/wirings/queue/queue.types.ts +32 -0
  75. package/src/wirings/rpc/rpc-runner.test.ts +28 -5
  76. package/src/wirings/rpc/rpc-runner.ts +1 -1
  77. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +86 -2
  78. package/src/wirings/workflow/feature.test.ts +131 -0
  79. package/src/wirings/workflow/feature.ts +78 -0
  80. package/src/wirings/workflow/index.ts +74 -0
  81. package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
  82. package/src/wirings/workflow/pikku-workflow-service.test.ts +126 -0
  83. package/src/wirings/workflow/pikku-workflow-service.ts +306 -228
  84. package/src/wirings/workflow/scenario-cookie-jar.test.ts +108 -0
  85. package/src/wirings/workflow/scenario-cookie-jar.ts +65 -0
  86. package/src/wirings/workflow/scenario-hooks.test.ts +212 -0
  87. package/src/wirings/workflow/scenario-poll.test.ts +66 -0
  88. package/src/wirings/workflow/scenario-poll.ts +36 -0
  89. package/src/wirings/workflow/scenario-prose.test.ts +152 -0
  90. package/src/wirings/workflow/scenario-prose.ts +79 -0
  91. package/src/wirings/workflow/scenario-service.test.ts +155 -0
  92. package/src/wirings/workflow/scenario-step-guards.ts +43 -0
  93. package/src/wirings/workflow/scenario-step.test.ts +441 -8
  94. package/src/wirings/workflow/scenario-step.types.ts +157 -0
  95. package/src/wirings/workflow/workflow.types.ts +137 -1
  96. package/tsconfig.tsbuildinfo +1 -1
@@ -2,13 +2,20 @@ import type {
2
2
  ScenarioActor,
3
3
  ScenarioActorConfig,
4
4
  ScenarioActors,
5
+ ScenarioInvokeOptions,
6
+ ScenarioHttpResponse,
5
7
  } from './scenario-actors-service.js'
8
+ import { readScenarioHttpResponse } from './scenario-actors-service.js'
6
9
  import type {
7
10
  ConverseOptions,
8
11
  ActorFlowVerdict,
9
12
  TargetAgentReply,
10
13
  } from '../wirings/actor-flow/actor-flow.types.js'
11
14
  import { runConversation } from '../wirings/actor-flow/run-conversation.js'
15
+ import {
16
+ createCookieJar,
17
+ type ScenarioCookieJar,
18
+ } from '../wirings/workflow/scenario-cookie-jar.js'
12
19
  import { getSingletonServices } from '../pikku-state.js'
13
20
  import { AIProviderNotConfiguredError } from '../errors/errors.js'
14
21
 
@@ -48,15 +55,21 @@ export interface HttpScenarioActorsConfig {
48
55
  * outlive a session).
49
56
  */
50
57
  export class HttpScenarioActor implements ScenarioActor {
51
- private cookie: string | null = null
52
- private origin: string
58
+ private jar: ScenarioCookieJar
59
+ /**
60
+ * Whether `login()` has succeeded since the last time the session was
61
+ * dropped. The jar cannot answer this — a target may set a cookie before
62
+ * anyone signs in, and it would then look like a session that was never
63
+ * established.
64
+ */
65
+ private signedIn = false
53
66
 
54
67
  constructor(
55
68
  readonly name: string,
56
69
  private actorConfig: ScenarioActorConfig,
57
70
  private config: HttpScenarioActorsConfig
58
71
  ) {
59
- this.origin = new URL(config.apiUrl).origin
72
+ this.jar = createCookieJar(config.apiUrl)
60
73
  }
61
74
 
62
75
  get email(): string {
@@ -64,17 +77,31 @@ export class HttpScenarioActor implements ScenarioActor {
64
77
  }
65
78
 
66
79
  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)
80
+ const res = await this.invokeRaw(rpcName, data)
81
+ if (!res.ok) {
82
+ throw new Error(
83
+ `[scenario] '${rpcName}' as '${this.name}' returned ${res.status}: ${res.serialized.slice(0, 300)}`
84
+ )
85
+ }
86
+ return res.body
87
+ }
88
+
89
+ async invokeRaw(
90
+ rpcName: string,
91
+ data: unknown,
92
+ options?: ScenarioInvokeOptions
93
+ ): Promise<ScenarioHttpResponse> {
94
+ if (!this.signedIn) {
95
+ await this.login()
96
+ }
97
+ let res = await this.postRpc(rpcName, data, options?.headers)
69
98
  if (res.status === 401) {
70
99
  // 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
- )
100
+ this.signOut()
101
+ await this.login()
102
+ res = await this.postRpc(rpcName, data, options?.headers)
76
103
  }
77
- return this.readRpcResponse(rpcName, res)
104
+ return readScenarioHttpResponse(res)
78
105
  }
79
106
 
80
107
  async converse(options: ConverseOptions): Promise<ActorFlowVerdict> {
@@ -92,8 +119,8 @@ export class HttpScenarioActor implements ScenarioActor {
92
119
  const resourceId = `actor:${this.name}`
93
120
 
94
121
  return runConversation({
95
- persona: this.actorConfig,
96
- personaName: this.actorConfig.name ?? this.name,
122
+ actor: this.actorConfig,
123
+ actorName: this.actorConfig.name ?? this.name,
97
124
  agentName: options.agent,
98
125
  task: options.task,
99
126
  evaluate: options.evaluate,
@@ -148,21 +175,18 @@ export class HttpScenarioActor implements ScenarioActor {
148
175
  private async postAgent(subPath: string, body: unknown): Promise<unknown> {
149
176
  const rpcPath = this.config.rpcPath ?? '/rpc'
150
177
  const url = `${this.config.apiUrl}${rpcPath}/${subPath}`
151
- const send = (cookie: string | null) =>
152
- fetch(url, {
178
+ const send = () =>
179
+ this.jar.fetch(url, {
153
180
  method: 'POST',
154
- headers: {
155
- 'content-type': 'application/json',
156
- origin: this.origin,
157
- ...(cookie ? { cookie } : {}),
158
- },
181
+ headers: { 'content-type': 'application/json' },
159
182
  body: JSON.stringify(body),
160
183
  })
161
184
 
162
- let res = await send(this.cookie)
185
+ let res = await send()
163
186
  if (res.status === 401) {
164
- this.cookie = null
165
- res = await send(await this.login())
187
+ this.signOut()
188
+ await this.login()
189
+ res = await send()
166
190
  }
167
191
  if (!res.ok) {
168
192
  const text = (await res.text().catch(() => '')).slice(0, 300)
@@ -175,36 +199,30 @@ export class HttpScenarioActor implements ScenarioActor {
175
199
  return text ? JSON.parse(text) : undefined
176
200
  }
177
201
 
178
- private async postRpc(rpcName: string, data: unknown, cookie: string) {
202
+ private async postRpc(
203
+ rpcName: string,
204
+ data: unknown,
205
+ extraHeaders?: Record<string, string>
206
+ ) {
179
207
  const rpcPath = this.config.rpcPath ?? '/rpc'
180
- return fetch(`${this.config.apiUrl}${rpcPath}/${rpcName}`, {
208
+ return this.jar.fetch(`${this.config.apiUrl}${rpcPath}/${rpcName}`, {
181
209
  method: 'POST',
182
- headers: {
183
- 'content-type': 'application/json',
184
- origin: this.origin,
185
- cookie,
186
- },
210
+ headers: { 'content-type': 'application/json', ...extraHeaders },
187
211
  body: JSON.stringify({ data }),
188
212
  })
189
213
  }
190
214
 
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
215
+ /** Drop the session, so the next call signs in again before it goes out. */
216
+ private signOut(): void {
217
+ this.jar.clear()
218
+ this.signedIn = false
201
219
  }
202
220
 
203
- private async login(): Promise<string> {
221
+ private async login(): Promise<void> {
204
222
  const signInPath = this.config.signInPath ?? '/auth/sign-in/actor'
205
- const res = await fetch(`${this.config.apiUrl}${signInPath}`, {
223
+ const res = await this.jar.fetch(`${this.config.apiUrl}${signInPath}`, {
206
224
  method: 'POST',
207
- headers: { 'content-type': 'application/json', origin: this.origin },
225
+ headers: { 'content-type': 'application/json' },
208
226
  body: JSON.stringify({
209
227
  email: this.actorConfig.email,
210
228
  name: this.actorConfig.name ?? this.name,
@@ -217,18 +235,14 @@ export class HttpScenarioActor implements ScenarioActor {
217
235
  `[scenario] actor sign-in failed for '${this.name}' (${res.status}): ${body}`
218
236
  )
219
237
  }
220
- const setCookies = res.headers.getSetCookie?.() ?? []
221
- const cookie = setCookies
222
- .map((c) => c.split(';')[0]!)
223
- .filter(Boolean)
224
- .join('; ')
225
- if (!cookie) {
238
+ // What proves a session was established is this response setting a cookie,
239
+ // not the jar being non-empty — the target may have set one earlier.
240
+ if (res.headers.getSetCookie().length === 0) {
226
241
  throw new Error(
227
242
  `[scenario] actor sign-in for '${this.name}' returned no session cookie`
228
243
  )
229
244
  }
230
- this.cookie = cookie
231
- return cookie
245
+ this.signedIn = true
232
246
  }
233
247
  }
234
248
 
@@ -2,7 +2,7 @@ import { describe, test, beforeEach } from 'node:test'
2
2
  import assert from 'node:assert'
3
3
  import { InMemoryWorkflowService } from './in-memory-workflow-service.js'
4
4
  import { getQueueWorkers } from '../wirings/queue/queue-runner.js'
5
- import { pikkuState } from '../pikku-state.js'
5
+ import { pikkuState, resetPikkuState } from '../pikku-state.js'
6
6
 
7
7
  let service: InMemoryWorkflowService
8
8
 
@@ -390,5 +390,54 @@ describe('InMemoryWorkflowService', () => {
390
390
  assert.strictEqual(getQueueWorkers().has(stepQueue), true)
391
391
  assert.strictEqual(getQueueWorkers().has(orchQueue), true)
392
392
  })
393
+
394
+ test('shared-groups leaves per-workflow queues unconsumed', () => {
395
+ const orchQueue = 'wf-orchestrator-shared-strategy-check'
396
+
397
+ const queueMeta = pikkuState(null, 'queue', 'meta')
398
+ queueMeta[orchQueue] = {
399
+ name: orchQueue,
400
+ pikkuFuncId: 'pikkuWorkflowOrchestrator:sharedStrategyCheck',
401
+ }
402
+
403
+ const ws = new InMemoryWorkflowService({
404
+ queueStrategy: 'shared-groups',
405
+ })
406
+ ws.wireQueueWorkers()
407
+
408
+ // The whole point: one set of pollers, not one per workflow.
409
+ assert.strictEqual(getQueueWorkers().has(orchQueue), false)
410
+ assert.strictEqual(
411
+ getQueueWorkers().has('pikku-workflow-orchestrator'),
412
+ true
413
+ )
414
+ })
415
+
416
+ test('shared-groups caps how much of the shared queue one workflow takes', () => {
417
+ // registerWorkflowFunc is first-write-wins, so start from clean state
418
+ // rather than inheriting the shared queues an earlier test registered.
419
+ resetPikkuState()
420
+ const ws = new InMemoryWorkflowService({
421
+ queueStrategy: 'shared-groups',
422
+ queueConcurrency: 20,
423
+ queueGroupConcurrency: 3,
424
+ })
425
+ ws.wireQueueWorkers()
426
+
427
+ const worker = getQueueWorkers().get('pikku-workflow-orchestrator')
428
+ assert.deepStrictEqual(worker?.config, {
429
+ batchSize: 20,
430
+ groupConcurrency: 3,
431
+ })
432
+ })
433
+
434
+ test('per-workflow (the default) leaves the shared queues unconstrained', () => {
435
+ resetPikkuState()
436
+ const ws = new InMemoryWorkflowService()
437
+ ws.wireQueueWorkers()
438
+
439
+ const worker = getQueueWorkers().get('pikku-workflow-orchestrator')
440
+ assert.strictEqual(worker?.config, undefined)
441
+ })
393
442
  })
394
443
  })
@@ -4,6 +4,7 @@ import { isExpectedError } from '../errors/error-handler.js'
4
4
  import type { SerializedError } from '../types/core.types.js'
5
5
  import type {
6
6
  WorkflowPlannedStep,
7
+ WorkflowQueueOptions,
7
8
  WorkflowRun,
8
9
  WorkflowRunService,
9
10
  WorkflowRunWire,
@@ -39,8 +40,8 @@ export class InMemoryWorkflowService
39
40
  extends PikkuWorkflowService
40
41
  implements WorkflowRunService
41
42
  {
42
- constructor() {
43
- super({ wireQueues: false })
43
+ constructor(options: WorkflowQueueOptions = {}) {
44
+ super({ ...options, wireQueues: false })
44
45
  }
45
46
 
46
47
  private sleepTimers = new Set<ReturnType<typeof setTimeout>>()
@@ -43,8 +43,13 @@ export type {
43
43
  export type {
44
44
  ScenarioActor,
45
45
  ScenarioActorConfig,
46
+ ScenarioActorOf,
46
47
  ScenarioActors,
48
+ ScenarioInvokeOptions,
49
+ ScenarioRpcMap,
50
+ ScenarioHttpResponse,
47
51
  } from './scenario-actors-service.js'
52
+ export { readScenarioHttpResponse } from './scenario-actors-service.js'
48
53
  export {
49
54
  HttpScenarioActor,
50
55
  createHttpScenarioActors,
@@ -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