@pikku/core 0.12.28 → 0.12.30

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.
@@ -485,7 +485,7 @@ describe('graph-runner bugs', () => {
485
485
  delete metaState['testInlineRpcMissing']
486
486
  })
487
487
 
488
- test('graph workflow with inline: true in meta should run inline', async () => {
488
+ test('graph workflow started with the inline flag should run inline', async () => {
489
489
  const ws = new InMemoryWorkflowService()
490
490
  let executed = false
491
491
 
@@ -511,7 +511,6 @@ describe('graph-runner bugs', () => {
511
511
  name: 'testInlineMetaGraph',
512
512
  pikkuFuncId: 'testInlineMetaGraph',
513
513
  source: 'graph',
514
- inline: true,
515
514
  entryNodeIds: ['a'],
516
515
  graphHash: 'inline-meta-graph-hash',
517
516
  nodes: {
@@ -497,8 +497,7 @@ export async function executeGraphStep(
497
497
  parentRunId: runId,
498
498
  parentStepId: stepId,
499
499
  }
500
- const shouldInline =
501
- subWorkflowMeta.inline || !getSingletonServices()?.queueService
500
+ const shouldInline = !getSingletonServices()?.queueService
502
501
  const { runId: childRunId } = await workflowService.startWorkflow(
503
502
  rpcName,
504
503
  data,
@@ -26,14 +26,13 @@ describe('pikku-workflow-service worker registration', () => {
26
26
  })
27
27
  })
28
28
 
29
- describe('pikku-workflow-service inline meta flag', () => {
30
- test('workflow with inline: true in meta should create inline run and not queue', async () => {
29
+ describe('pikku-workflow-service run-level inline', () => {
30
+ test('workflow runs inline (and queues nothing) when no queue service is configured', async () => {
31
31
  const ws = new InMemoryWorkflowService()
32
- const workflowName = 'testInlineMetaFlag'
33
- const graphHash = 'inline-meta-hash'
32
+ const workflowName = 'testRunInlineNoQueue'
33
+ const graphHash = 'run-inline-no-queue-hash'
34
34
 
35
- // Set up a queue service that tracks if anything was queued
36
- let queued = false
35
+ // No queueService configured at all the run executes inline.
37
36
  pikkuState(null, 'package', 'singletonServices', {
38
37
  logger: {
39
38
  error: () => {},
@@ -41,11 +40,6 @@ describe('pikku-workflow-service inline meta flag', () => {
41
40
  warn: () => {},
42
41
  debug: () => {},
43
42
  },
44
- queueService: {
45
- add: async () => {
46
- queued = true
47
- },
48
- },
49
43
  } as any)
50
44
 
51
45
  const metaState = pikkuState(null, 'workflows', 'meta')
@@ -54,7 +48,6 @@ describe('pikku-workflow-service inline meta flag', () => {
54
48
  pikkuFuncId: workflowName,
55
49
  source: 'dsl',
56
50
  graphHash,
57
- inline: true,
58
51
  }
59
52
 
60
53
  const functionMetaState = pikkuState(null, 'function', 'meta')
@@ -72,21 +65,16 @@ describe('pikku-workflow-service inline meta flag', () => {
72
65
 
73
66
  const { runId } = await ws.startWorkflow(workflowName, {}, {}, {})
74
67
 
75
- // The run should be created as inline
68
+ // With no queue service the run is created as inline.
76
69
  const run = await ws.getRun(runId)
77
70
  assert.equal(run?.inline, true, 'run should be marked as inline')
78
- assert.equal(
79
- queued,
80
- false,
81
- 'nothing should have been queued to the queue service'
82
- )
83
71
 
84
72
  delete metaState[workflowName]
85
73
  delete functionMetaState[workflowName]
86
74
  pikkuState(null, 'workflows', 'registrations').delete(workflowName)
87
75
  })
88
76
 
89
- test('workflow without inline flag should use queue when queueService exists', async () => {
77
+ test('workflow is dispatched to the queue when a queueService exists', async () => {
90
78
  const ws = new InMemoryWorkflowService()
91
79
  const workflowName = 'testNonInlineMetaFlag'
92
80
  const graphHash = 'non-inline-meta-hash'
@@ -135,6 +123,95 @@ describe('pikku-workflow-service inline meta flag', () => {
135
123
  })
136
124
  })
137
125
 
126
+ describe('pikku-workflow-service per-function step dispatch', () => {
127
+ // Expose the protected dispatchStep so we can assert the dispatch decision
128
+ // in isolation: a step queues only when its function opts out of inline
129
+ // execution (inline: false).
130
+ class TestWorkflowService extends InMemoryWorkflowService {
131
+ public callDispatchStep(rpcName: string) {
132
+ return this.dispatchStep('run-1', 'step-1', rpcName, {})
133
+ }
134
+ }
135
+
136
+ const setupFunction = (rpcName: string, inline?: boolean) => {
137
+ pikkuState(null, 'rpc', 'meta')[rpcName] = rpcName as any
138
+ pikkuState(null, 'function', 'meta')[rpcName] = {
139
+ pikkuFuncId: rpcName,
140
+ sessionless: true,
141
+ ...(inline === undefined ? {} : { inline }),
142
+ } as any
143
+ }
144
+
145
+ const cleanup = (rpcName: string) => {
146
+ delete pikkuState(null, 'rpc', 'meta')[rpcName]
147
+ delete pikkuState(null, 'function', 'meta')[rpcName]
148
+ }
149
+
150
+ test('default (inline) step is NOT dispatched to the queue', async () => {
151
+ const ws = new TestWorkflowService()
152
+ let queued = false
153
+ pikkuState(null, 'package', 'singletonServices', {
154
+ logger: { error() {}, info() {}, warn() {}, debug() {} },
155
+ queueService: {
156
+ add: async () => {
157
+ queued = true
158
+ },
159
+ },
160
+ } as any)
161
+
162
+ setupFunction('defaultInlineStep')
163
+ const dispatched = await ws.callDispatchStep('defaultInlineStep')
164
+ assert.equal(dispatched, false, 'default step should run inline')
165
+ assert.equal(queued, false, 'default step should not queue')
166
+ cleanup('defaultInlineStep')
167
+ })
168
+
169
+ test('inline: false step IS dispatched to the queue when a queueService exists', async () => {
170
+ const ws = new TestWorkflowService()
171
+ let queued = false
172
+ pikkuState(null, 'package', 'singletonServices', {
173
+ logger: { error() {}, info() {}, warn() {}, debug() {} },
174
+ queueService: {
175
+ add: async () => {
176
+ queued = true
177
+ },
178
+ },
179
+ } as any)
180
+
181
+ setupFunction('queuedStep', false)
182
+ const dispatched = await ws.callDispatchStep('queuedStep')
183
+ assert.equal(dispatched, true, 'inline:false step should dispatch')
184
+ assert.equal(queued, true, 'inline:false step should queue')
185
+ cleanup('queuedStep')
186
+ })
187
+
188
+ test('inline: false step warns and runs inline when no queueService is configured', async () => {
189
+ const ws = new TestWorkflowService()
190
+ let warned = false
191
+ pikkuState(null, 'package', 'singletonServices', {
192
+ logger: {
193
+ error() {},
194
+ info() {},
195
+ warn: () => {
196
+ warned = true
197
+ },
198
+ debug() {},
199
+ },
200
+ // no queueService
201
+ } as any)
202
+
203
+ setupFunction('queuedStepNoQueue', false)
204
+ const dispatched = await ws.callDispatchStep('queuedStepNoQueue')
205
+ assert.equal(
206
+ dispatched,
207
+ false,
208
+ 'falls back to inline when no queue service'
209
+ )
210
+ assert.equal(warned, true, 'should warn about the misconfiguration')
211
+ cleanup('queuedStepNoQueue')
212
+ })
213
+ })
214
+
138
215
  describe('pikku-workflow-service version mismatch fallback', () => {
139
216
  test('should fall back to stored graph for dsl workflow version mismatch', async () => {
140
217
  const ws = new InMemoryWorkflowService()
@@ -323,4 +400,76 @@ describe('pikku-workflow-service suspend', () => {
323
400
  delete functionMetaState[workflowName]
324
401
  pikkuState(null, 'workflows', 'registrations').delete(workflowName)
325
402
  })
403
+
404
+ test('should support multiple independent suspend points in one workflow', async () => {
405
+ const ws = new InMemoryWorkflowService()
406
+ pikkuState(null, 'package', 'singletonServices', {
407
+ queueService: {
408
+ add: async () => {},
409
+ },
410
+ } as any)
411
+
412
+ const workflowName = 'testMultiSuspendWorkflow'
413
+ const graphHash = 'multi-suspend-hash'
414
+ const metaState = pikkuState(null, 'workflows', 'meta')
415
+ metaState[workflowName] = {
416
+ name: workflowName,
417
+ pikkuFuncId: workflowName,
418
+ source: 'dsl',
419
+ graphHash,
420
+ }
421
+ const functionMetaState = pikkuState(null, 'function', 'meta')
422
+ functionMetaState[workflowName] = {
423
+ name: workflowName,
424
+ sessionless: true,
425
+ permissions: [],
426
+ } as any
427
+
428
+ addWorkflow(workflowName, {
429
+ func: async (
430
+ _services: any,
431
+ _data: any,
432
+ { workflow }: { workflow: PikkuWorkflowWire }
433
+ ) => {
434
+ await workflow.suspend('Building')
435
+ await workflow.suspend('Awaiting approval')
436
+ return { ok: true }
437
+ },
438
+ })
439
+
440
+ const runId = await ws.createRun(workflowName, {}, false, graphHash, {
441
+ type: 'test',
442
+ })
443
+
444
+ // First suspend: 'await_build' pauses the run.
445
+ await assert.rejects(
446
+ ws.runWorkflowJob(runId, {}),
447
+ (error: unknown) => error instanceof WorkflowSuspendedException
448
+ )
449
+ let run = await ws.getRun(runId)
450
+ assert.equal(run?.status, 'suspended')
451
+ assert.equal(run?.error?.message, 'Building')
452
+
453
+ // Resuming must hit the SECOND suspend ('awaiting_approval'), not fall
454
+ // through it — this is what the shared-step-name bug broke.
455
+ await ws.resumeWorkflow(runId)
456
+ await assert.rejects(
457
+ ws.runWorkflowJob(runId, {}),
458
+ (error: unknown) => error instanceof WorkflowSuspendedException
459
+ )
460
+ run = await ws.getRun(runId)
461
+ assert.equal(run?.status, 'suspended')
462
+ assert.equal(run?.error?.message, 'Awaiting approval')
463
+
464
+ // Resuming again completes the workflow.
465
+ await ws.resumeWorkflow(runId)
466
+ await ws.runWorkflowJob(runId, {})
467
+ run = await ws.getRun(runId)
468
+ assert.equal(run?.status, 'completed')
469
+ assert.deepEqual(run?.output, { ok: true })
470
+
471
+ delete metaState[workflowName]
472
+ delete functionMetaState[workflowName]
473
+ pikkuState(null, 'workflows', 'registrations').delete(workflowName)
474
+ })
326
475
  })
@@ -879,16 +879,28 @@ export abstract class PikkuWorkflowService implements WorkflowService {
879
879
  data: unknown,
880
880
  stepOptions?: WorkflowStepOptions
881
881
  ): Promise<boolean> {
882
- if (!getSingletonServices()?.queueService) {
883
- return false
884
- }
885
- // Functions default to inline execution. Only dispatch via queue when the
886
- // function explicitly sets inline: false.
882
+ // Step execution is decided purely by the function's `inline` flag (default
883
+ // true). Only a function explicitly marked `inline: false` dispatches via
884
+ // the queue. The run-level inline state is intentionally NOT consulted
885
+ // here: default steps run inline even inside a queue-dispatched run, so a
886
+ // normally-started workflow executes its steps in one orchestrator-worker
887
+ // pass instead of one queue round-trip per step.
887
888
  const functionsMeta = pikkuState(null, 'function', 'meta')
888
889
  const rpcFuncId = pikkuState(null, 'rpc', 'meta')[rpcName]
889
- const rpcMeta = typeof rpcFuncId === 'string' ? functionsMeta[rpcFuncId] : undefined
890
+ const rpcMeta =
891
+ typeof rpcFuncId === 'string' ? functionsMeta[rpcFuncId] : undefined
890
892
  const forceQueue = rpcMeta?.inline === false
891
- if (!forceQueue && this.isInline(runId)) {
893
+ if (!forceQueue) {
894
+ return false
895
+ }
896
+ // The function opted out of inline execution (`inline: false`) but no queue
897
+ // service is configured to dispatch it. Fall back to inline so the workflow
898
+ // still progresses, but warn loudly — silently swallowing this hides a real
899
+ // misconfiguration (the step won't get its own worker/retry isolation).
900
+ if (!getSingletonServices()?.queueService) {
901
+ getSingletonServices()?.logger.warn(
902
+ `Workflow step '${stepName}' (function '${rpcName}') is marked 'inline: false' but no queue service is configured — running it inline instead of dispatching to a queue.`
903
+ )
892
904
  return false
893
905
  }
894
906
  const retries = stepOptions?.retries ?? 0
@@ -977,9 +989,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
977
989
  workflowMeta.source === 'dynamic-workflow'
978
990
  ) {
979
991
  const shouldInline =
980
- options?.inline ||
981
- workflowMeta.inline ||
982
- !getSingletonServices()?.queueService
992
+ options?.inline || !getSingletonServices()?.queueService
983
993
  return runWorkflowGraph(
984
994
  this,
985
995
  name,
@@ -1005,9 +1015,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1005
1015
  }
1006
1016
 
1007
1017
  const shouldInline =
1008
- options?.inline ||
1009
- workflowMeta.inline ||
1010
- !getSingletonServices()?.queueService
1018
+ options?.inline || !getSingletonServices()?.queueService
1011
1019
 
1012
1020
  const runId = await this.createRun(
1013
1021
  name,
@@ -1351,8 +1359,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1351
1359
  parentRunId: runId,
1352
1360
  parentStepId: stepState.stepId,
1353
1361
  }
1354
- const shouldInline =
1355
- subWorkflowMeta.inline || !getSingletonServices()?.queueService
1362
+ const shouldInline = !getSingletonServices()?.queueService
1356
1363
  const { runId: childRunId } = await this.startWorkflow(
1357
1364
  rpcName,
1358
1365
  data,
@@ -1762,23 +1769,31 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1762
1769
  await this.setStepResult(stepState.stepId, null)
1763
1770
  }
1764
1771
 
1772
+ /**
1773
+ * Derive the durable step name for a suspend point from its `reason`, so each
1774
+ * distinct reason is its own step row — letting one workflow have multiple
1775
+ * independent suspends (e.g. wait-for-build, then wait-for-approval), and
1776
+ * supporting dynamic reasons in loops (`suspend(`Wait for ${i}`)`) exactly
1777
+ * like dynamic `do()` step names. The reason is used raw (it's just a text
1778
+ * step name), only namespaced so it can't collide with a `do`/`sleep` step of
1779
+ * the same name. Like `do()` / `sleep()`, the reason is the step's stable
1780
+ * identity: it MUST be derived deterministically so it's the same every time
1781
+ * the workflow replays through that point.
1782
+ */
1765
1783
  private getSuspendStepName(reason: string): string {
1766
- if (!reason) {
1767
- return '__workflow_suspend'
1768
- }
1769
- return '__workflow_suspend'
1784
+ return `__workflow_suspend:${reason}`
1770
1785
  }
1771
1786
 
1772
1787
  private async suspendStep(runId: string, reason: string): Promise<void> {
1773
- const stepName = this.getSuspendStepName(reason)
1774
- await this.withStepLock(runId, stepName, async () => {
1788
+ const suspendStepName = this.getSuspendStepName(reason)
1789
+ await this.withStepLock(runId, suspendStepName, async () => {
1775
1790
  let stepState: StepState
1776
1791
  try {
1777
- stepState = await this.getStepState(runId, stepName)
1792
+ stepState = await this.getStepState(runId, suspendStepName)
1778
1793
  } catch {
1779
1794
  stepState = await this.insertStepState(
1780
1795
  runId,
1781
- stepName,
1796
+ suspendStepName,
1782
1797
  'pikkuWorkflowSuspend',
1783
1798
  {
1784
1799
  reason,
@@ -1788,7 +1803,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1788
1803
  if (!stepState.stepId) {
1789
1804
  stepState = await this.insertStepState(
1790
1805
  runId,
1791
- stepName,
1806
+ suspendStepName,
1792
1807
  'pikkuWorkflowSuspend',
1793
1808
  {
1794
1809
  reason,
@@ -1865,7 +1880,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1865
1880
  },
1866
1881
 
1867
1882
  suspend: async (reason: string) => {
1868
- await this.suspendStep(runId, reason || 'Workflow suspended')
1883
+ this.verifyStepName(reason)
1884
+ await this.suspendStep(runId, reason)
1869
1885
  },
1870
1886
  }
1871
1887
  return workflowWire
@@ -315,7 +315,6 @@ export type WorkflowsMeta = Record<
315
315
  steps: WorkflowStepMeta[]
316
316
  context?: WorkflowContext
317
317
  dsl?: boolean
318
- inline?: boolean
319
318
  expose?: boolean
320
319
  }
321
320
  >
@@ -336,8 +335,6 @@ export interface WorkflowRuntimeMeta {
336
335
  description?: string
337
336
  /** Tags for organization */
338
337
  tags?: string[]
339
- /** If true, workflow always executes inline without queues */
340
- inline?: boolean
341
338
  /** Serialized nodes */
342
339
  nodes?: Record<string, any>
343
340
  /** Entry node IDs for graph workflows (computed at build time) */