@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
@@ -875,3 +875,58 @@ describe('pikku-workflow-service approval', () => {
875
875
  cleanup()
876
876
  })
877
877
  })
878
+
879
+ describe('pikku-workflow-service run identity on failure', () => {
880
+ // A failing inline run throws before startWorkflow can return `{ runId }`,
881
+ // so a caller that wants to read the run back — its steps, which one failed,
882
+ // what it was called with — has nothing to read it by. `onRunCreated` fires
883
+ // as soon as the run exists, which is the only moment guaranteed to happen
884
+ // whether the run goes on to pass, fail, or suspend.
885
+ const runFailing = async (name: string) => {
886
+ resetPikkuState()
887
+ const ws = new InMemoryWorkflowService()
888
+ pikkuState(null, 'package', 'singletonServices', {
889
+ logger: { error() {}, info() {}, warn() {}, debug() {} },
890
+ } as any)
891
+
892
+ const metaState = pikkuState(null, 'workflows', 'meta')
893
+ metaState[name] = {
894
+ name,
895
+ pikkuFuncId: name,
896
+ source: 'scenario',
897
+ graphHash: `${name}-hash`,
898
+ } as any
899
+ pikkuState(null, 'function', 'meta')[name] = {
900
+ name,
901
+ sessionless: true,
902
+ permissions: [],
903
+ } as any
904
+
905
+ addWorkflow(name, {
906
+ func: async () => {
907
+ throw new Error('the scenario blew up')
908
+ },
909
+ } as any)
910
+
911
+ const created: string[] = []
912
+ let error: any
913
+ try {
914
+ await ws.startWorkflow(name, {}, {} as any, {}, {
915
+ onRunCreated: (runId: string) => created.push(runId),
916
+ } as any)
917
+ } catch (e: any) {
918
+ error = e
919
+ }
920
+ return { ws, created, error }
921
+ }
922
+
923
+ test('reports the run id of a run that fails inline', async () => {
924
+ const { ws, created, error } = await runFailing('onRunCreatedFailing')
925
+
926
+ assert.equal(error?.message, 'the scenario blew up')
927
+ assert.equal(created.length, 1, 'the run existed, so its id must be known')
928
+ const run = await ws.getRun(created[0]!)
929
+ assert.equal(run?.status, 'failed')
930
+ assert.equal(run?.error?.message, 'the scenario blew up')
931
+ })
932
+ })
@@ -11,7 +11,6 @@ import {
11
11
  pikkuState,
12
12
  } from '../../pikku-state.js'
13
13
  import { getDurationInMilliseconds } from '../../time-utils.js'
14
- import { createHttpScenarioActors } from '../../services/http-scenario-actors.js'
15
14
 
16
15
  const resolveWorkflowMeta = (
17
16
  name: string
@@ -46,10 +45,10 @@ const toKebab = (s: string) =>
46
45
  s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
47
46
  import type { PikkuWire, SerializedError } from '../../types/core.types.js'
48
47
  import type { QueueService } from '../queue/queue.types.js'
49
- import { runScheduledTask } from '../scheduler/scheduler-runner.js'
50
48
  import type {
51
49
  ApprovalOutcome,
52
- PikkuScenarioWire,
50
+ CoreWorkflow,
51
+ PikkuWorkflowWire,
53
52
  StepState,
54
53
  StepStatus,
55
54
  WorkflowApprovalOptions,
@@ -63,9 +62,6 @@ import type {
63
62
  WorkflowQueueOptions,
64
63
  WorkflowServiceConfig,
65
64
  WorkflowStepOptions,
66
- WorkflowExpectEventuallyOptions,
67
- WorkflowExpectErrorOptions,
68
- WorkflowExpectServiceOptions,
69
65
  } from './workflow.types.js'
70
66
  import {
71
67
  continueGraph,
@@ -246,6 +242,101 @@ const WORKFLOW_END_STATES: ReadonlySet<string> = new Set([
246
242
  'suspended',
247
243
  ])
248
244
 
245
+ /**
246
+ * Everything an extension needs to run something of its own around a run's
247
+ * function: the registration it was resolved from, the run itself (so `input`
248
+ * is the same object the function is called with), and the wire it is given.
249
+ */
250
+ export interface RunLifecycleContext {
251
+ runId: string
252
+ run: WorkflowRun
253
+ workflowMeta: any
254
+ workflow: CoreWorkflow
255
+ wire: PikkuWire
256
+ packageName: string | null
257
+ }
258
+
259
+ /**
260
+ * The slice of the run engine an extension is allowed to drive, handed to it at
261
+ * construction. It exists so that recording a durable step stays available to
262
+ * an extension without `inlineStep` and friends becoming public API on every
263
+ * workflow service a production app instantiates.
264
+ */
265
+ export interface WorkflowRunEngine {
266
+ inlineStep(
267
+ runId: string,
268
+ logicalStepName: string,
269
+ fn: Function,
270
+ stepOptions?: WorkflowStepOptions,
271
+ data?: any,
272
+ funcName?: string
273
+ ): Promise<any>
274
+ updateRunStatus(
275
+ runId: string,
276
+ status: WorkflowStatus,
277
+ output?: any,
278
+ error?: SerializedError
279
+ ): Promise<void>
280
+ onChildWorkflowFailed(run: WorkflowRun, error: unknown): Promise<void>
281
+ verifyStepName(stepName: unknown): void
282
+ }
283
+
284
+ /**
285
+ * A capability layered onto a run without being a workflow service itself.
286
+ *
287
+ * The engine names nothing about what an extension is for: a bundler drops an
288
+ * unused *module* but never an unused class member, so the alternative — a
289
+ * subclass carrying the capability — puts it in every server built on Pikku
290
+ * whether or not the app ever uses it. Scenarios are the one implementation
291
+ * today (`PikkuScenarioService` in `@pikku/core/scenario`).
292
+ */
293
+ export interface WorkflowRunExtension {
294
+ /** Per-run state resolved once the run has an id. */
295
+ attachRunContext(
296
+ runId: string,
297
+ workflowMeta: any,
298
+ options?: Record<string, any>
299
+ ): Promise<void>
300
+ /** Release whatever `attachRunContext` stored. */
301
+ detachRunContext(runId: string): void
302
+ /** Contribute extra members to the wire a run's function is invoked with. */
303
+ decorateRunWire(
304
+ wire: PikkuWire,
305
+ context: {
306
+ runId: string
307
+ workflowMeta: any
308
+ workflowWire: PikkuWorkflowWire
309
+ }
310
+ ): void
311
+ /** Contribute extra members to the workflow wire itself, in place. */
312
+ decorateWorkflowWire(
313
+ workflowWire: PikkuWorkflowWire,
314
+ context: {
315
+ name: string
316
+ runId: string
317
+ rpcService: any
318
+ addonNamespace?: string | null
319
+ }
320
+ ): void
321
+ /**
322
+ * Called immediately before the run's function, inside the run lock. Throwing
323
+ * skips the function and fails the run.
324
+ */
325
+ onBeforeRunFunc(context: RunLifecycleContext): Promise<void>
326
+ /**
327
+ * Called once the run reaches a terminal state, in a `finally`, so it runs
328
+ * whether the function passed or threw. `interrupted` means the run suspended
329
+ * or went async and is still mid-flight. Throwing here propagates, replacing
330
+ * a pending failure, so an extension that must not mask one handles that
331
+ * itself.
332
+ */
333
+ onAfterRunFunc(
334
+ context: RunLifecycleContext,
335
+ outcome: 'completed' | 'failed' | 'interrupted',
336
+ failure: unknown
337
+ ): Promise<void>
338
+ }
339
+
249
340
  /**
250
341
  * Abstract workflow state service
251
342
  * Implementations provide pluggable storage backends (SQLite, PostgreSQL, etc.)
@@ -253,9 +344,7 @@ const WORKFLOW_END_STATES: ReadonlySet<string> = new Set([
253
344
  */
254
345
  export abstract class PikkuWorkflowService implements WorkflowService {
255
346
  private inlineRuns = new Set<string>()
256
- // User-flow actors per run: live authenticated clients (cookie jars) are
257
- // process-local by nature, so they ride this map, never the persisted wire.
258
- private runActors = new Map<string, ScenarioActors>()
347
+ private runExtension?: WorkflowRunExtension
259
348
 
260
349
  protected get logger() {
261
350
  return getSingletonServices()?.logger
@@ -631,7 +720,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
631
720
  * Creates pending step in both workflow_step and workflow_step_history
632
721
  * @param runId - Run ID
633
722
  * @param stepName - Step cache key
634
- * @param rpcName - RPC function name
723
+ * @param rpcName - The name this step was dispatched by: an RPC for a
724
+ * `workflow.do` step, a step function for a scenario step, null for a
725
+ * closure. Nothing dispatches off this value — it is recorded so a reader
726
+ * can join a step back to the function that ran it.
635
727
  * @param data - Step input data
636
728
  * @param stepOptions - Step options (retries, retryDelay)
637
729
  * @returns Step state with generated stepId
@@ -1162,37 +1254,28 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1162
1254
  return true
1163
1255
  }
1164
1256
 
1165
- /** Build HTTP scenario actors for a run started without them; undefined when SCENARIO_ACTOR_SECRET or the API URL is missing */
1166
- private async resolveScenarioActors(): Promise<ScenarioActors | undefined> {
1167
- const services = getSingletonServices()
1168
- const variables = services?.variables
1169
- const metaService = services?.metaService
1170
- if (!variables || !metaService) {
1171
- return undefined
1172
- }
1173
- const secret = await variables.get('SCENARIO_ACTOR_SECRET')
1174
- const apiUrl = await variables.get('API_URL')
1175
- if (!secret || !apiUrl) {
1176
- services?.logger?.warn(
1177
- 'A scenario was started without actors but SCENARIO_ACTOR_SECRET / API_URL is not configured — running without actors.'
1178
- )
1179
- return undefined
1180
- }
1181
- const actorsConfig = await metaService.getScenarioActorsMeta()
1182
- if (!actorsConfig || Object.keys(actorsConfig).length === 0) {
1183
- return undefined
1257
+ /**
1258
+ * Install the one extension a run may have, built from a handle onto the run
1259
+ * engine so that `inlineStep` and friends stay protected rather than becoming
1260
+ * public API. Returns the extension, so the caller keeps a typed reference to
1261
+ * whatever it just built.
1262
+ */
1263
+ public setRunExtension<T extends WorkflowRunExtension>(
1264
+ create: (engine: WorkflowRunEngine) => T
1265
+ ): T {
1266
+ const engine: WorkflowRunEngine = {
1267
+ inlineStep: this.inlineStep.bind(this),
1268
+ updateRunStatus: this.updateRunStatus.bind(this),
1269
+ onChildWorkflowFailed: this.onChildWorkflowFailed.bind(this),
1270
+ verifyStepName: this.verifyStepName.bind(this),
1184
1271
  }
1185
- const signInPath =
1186
- (await variables.get('SCENARIO_SIGN_IN_PATH')) ??
1187
- '/api/auth/sign-in/actor'
1188
- const rpcPath = (await variables.get('SCENARIO_RPC_PATH')) ?? '/rpc'
1189
- return createHttpScenarioActors({
1190
- apiUrl,
1191
- secret,
1192
- actors: actorsConfig,
1193
- signInPath,
1194
- rpcPath,
1195
- })
1272
+ const extension = create(engine)
1273
+ this.runExtension = extension
1274
+ return extension
1275
+ }
1276
+
1277
+ public getRunExtension(): WorkflowRunExtension | undefined {
1278
+ return this.runExtension
1196
1279
  }
1197
1280
 
1198
1281
  /**
@@ -1200,13 +1283,21 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1200
1283
  * Automatically detects workflow type (DSL or graph) from meta and executes accordingly
1201
1284
  * @param options.inline - If true, execute workflow directly without queue service
1202
1285
  * @param options.startNode - Starting node ID for graph workflows (from wire config)
1286
+ * @param options.onRunCreated - Called with the run id the moment the run exists.
1287
+ * An inline run that fails throws instead of returning, so this is the only
1288
+ * way a caller can still read that run back — its steps, and which one failed.
1203
1289
  */
1204
1290
  public async startWorkflow<I>(
1205
1291
  name: string,
1206
1292
  input: I,
1207
1293
  wire: WorkflowRunWire,
1208
1294
  rpcService: any,
1209
- options?: { inline?: boolean; startNode?: string; actors?: ScenarioActors }
1295
+ options?: {
1296
+ inline?: boolean
1297
+ startNode?: string
1298
+ actors?: ScenarioActors
1299
+ onRunCreated?: (runId: string) => void
1300
+ }
1210
1301
  ): Promise<{ runId: string }> {
1211
1302
  // Resolve workflow from static meta (root or addon namespace), then dynamic DB
1212
1303
  const resolved = resolveWorkflowMeta(name)
@@ -1270,14 +1361,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1270
1361
  }
1271
1362
  )
1272
1363
 
1273
- const actors =
1274
- options?.actors ??
1275
- (workflowMeta.source === 'scenario'
1276
- ? await this.resolveScenarioActors()
1277
- : undefined)
1278
- if (actors) {
1279
- this.runActors.set(runId, actors)
1280
- }
1364
+ options?.onRunCreated?.(runId)
1365
+
1366
+ await this.runExtension?.attachRunContext(runId, workflowMeta, options)
1281
1367
 
1282
1368
  if (shouldInline) {
1283
1369
  this.inlineRuns.add(runId)
@@ -1314,7 +1400,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1314
1400
  }
1315
1401
  } finally {
1316
1402
  this.inlineRuns.delete(runId)
1317
- this.runActors.delete(runId)
1403
+ this.runExtension?.detachRunContext(runId)
1318
1404
  }
1319
1405
  } else {
1320
1406
  await this.resumeWorkflow(runId)
@@ -1485,15 +1571,33 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1485
1571
  workflowWire.pikkuUserId = run.wire?.pikkuUserId
1486
1572
  const wire: PikkuWire = {
1487
1573
  workflow: workflowWire,
1488
- scenario:
1489
- workflowMeta?.source === 'scenario' ? workflowWire : undefined,
1490
1574
  pikkuUserId: run.wire?.pikkuUserId,
1491
1575
  session: rpcService?.wire?.session,
1492
1576
  rpc: rpcService?.wire?.rpc,
1493
- // User-flow actors registered for this run (see startWorkflow options)
1494
- actors: this.runActors.get(runId),
1495
1577
  }
1578
+ this.runExtension?.decorateRunWire(wire, {
1579
+ runId,
1580
+ workflowMeta,
1581
+ workflowWire,
1582
+ })
1583
+
1584
+ const lifecycle: RunLifecycleContext = {
1585
+ runId,
1586
+ run,
1587
+ workflowMeta,
1588
+ workflow,
1589
+ wire,
1590
+ packageName: pkgName,
1591
+ }
1592
+
1593
+ // `interrupted` means the run has not reached a terminal state — it is
1594
+ // suspended or waiting — so teardown would run while the run is still
1595
+ // mid-flight.
1596
+ let outcome: 'completed' | 'failed' | 'interrupted' = 'completed'
1597
+ let failure: any
1496
1598
  try {
1599
+ await this.runExtension?.onBeforeRunFunc(lifecycle)
1600
+
1497
1601
  const result = await runPikkuFunc(
1498
1602
  'workflow',
1499
1603
  workflowMeta.name,
@@ -1510,11 +1614,15 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1510
1614
  await this.updateRunStatus(runId, 'completed', result)
1511
1615
  await this.onChildWorkflowCompleted(run, result)
1512
1616
  } catch (error: any) {
1617
+ failure = error
1618
+
1513
1619
  if (error instanceof WorkflowAsyncException) {
1620
+ outcome = 'interrupted'
1514
1621
  throw error
1515
1622
  }
1516
1623
 
1517
1624
  if (error instanceof WorkflowCancelledException) {
1625
+ outcome = 'failed'
1518
1626
  await this.updateRunStatus(runId, 'cancelled', undefined, {
1519
1627
  message: error.message || 'Workflow cancelled',
1520
1628
  stack: '',
@@ -1525,6 +1633,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1525
1633
  }
1526
1634
 
1527
1635
  if (error instanceof WorkflowSuspendedException) {
1636
+ outcome = 'interrupted'
1528
1637
  await this.updateRunStatus(runId, 'suspended', undefined, {
1529
1638
  message: error.message || 'Workflow suspended',
1530
1639
  stack: '',
@@ -1533,6 +1642,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1533
1642
  throw error
1534
1643
  }
1535
1644
 
1645
+ outcome = 'failed'
1536
1646
  await this.updateRunStatus(runId, 'failed', undefined, {
1537
1647
  message: error.message,
1538
1648
  stack: error.stack,
@@ -1541,6 +1651,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1541
1651
  await this.onChildWorkflowFailed(run, error)
1542
1652
 
1543
1653
  throw error
1654
+ } finally {
1655
+ await this.runExtension?.onAfterRunFunc(lifecycle, outcome, failure)
1544
1656
  }
1545
1657
  })
1546
1658
  }
@@ -1559,7 +1671,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1559
1671
  await this.resumeWorkflow(parentRunId)
1560
1672
  }
1561
1673
 
1562
- private async onChildWorkflowFailed(
1674
+ protected async onChildWorkflowFailed(
1563
1675
  childRun: WorkflowRun,
1564
1676
  error: Error
1565
1677
  ): Promise<void> {
@@ -2088,11 +2200,25 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2088
2200
  )
2089
2201
  }
2090
2202
 
2091
- private async inlineStep(
2203
+ protected async inlineStep(
2092
2204
  runId: string,
2093
2205
  logicalStepName: string,
2094
2206
  fn: Function,
2095
- stepOptions?: WorkflowStepOptions
2207
+ stepOptions?: WorkflowStepOptions,
2208
+ /**
2209
+ * The input this step was called with, recorded on the run so a reporter can
2210
+ * name the values under test. A closure step has none; a scenario step does.
2211
+ */
2212
+ data: any = null,
2213
+ /**
2214
+ * The name this step was dispatched by, for the kinds of inline step that
2215
+ * have one. A closure step has no name; a scenario step is a step RPC, so
2216
+ * it records the step function that ran — which is the only way to join a
2217
+ * step back to its declaration when its durable name was built at runtime
2218
+ * (a step called in a loop reaches the run as `sees @pikku/addon-todos`,
2219
+ * declared as `sees ${packageName}`).
2220
+ */
2221
+ rpcName: string | null = null
2096
2222
  ): Promise<any> {
2097
2223
  const fromStepName = this.lastStepName(runId)
2098
2224
  const stepName = this.nextStepKey(runId, logicalStepName)
@@ -2101,12 +2227,12 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2101
2227
  try {
2102
2228
  stepState = await this.getStepState(runId, stepName)
2103
2229
  } catch {
2104
- // Step doesn't exist - create it (inline, no RPC)
2230
+ // Step doesn't exist - create it (inline, so never dispatched)
2105
2231
  stepState = await this.insertStepState(
2106
2232
  runId,
2107
2233
  stepName,
2108
- null,
2109
- null,
2234
+ rpcName,
2235
+ data,
2110
2236
  stepOptions,
2111
2237
  fromStepName
2112
2238
  )
@@ -2494,8 +2620,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2494
2620
  runId: string,
2495
2621
  rpcService: any,
2496
2622
  addonNamespace?: string | null
2497
- ): PikkuScenarioWire {
2498
- const workflowWire: PikkuScenarioWire = {
2623
+ ): PikkuWorkflowWire {
2624
+ const workflowWire: PikkuWorkflowWire = {
2499
2625
  name,
2500
2626
  runId,
2501
2627
  getRun: async () => (await this.getRun(runId)) as WorkflowRun,
@@ -2531,146 +2657,6 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2531
2657
  }
2532
2658
  },
2533
2659
 
2534
- // Durable polling step: invoke an RPC (as an actor when options.as is
2535
- // set) until the predicate passes or `within` elapses. The whole poll is
2536
- // ONE recorded step, so replay returns the cached outcome.
2537
- expectEventually: async (
2538
- stepName: string,
2539
- rpcName: string,
2540
- data: any,
2541
- predicate: (output: any) => boolean,
2542
- options?: WorkflowExpectEventuallyOptions
2543
- ) => {
2544
- this.verifyStepName(stepName)
2545
- const resolvedRpcName =
2546
- addonNamespace && !rpcName.includes(':')
2547
- ? `${addonNamespace}:${rpcName}`
2548
- : rpcName
2549
- const within = getDurationInMilliseconds(options?.within ?? '30s')
2550
- const interval = getDurationInMilliseconds(options?.interval ?? '1s')
2551
- return await this.inlineStep(
2552
- runId,
2553
- stepName,
2554
- async () => {
2555
- const deadline = Date.now() + within
2556
- let last: any
2557
- while (true) {
2558
- last = options?.actor
2559
- ? await options.actor.invoke(resolvedRpcName, data)
2560
- : await rpcService.rpcWithWire(resolvedRpcName, data, {})
2561
- if (predicate(last)) return last
2562
- if (Date.now() + interval > deadline) {
2563
- throw new Error(
2564
- `[workflow] expectEventually '${stepName}' ('${resolvedRpcName}'` +
2565
- `${options?.actor ? ` as '${options.actor.name}'` : ''}) did not pass within ${within}ms; ` +
2566
- `last result: ${JSON.stringify(last)?.slice(0, 300)}`
2567
- )
2568
- }
2569
- await new Promise((resolve) => setTimeout(resolve, interval))
2570
- }
2571
- },
2572
- options
2573
- )
2574
- },
2575
-
2576
- expectError: async (
2577
- stepName: string,
2578
- rpcName: string,
2579
- data: any,
2580
- options?: WorkflowExpectErrorOptions
2581
- ) => {
2582
- this.verifyStepName(stepName)
2583
- const resolvedRpcName =
2584
- addonNamespace && !rpcName.includes(':')
2585
- ? `${addonNamespace}:${rpcName}`
2586
- : rpcName
2587
- return await this.inlineStep(
2588
- runId,
2589
- stepName,
2590
- async () => {
2591
- let result: any
2592
- try {
2593
- result = options?.actor
2594
- ? await options.actor.invoke(resolvedRpcName, data)
2595
- : await rpcService.rpcWithWire(resolvedRpcName, data, {})
2596
- } catch (e: any) {
2597
- const message = e?.message ?? String(e)
2598
- if (options?.matches) {
2599
- const matched =
2600
- typeof options.matches === 'string'
2601
- ? message.includes(options.matches)
2602
- : options.matches.test(message)
2603
- if (!matched) {
2604
- throw new Error(
2605
- `[workflow] expectError '${stepName}' ('${resolvedRpcName}') threw, but the message did not match ${options.matches}: ${message}`
2606
- )
2607
- }
2608
- }
2609
- return message
2610
- }
2611
- throw new Error(
2612
- `[workflow] expectError '${stepName}' ('${resolvedRpcName}') expected an error but the call succeeded: ${JSON.stringify(result)?.slice(0, 300)}`
2613
- )
2614
- },
2615
- options
2616
- )
2617
- },
2618
-
2619
- expectService: async (
2620
- stepName: string,
2621
- serviceMethod: string,
2622
- options?: WorkflowExpectServiceOptions
2623
- ) => {
2624
- this.verifyStepName(stepName)
2625
- const [service, method] = serviceMethod.split('.')
2626
- if (!service || !method) {
2627
- throw new Error(
2628
- `[workflow] expectService '${stepName}' needs 'service.method', got '${serviceMethod}'`
2629
- )
2630
- }
2631
- await this.inlineStep(
2632
- runId,
2633
- stepName,
2634
- async () => {
2635
- const rpcName = 'pikkuScenarioGetStubCalls'
2636
- const calls: Array<{
2637
- service: string
2638
- method: string
2639
- args: unknown[]
2640
- }> = options?.actor
2641
- ? await options.actor.invoke(rpcName, { service })
2642
- : await rpcService.rpcWithWire(rpcName, { service }, {})
2643
- const matching = (calls ?? []).filter(
2644
- (c) =>
2645
- c.service === service &&
2646
- c.method === method &&
2647
- (options?.calledWith === undefined ||
2648
- JSON.stringify(c.args?.[0]) ===
2649
- JSON.stringify(options.calledWith))
2650
- )
2651
- const expected = options?.times
2652
- const ok =
2653
- expected === undefined
2654
- ? matching.length > 0
2655
- : matching.length === expected
2656
- if (!ok) {
2657
- const seen =
2658
- (calls ?? [])
2659
- .map(
2660
- (c) =>
2661
- `${c.service}.${c.method}(${JSON.stringify(c.args?.[0])?.slice(0, 120) ?? ''})`
2662
- )
2663
- .join('\n ') || '(none)'
2664
- throw new Error(
2665
- `[workflow] expectService '${stepName}' expected ${expected ?? 'at least one'} call(s) to '${serviceMethod}'` +
2666
- `${options?.calledWith !== undefined ? ` with ${JSON.stringify(options.calledWith)}` : ''}, found ${matching.length}. Recorded:\n ${seen}`
2667
- )
2668
- }
2669
- },
2670
- options
2671
- )
2672
- },
2673
-
2674
2660
  // Implement workflow.sleep()
2675
2661
  sleep: async (stepName: string, duration: string | number) => {
2676
2662
  this.verifyStepName(stepName)
@@ -2689,16 +2675,18 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2689
2675
  approval: (async (reason: string, options: WorkflowApprovalOptions) => {
2690
2676
  this.verifyStepName(reason)
2691
2677
  return await this.approvalStep(runId, reason, options)
2692
- }) as PikkuScenarioWire['approval'],
2693
-
2694
- runScheduledTask: async (taskName: string) => {
2695
- await runScheduledTask({ name: taskName })
2696
- },
2678
+ }) as PikkuWorkflowWire['approval'],
2697
2679
  }
2680
+ this.runExtension?.decorateWorkflowWire(workflowWire, {
2681
+ name,
2682
+ runId,
2683
+ rpcService,
2684
+ addonNamespace,
2685
+ })
2698
2686
  return workflowWire
2699
2687
  }
2700
2688
 
2701
- private verifyStepName(stepName: string) {
2689
+ protected verifyStepName(stepName: string) {
2702
2690
  if (typeof stepName !== 'string') {
2703
2691
  throw new WorkflowStepNameNotString(stepName)
2704
2692
  }