@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
@@ -74,6 +74,20 @@ const testMeta: CLIMeta = {
74
74
  positionals: [{ name: 'name', required: false }],
75
75
  options: {},
76
76
  },
77
+ serve: {
78
+ pikkuFuncId: 'serveFunc',
79
+ positionals: [],
80
+ options: {
81
+ browser: {
82
+ description: 'Open a browser',
83
+ default: true,
84
+ },
85
+ port: {
86
+ description: 'Port to listen on',
87
+ default: 8080,
88
+ },
89
+ },
90
+ },
77
91
  },
78
92
  },
79
93
  },
@@ -103,6 +117,52 @@ describe('Command Parser', () => {
103
117
  assert.strictEqual(result.errors.length, 0)
104
118
  })
105
119
 
120
+ test('should turn a boolean option off with --no-<flag>', () => {
121
+ const result = parseCLIArguments(
122
+ ['serve', '--no-browser'],
123
+ 'test-cli',
124
+ testMeta
125
+ )
126
+
127
+ assert.strictEqual(result.options.browser, false)
128
+ assert.strictEqual(result.errors.length, 0)
129
+ assert.deepStrictEqual(
130
+ result.warnings ?? [],
131
+ [],
132
+ '--no-browser is the negation of a known option, not an unknown one'
133
+ )
134
+ })
135
+
136
+ test('should leave a boolean option at its default when not negated', () => {
137
+ const result = parseCLIArguments(['serve'], 'test-cli', testMeta)
138
+
139
+ assert.strictEqual(result.options.browser, true)
140
+ })
141
+
142
+ test('should not consume the next argument when negating', () => {
143
+ const result = parseCLIArguments(
144
+ ['serve', '--no-browser', '--port', '4077'],
145
+ 'test-cli',
146
+ testMeta
147
+ )
148
+
149
+ assert.strictEqual(result.options.browser, false)
150
+ assert.strictEqual(result.options.port, 4077)
151
+ })
152
+
153
+ test('should still warn about an unknown --no-<flag>', () => {
154
+ const result = parseCLIArguments(
155
+ ['serve', '--no-telemetry'],
156
+ 'test-cli',
157
+ testMeta
158
+ )
159
+
160
+ assert.ok(
161
+ (result.warnings ?? []).length > 0,
162
+ 'negating an option that does not exist is a typo, not a feature'
163
+ )
164
+ })
165
+
106
166
  test('should parse command with short flag', () => {
107
167
  const result = parseCLIArguments(
108
168
  ['greet', 'Alice', '-l'],
@@ -210,8 +210,19 @@ export function parseCLIArguments(
210
210
 
211
211
  if (arg.startsWith('--')) {
212
212
  // Long option (--from-plan → fromPlan)
213
+ const negatedKey = arg.startsWith('--no-')
214
+ ? toCamelCase(arg.slice(5))
215
+ : undefined
213
216
  const equalIndex = arg.indexOf('=')
214
- if (equalIndex > 0) {
217
+ if (
218
+ negatedKey &&
219
+ typeof availableOptions[negatedKey]?.default === 'boolean'
220
+ ) {
221
+ // --no-<flag> turns a boolean option off. Only options that declare a
222
+ // boolean default can be negated, so a literal `--no-something` option
223
+ // name still parses as itself.
224
+ optionArgs[negatedKey] = false
225
+ } else if (equalIndex > 0) {
215
226
  // --option=value format
216
227
  const key = toCamelCase(arg.slice(2, equalIndex))
217
228
  const optionDef = availableOptions[key]
@@ -1,6 +1,8 @@
1
1
  export type {
2
2
  ConfigValidationResult,
3
3
  CoreQueueWorker,
4
+ GroupConcurrencyConfig,
5
+ JobGroup,
4
6
  JobOptions,
5
7
  PikkuJobConfig,
6
8
  PikkuWorkerConfig,
@@ -31,6 +31,36 @@ export interface PikkuWorkerConfig {
31
31
  maxStalledCount?: number
32
32
  /** Condition to start processor at instance creation */
33
33
  autorun?: boolean
34
+ /**
35
+ * Cap how many jobs of any one group ({@link JobOptions.group}) may run at
36
+ * once, so a single group can't occupy the whole worker. Lets one shared
37
+ * queue stay fair across producers instead of splitting it into one queue
38
+ * per producer — which multiplies polling cost on pull-based backends.
39
+ * Must not exceed {@link batchSize}.
40
+ */
41
+ groupConcurrency?: number | GroupConcurrencyConfig
42
+ }
43
+
44
+ /**
45
+ * Per-group concurrency limits, optionally varied by tier so slow groups can
46
+ * be allowed more (or fewer) slots than the default.
47
+ */
48
+ export interface GroupConcurrencyConfig {
49
+ /** Limit applied to any group without a matching tier */
50
+ default: number
51
+ /** Per-tier overrides, keyed by {@link JobGroup.tier} */
52
+ tiers?: Record<string, number>
53
+ }
54
+
55
+ /**
56
+ * Fairness key for a job. Jobs sharing an `id` count against the same
57
+ * {@link PikkuWorkerConfig.groupConcurrency} limit.
58
+ */
59
+ export interface JobGroup {
60
+ /** Group this job belongs to (e.g. a workflow name) */
61
+ id: string
62
+ /** Optional tier selecting a per-tier limit */
63
+ tier?: string
34
64
  }
35
65
 
36
66
  /**
@@ -113,6 +143,8 @@ export interface JobOptions {
113
143
  jobId?: string
114
144
  /** Pikku user ID to propagate to the queue worker for credential resolution */
115
145
  pikkuUserId?: string
146
+ /** Fairness key — counts against the worker's {@link PikkuWorkerConfig.groupConcurrency} */
147
+ group?: JobGroup
116
148
  }
117
149
 
118
150
  /**
@@ -29,11 +29,13 @@ const registerFunction = (
29
29
  expose,
30
30
  pikkuFuncId,
31
31
  tags,
32
+ scenarioStep,
32
33
  }: {
33
34
  packageName?: string | null
34
35
  expose?: boolean
35
36
  pikkuFuncId?: string
36
37
  tags?: string[]
38
+ scenarioStep?: boolean
37
39
  } = {}
38
40
  ) => {
39
41
  addFunction(funcName, { func } as never, packageName)
@@ -44,6 +46,7 @@ const registerFunction = (
44
46
  permissions: [],
45
47
  expose,
46
48
  tags,
49
+ scenarioStep,
47
50
  } as never
48
51
  }
49
52
 
@@ -312,6 +315,27 @@ describe('ContextAwareRPCService.rpcExposed', () => {
312
315
  )
313
316
  })
314
317
 
318
+ test('a scenario step is not callable over the network, even if marked exposed', async () => {
319
+ // A step may drive a browser or assert against fixtures, so it is a step
320
+ // RPC — dispatched by name inside a run, never reachable from outside it.
321
+ pikkuState(null, 'rpc', 'meta').seesAddonCard = 'seesAddonCard'
322
+ registerFunction('seesAddonCard', async () => ({ visible: true }), {
323
+ expose: true,
324
+ scenarioStep: true,
325
+ })
326
+
327
+ const service = new ContextAwareRPCService(
328
+ createServices(),
329
+ {} as never,
330
+ {}
331
+ )
332
+
333
+ await assert.rejects(
334
+ () => service.rpcExposed('seesAddonCard', {}),
335
+ RPCNotFoundError
336
+ )
337
+ })
338
+
315
339
  test('runs exposed addon functions', async () => {
316
340
  pikkuState(null, 'addons', 'packages').set('addon', {
317
341
  package: '@addon/pkg',
@@ -669,7 +693,9 @@ describe('wireRemoteAddon dispatch', () => {
669
693
  {}
670
694
  )
671
695
 
672
- const result = await service.rpc('registry:getOpenApi', { name: 'stripe' })
696
+ const result = await service.rpc('registry:getOpenApi', {
697
+ name: 'stripe',
698
+ })
673
699
 
674
700
  assert.deepEqual(result, { echoed: 42 })
675
701
  assert.equal(calls.length, 1)
@@ -713,10 +739,7 @@ describe('wireRemoteAddon dispatch', () => {
713
739
  )
714
740
 
715
741
  await service.rpc('registry:getOpenApi', {})
716
- assert.equal(
717
- calls[0]!.init.headers.authorization,
718
- 'Bearer user-token-9'
719
- )
742
+ assert.equal(calls[0]!.init.headers.authorization, 'Bearer user-token-9')
720
743
  } finally {
721
744
  restoreFetch()
722
745
  }
@@ -165,7 +165,7 @@ export class ContextAwareRPCService {
165
165
  if (!functionMeta) {
166
166
  throw new RPCNotFoundError(funcName)
167
167
  }
168
- if (!functionMeta.expose) {
168
+ if (!functionMeta.expose || functionMeta.scenarioStep) {
169
169
  throw new RPCNotFoundError(funcName)
170
170
  }
171
171
  return await this.rpc(funcName, data)
@@ -7,6 +7,10 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'
7
7
 
8
8
  import type { WorkflowRun } from '../workflow.types.js'
9
9
  import type { ScenarioActor } from '../../../services/scenario-actors-service.js'
10
+ import type {
11
+ ScenarioStepOptions,
12
+ ScenarioStepPhase,
13
+ } from '../scenario-step.types.js'
10
14
 
11
15
  /**
12
16
  * Workflow step options
@@ -79,6 +83,19 @@ export type WorkflowWireDoInline = <T>(
79
83
  options?: WorkflowStepOptions
80
84
  ) => Promise<T>
81
85
 
86
+ /**
87
+ * Type signature for scenario.step/given/when/then - used by inspector.
88
+ *
89
+ * Deliberately mirrors WorkflowWireDoRPC's shape: the target is a string, not
90
+ * an imported symbol, so the extractor reads it as a literal.
91
+ */
92
+ export type ScenarioStepInvocation = <TOutput = any, TInput = any>(
93
+ stepName: string,
94
+ stepFunc: string,
95
+ data?: TInput,
96
+ options?: ScenarioStepOptions
97
+ ) => Promise<TOutput>
98
+
82
99
  /**
83
100
  * Type signature for workflow.sleep() - used by inspector
84
101
  */
@@ -180,6 +197,34 @@ export interface RpcStepMeta {
180
197
  expectEventually?: boolean
181
198
  }
182
199
 
200
+ /**
201
+ * Scenario step metadata — a call to `scenario.step/given/when/then`.
202
+ *
203
+ * Distinct from RpcStepMeta on purpose: a step runs locally through
204
+ * runPikkuFunc and must never be treated as dispatchable on the queue/replay
205
+ * path, nor registered as a callable RPC (a browser-driving step must not be
206
+ * network-invocable).
207
+ */
208
+ export interface ScenarioStepMeta {
209
+ type: 'scenarioStep'
210
+ /** Cache key (first argument), ordinal-suffixed by the engine when repeated */
211
+ stepName: string
212
+ /** Registered name of the step function being run */
213
+ stepFunc: string
214
+ /** Which keyword the reporter prefixes — given/when/then, or none for `step` */
215
+ phase: ScenarioStepPhase
216
+ /** Output variable name (if assigned) */
217
+ outputVar?: string
218
+ /** Input source mappings, or 'passthrough' when entire data is passed */
219
+ inputs?: Record<string, InputSource> | 'passthrough'
220
+ /** Step options */
221
+ options?: WorkflowStepOptions
222
+ /** Scenario actor name this step runs as ({ actor: actors.x }) */
223
+ actor?: string
224
+ /** Mirrors the step function's `browser: true` declaration */
225
+ browser?: boolean
226
+ }
227
+
183
228
  /**
184
229
  * Simple condition expression (leaf node)
185
230
  */
@@ -223,7 +268,7 @@ export interface BranchStepMeta {
223
268
  export interface ParallelGroupStepMeta {
224
269
  type: 'parallel'
225
270
  /** Child steps to execute in parallel */
226
- children: RpcStepMeta[]
271
+ children: Array<RpcStepMeta | ScenarioStepMeta>
227
272
  }
228
273
 
229
274
  /**
@@ -244,7 +289,7 @@ export interface FanoutStepMeta {
244
289
  /** Execution mode */
245
290
  mode: 'parallel' | 'sequential'
246
291
  /** Steps to execute inline per iteration, in order */
247
- body: Array<RpcStepMeta | SleepStepMeta | SuspendStepMeta>
292
+ body: Array<RpcStepMeta | SleepStepMeta | SuspendStepMeta | ScenarioStepMeta>
248
293
  /** Time between iterations (sequential mode only) */
249
294
  timeBetween?: string
250
295
  }
@@ -409,6 +454,7 @@ export interface ArrayPredicateStepMeta {
409
454
  */
410
455
  export type WorkflowStepMeta =
411
456
  | RpcStepMeta
457
+ | ScenarioStepMeta
412
458
  | BranchStepMeta
413
459
  | ParallelGroupStepMeta
414
460
  | FanoutStepMeta
@@ -509,5 +555,43 @@ export interface PikkuScenarioWire extends PikkuWorkflowWire {
509
555
  options?: WorkflowExpectServiceOptions
510
556
  ) => Promise<void>
511
557
 
558
+ /**
559
+ * Run a registered scenario step. Shaped exactly like `do`'s RPC form —
560
+ * `(stepName, target, data, options)` — so the inspector reads the target as
561
+ * a string literal rather than resolving an imported symbol.
562
+ *
563
+ * The generated `TypedScenario` narrows these over `FlattenedScenarioStepMap`.
564
+ */
565
+ step(
566
+ stepName: string,
567
+ stepFunc: string,
568
+ data?: any,
569
+ options?: ScenarioStepOptions
570
+ ): Promise<any>
571
+
572
+ /** `step` with a "Given" prefix in the rendered prose */
573
+ given(
574
+ stepName: string,
575
+ stepFunc: string,
576
+ data?: any,
577
+ options?: ScenarioStepOptions
578
+ ): Promise<any>
579
+
580
+ /** `step` with a "When" prefix in the rendered prose */
581
+ when(
582
+ stepName: string,
583
+ stepFunc: string,
584
+ data?: any,
585
+ options?: ScenarioStepOptions
586
+ ): Promise<any>
587
+
588
+ /** `step` with a "Then" prefix in the rendered prose */
589
+ then(
590
+ stepName: string,
591
+ stepFunc: string,
592
+ data?: any,
593
+ options?: ScenarioStepOptions
594
+ ): Promise<any>
595
+
512
596
  runScheduledTask: (name: string) => Promise<unknown>
513
597
  }
@@ -0,0 +1,131 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { resolveFeatureScenarios } from './feature.js'
5
+ import type { CoreFeature, CoreWorkflow } from './workflow.types.js'
6
+
7
+ const scenario = (tags: string[] = []) =>
8
+ ({ tags, func: async () => ({}) }) as any
9
+
10
+ const registered = (entries: Array<[string, any]>): Map<string, CoreWorkflow> =>
11
+ new Map(entries.map(([name, func]) => [name, { name, func }]))
12
+
13
+ describe('resolveFeatureScenarios', () => {
14
+ test('resolves imported identifiers to the names they are registered under', () => {
15
+ const lazyLoad = scenario()
16
+ const roundTrip = scenario()
17
+ const features = new Map<string, CoreFeature>([
18
+ [
19
+ 'credentialFeature',
20
+ {
21
+ name: 'Credential API',
22
+ scenarios: [lazyLoad, roundTrip],
23
+ },
24
+ ],
25
+ ])
26
+
27
+ const { entries, unresolved } = resolveFeatureScenarios(
28
+ features,
29
+ registered([
30
+ ['credentialLazyLoadScenario', lazyLoad],
31
+ ['credentialRoundTripScenario', roundTrip],
32
+ ])
33
+ )
34
+
35
+ assert.deepEqual(unresolved, [])
36
+ assert.deepEqual(
37
+ entries.map((e) => e.scenarioName),
38
+ ['credentialLazyLoadScenario', 'credentialRoundTripScenario']
39
+ )
40
+ assert.equal(entries[0]!.featureName, 'Credential API')
41
+ assert.equal(entries[0]!.featureId, 'credentialFeature')
42
+ })
43
+
44
+ test('keeps declaration order, including repeats of the same scenario', () => {
45
+ const roundTrip = scenario()
46
+ const features = new Map<string, CoreFeature>([
47
+ [
48
+ 'credentialFeature',
49
+ {
50
+ name: 'Credential API',
51
+ scenarios: ['stripe', 'google', 'hmac-key'].map((name) => ({
52
+ scenario: roundTrip,
53
+ data: { name },
54
+ })),
55
+ },
56
+ ],
57
+ ])
58
+
59
+ const { entries } = resolveFeatureScenarios(
60
+ features,
61
+ registered([['credentialRoundTripScenario', roundTrip]])
62
+ )
63
+
64
+ assert.equal(entries.length, 3, 'a mapped loop is three separate runs')
65
+ assert.deepEqual(
66
+ entries.map((e) => e.data),
67
+ [{ name: 'stripe' }, { name: 'google' }, { name: 'hmac-key' }]
68
+ )
69
+ assert.ok(
70
+ entries.every((e) => e.scenarioName === 'credentialRoundTripScenario')
71
+ )
72
+ })
73
+
74
+ test('a bare reference carries no data', () => {
75
+ const lazyLoad = scenario()
76
+ const { entries } = resolveFeatureScenarios(
77
+ new Map([['f', { name: 'F', scenarios: [lazyLoad] } as CoreFeature]]),
78
+ registered([['lazyLoadScenario', lazyLoad]])
79
+ )
80
+ assert.equal(entries[0]!.data, undefined)
81
+ })
82
+
83
+ test("a scenario's effective tags union its own with the feature's", () => {
84
+ const lazyLoad = scenario(['scenario', 'credential'])
85
+ const { entries } = resolveFeatureScenarios(
86
+ new Map([
87
+ [
88
+ 'f',
89
+ {
90
+ name: 'F',
91
+ tags: ['nightly', 'credential'],
92
+ scenarios: [lazyLoad],
93
+ } as CoreFeature,
94
+ ],
95
+ ]),
96
+ registered([['lazyLoadScenario', lazyLoad]])
97
+ )
98
+ assert.deepEqual(entries[0]!.tags, ['scenario', 'credential', 'nightly'])
99
+ })
100
+
101
+ test('an unregistered scenario is reported, never silently matched by shape', () => {
102
+ const registeredScenario = scenario()
103
+ const lookalike = scenario()
104
+ const { entries, unresolved } = resolveFeatureScenarios(
105
+ new Map([
106
+ [
107
+ 'f',
108
+ {
109
+ name: 'F',
110
+ scenarios: [registeredScenario, lookalike],
111
+ } as CoreFeature,
112
+ ],
113
+ ]),
114
+ registered([['realScenario', registeredScenario]])
115
+ )
116
+ assert.deepEqual(
117
+ entries.map((e) => e.scenarioName),
118
+ ['realScenario']
119
+ )
120
+ assert.deepEqual(unresolved, [{ featureId: 'f', index: 1 }])
121
+ })
122
+
123
+ test('a feature with no scenarios contributes nothing', () => {
124
+ const { entries, unresolved } = resolveFeatureScenarios(
125
+ new Map([['f', { name: 'F', scenarios: [] } as CoreFeature]]),
126
+ registered([])
127
+ )
128
+ assert.deepEqual(entries, [])
129
+ assert.deepEqual(unresolved, [])
130
+ })
131
+ })
@@ -0,0 +1,78 @@
1
+ import { pikkuState } from '../../pikku-state.js'
2
+ import type {
3
+ CoreFeature,
4
+ CoreWorkflow,
5
+ FeaturePlanEntry,
6
+ } from './workflow.types.js'
7
+
8
+ /**
9
+ * Register a feature under the name it is exported as. Called from generated
10
+ * wiring, the same way `addWorkflow` is.
11
+ */
12
+ export const addFeature = (
13
+ featureId: string,
14
+ feature: CoreFeature,
15
+ packageName: string | null = null
16
+ ) => {
17
+ pikkuState(packageName, 'workflows', 'features').set(featureId, feature)
18
+ }
19
+
20
+ /**
21
+ * Resolve every feature's scenario references back to the names their
22
+ * scenarios are registered under.
23
+ *
24
+ * Matching is by **object identity**: `pikkuScenario` returns its config
25
+ * verbatim and `addWorkflow` registers that same object, so a feature holding
26
+ * the imported identifier holds the very object that was registered. Nothing
27
+ * is matched by shape, by name, or by any other guess — which is also why a
28
+ * scenario built inline inside a feature (and therefore never registered)
29
+ * comes back as unresolved rather than silently running as something else.
30
+ *
31
+ * Entries are returned in declaration order, features in registration order.
32
+ * A scenario's effective tags are its own plus the containing feature's, so
33
+ * `--tags credential` selects through the feature.
34
+ */
35
+ export const resolveFeatureScenarios = (
36
+ features: Map<string, CoreFeature>,
37
+ registrations: Map<string, CoreWorkflow>
38
+ ): {
39
+ entries: FeaturePlanEntry[]
40
+ unresolved: Array<{ featureId: string; index: number }>
41
+ } => {
42
+ const nameByConfig = new Map<unknown, string>()
43
+ for (const [name, registration] of registrations) {
44
+ nameByConfig.set(registration.func, name)
45
+ }
46
+
47
+ const entries: FeaturePlanEntry[] = []
48
+ const unresolved: Array<{ featureId: string; index: number }> = []
49
+
50
+ for (const [featureId, feature] of features) {
51
+ const scenarios = feature.scenarios ?? []
52
+ for (let index = 0; index < scenarios.length; index++) {
53
+ const entry = scenarios[index]!
54
+ const paired =
55
+ typeof entry === 'object' && entry !== null && 'scenario' in entry
56
+ const config = paired ? (entry as any).scenario : entry
57
+ const scenarioName = nameByConfig.get(config)
58
+ if (!scenarioName) {
59
+ unresolved.push({ featureId, index })
60
+ continue
61
+ }
62
+ entries.push({
63
+ featureId,
64
+ featureName: feature.name ?? featureId,
65
+ scenarioName,
66
+ data: paired ? (entry as any).data : undefined,
67
+ tags: [
68
+ ...new Set([
69
+ ...((registrations.get(scenarioName)?.func as any)?.tags ?? []),
70
+ ...(feature.tags ?? []),
71
+ ]),
72
+ ],
73
+ })
74
+ }
75
+ }
76
+
77
+ return { entries, unresolved }
78
+ }
@@ -11,6 +11,11 @@ export {
11
11
  WorkflowApprovalResolvedError,
12
12
  DEFAULT_STEP_RETRIES,
13
13
  } from './pikku-workflow-service.js'
14
+ export type {
15
+ RunLifecycleContext,
16
+ WorkflowRunEngine,
17
+ WorkflowRunExtension,
18
+ } from './pikku-workflow-service.js'
14
19
  export { deriveInvocationId, uuidv5 } from './workflow-invocation-id.js'
15
20
 
16
21
  // Time-travel: reconstruct run state at any point from durable history
@@ -29,6 +34,7 @@ export type {
29
34
 
30
35
  // Internal registration functions (used by generated code)
31
36
  export { addWorkflow } from './dsl/workflow-runner.js'
37
+ export { addFeature, resolveFeatureScenarios } from './feature.js'
32
38
 
33
39
  // Graph helpers (template, pikkuWorkflowGraph)
34
40
  export { template, type TemplateString } from './graph/template.js'
@@ -59,6 +65,7 @@ export type {
59
65
  // Re-export all types from workflow.types
60
66
  export type {
61
67
  WorkflowService,
68
+ WorkflowQueueOptions,
62
69
  WorkflowServiceConfig,
63
70
  WorkflowPlannedStep,
64
71
  WorkflowRunWire,
@@ -71,6 +78,12 @@ export type {
71
78
  WorkflowRunService,
72
79
  WorkflowRunMirror,
73
80
  CoreWorkflow,
81
+ CoreFeature,
82
+ CoreFeatureScenario,
83
+ FeatureMeta,
84
+ FeatureMetaEntry,
85
+ FeaturesMeta,
86
+ FeaturePlanEntry,
74
87
  PikkuWorkflow,
75
88
  ContextVariable,
76
89
  WorkflowContext,
@@ -112,8 +125,69 @@ export type {
112
125
  SwitchStepMeta,
113
126
  FilterStepMeta,
114
127
  ArrayPredicateStepMeta,
128
+ ScenarioStepInvocation,
129
+ ScenarioStepMeta,
115
130
  WorkflowStepMeta,
116
131
  WorkflowStepWire,
117
132
  PikkuWorkflowWire,
118
133
  PikkuScenarioWire,
119
134
  } from './workflow.types.js'
135
+
136
+ /* ------------------------------------------------------------------ *
137
+ * Scenarios — writing a step
138
+ *
139
+ * What a step file imports. Everything here is reached from inside a
140
+ * `pikkuScenarioStep` body.
141
+ * ------------------------------------------------------------------ */
142
+
143
+ // The step's own wire, and the shape of what it was given
144
+ export type {
145
+ ScenarioStepPhase,
146
+ ScenarioStepOptions,
147
+ PikkuScenarioStepWire,
148
+ ScenarioEnvironment,
149
+ } from './scenario-step.types.js'
150
+
151
+ // Narrows the optional halves of the step wire, with a message that says what to do
152
+ export { requireActor, requireScenarioEnv } from './scenario-step-guards.js'
153
+
154
+ // Waits for the target to catch up, without every step writing the loop again
155
+ export { pollUntil, type PollOptions } from './scenario-poll.js'
156
+
157
+ // Persists cookies across a step's requests, the way a browser would
158
+ export { createCookieJar } from './scenario-cookie-jar.js'
159
+ export type { ScenarioCookieJar } from './scenario-cookie-jar.js'
160
+
161
+ // What the transport answered — an actor's `invokeRaw`, or a route reached
162
+ // directly — and the one way to POST JSON at a route and keep its status
163
+ export type {
164
+ ScenarioHttpResponse,
165
+ ScenarioJsonRequest,
166
+ } from '../../services/scenario-actors-service.js'
167
+ export {
168
+ readScenarioHttpResponse,
169
+ postScenarioJson,
170
+ } from '../../services/scenario-actors-service.js'
171
+
172
+ /* ------------------------------------------------------------------ *
173
+ * Scenarios — driving a browser
174
+ *
175
+ * How a step names an element, and what a driver package
176
+ * (`@pikku/playwright`, or another) implements.
177
+ * ------------------------------------------------------------------ */
178
+
179
+ export type {
180
+ PikkuBrowserWire,
181
+ TestIdSelector,
182
+ ScenarioBrowserProvider,
183
+ ScenarioBrowserFailure,
184
+ } from './scenario-step.types.js'
185
+
186
+ /* ------------------------------------------------------------------ *
187
+ * Scenarios — reporting a run
188
+ *
189
+ * Used by the CLI reporter and the console, so the two render the same
190
+ * sentence for the same step. A step body needs none of it.
191
+ * ------------------------------------------------------------------ */
192
+
193
+ export { composeStepProse, renderStepTemplate } from './scenario-prose.js'