@pikku/core 0.12.37 → 0.12.39

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 (52) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/dist/errors/error-handler.d.ts +8 -0
  3. package/dist/errors/error-handler.js +8 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +1 -0
  6. package/dist/services/in-memory-queue-service.d.ts +9 -0
  7. package/dist/services/in-memory-queue-service.js +42 -11
  8. package/dist/services/in-memory-workflow-service.d.ts +7 -2
  9. package/dist/services/in-memory-workflow-service.js +19 -1
  10. package/dist/services/workflow-service.d.ts +3 -0
  11. package/dist/testing/service-tests.js +35 -0
  12. package/dist/types/core.types.d.ts +1 -0
  13. package/dist/wirings/cli/cli-runner.js +12 -3
  14. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +19 -1
  15. package/dist/wirings/workflow/graph/graph-runner.js +168 -106
  16. package/dist/wirings/workflow/index.d.ts +4 -1
  17. package/dist/wirings/workflow/index.js +4 -1
  18. package/dist/wirings/workflow/pikku-workflow-service.d.ts +95 -5
  19. package/dist/wirings/workflow/pikku-workflow-service.js +323 -175
  20. package/dist/wirings/workflow/run-timeline.d.ts +85 -0
  21. package/dist/wirings/workflow/run-timeline.js +153 -0
  22. package/dist/wirings/workflow/workflow-invocation-id.d.ts +20 -0
  23. package/dist/wirings/workflow/workflow-invocation-id.js +38 -0
  24. package/dist/wirings/workflow/workflow-queue-workers.d.ts +2 -0
  25. package/dist/wirings/workflow/workflow.types.d.ts +7 -0
  26. package/package.json +2 -1
  27. package/src/dev/hot-reload.test.ts +5 -0
  28. package/src/errors/error-handler.ts +10 -0
  29. package/src/index.ts +1 -0
  30. package/src/services/in-memory-queue-service.test.ts +97 -0
  31. package/src/services/in-memory-queue-service.ts +51 -16
  32. package/src/services/in-memory-workflow-service.ts +27 -1
  33. package/src/services/workflow-service.ts +9 -0
  34. package/src/testing/service-tests.ts +65 -0
  35. package/src/types/core.types.ts +4 -0
  36. package/src/wirings/cli/cli-runner.ts +11 -3
  37. package/src/wirings/oauth2/oauth2-client.test.ts +25 -23
  38. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +19 -1
  39. package/src/wirings/workflow/graph/graph-runner.test.ts +159 -3
  40. package/src/wirings/workflow/graph/graph-runner.ts +253 -142
  41. package/src/wirings/workflow/index.ts +17 -0
  42. package/src/wirings/workflow/pikku-workflow-service.ts +447 -213
  43. package/src/wirings/workflow/run-timeline.test.ts +211 -0
  44. package/src/wirings/workflow/run-timeline.ts +241 -0
  45. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +117 -0
  46. package/src/wirings/workflow/workflow-invocation-id.test.ts +53 -0
  47. package/src/wirings/workflow/workflow-invocation-id.ts +48 -0
  48. package/src/wirings/workflow/workflow-queue-workers.ts +2 -0
  49. package/src/wirings/workflow/workflow-retry-policy.test.ts +111 -0
  50. package/src/wirings/workflow/workflow-step-ordinal.test.ts +79 -0
  51. package/src/wirings/workflow/workflow.types.ts +7 -0
  52. package/tsconfig.tsbuildinfo +1 -1
@@ -320,6 +320,71 @@ export function defineServiceTests(config: ServiceTestConfig): void {
320
320
  assert.equal(retried.result, undefined)
321
321
  })
322
322
 
323
+ test('getRunStatus reports per-step duration and attempts', async () => {
324
+ const runId = await service.createRun(
325
+ 'status-timing-workflow',
326
+ {},
327
+ false,
328
+ 'hash-timing',
329
+ wire
330
+ )
331
+ const step = await service.insertStepState(
332
+ runId,
333
+ 'timed-step',
334
+ 'myRpc',
335
+ {}
336
+ )
337
+ await service.setStepRunning(step.stepId)
338
+ await service.setStepResult(step.stepId, { ok: true })
339
+
340
+ const status = await service.getRunStatus(runId)
341
+ assert.ok(status)
342
+ const timed = status.steps.find((s) => s.name === 'timed-step')
343
+ assert.ok(timed, 'step appears in run status')
344
+ // running→succeeded must stamp runningAt/succeededAt so a duration is
345
+ // computable. Regression guard: the kysely store previously updated
346
+ // only the status on transitions, leaving the timestamps null and
347
+ // duration permanently undefined.
348
+ assert.ok(
349
+ timed.duration !== undefined,
350
+ 'duration is computed from stamped transition timestamps'
351
+ )
352
+ assert.ok(timed.duration >= 0, 'duration is non-negative')
353
+ assert.equal(timed.attempts, 1, 'single attempt')
354
+ })
355
+
356
+ test('getRunStatus surfaces retried attempt count', async () => {
357
+ const runId = await service.createRun(
358
+ 'status-retry-workflow',
359
+ {},
360
+ false,
361
+ 'hash-retry-status',
362
+ wire
363
+ )
364
+ const step = await service.insertStepState(
365
+ runId,
366
+ 'flaky-step',
367
+ 'myRpc',
368
+ {},
369
+ { retries: 2 }
370
+ )
371
+ await service.setStepRunning(step.stepId)
372
+ await service.setStepError(step.stepId, new Error('first try'))
373
+ // Guarantee the retry's history row sorts after the failed attempt so
374
+ // getRunStatus picks the latest attempt (it tie-breaks on timestamps).
375
+ await new Promise((resolve) => setTimeout(resolve, 5))
376
+ const retry = await service.createRetryAttempt(step.stepId, 'pending')
377
+ await service.setStepRunning(retry.stepId)
378
+ await service.setStepResult(retry.stepId, { ok: true })
379
+
380
+ const status = await service.getRunStatus(runId)
381
+ assert.ok(status)
382
+ const flaky = status.steps.find((s) => s.name === 'flaky-step')
383
+ assert.ok(flaky, 'retried step collapses to a single status entry')
384
+ assert.equal(flaky.status, 'succeeded', 'latest attempt wins')
385
+ assert.equal(flaky.attempts, 2, 'attempt count surfaces in status')
386
+ })
387
+
323
388
  test('getNodesWithoutSteps', async () => {
324
389
  const runId = await service.createRun(
325
390
  'graph-workflow',
@@ -558,5 +558,9 @@ export interface SerializedError {
558
558
  message: string
559
559
  stack?: string
560
560
  code?: string
561
+ // True when the original error was a deliberate, expected failure (a
562
+ // PikkuError). Survives the step-boundary rehydration so the workflow runner
563
+ // can log the message alone instead of a stack trace. See isExpectedError.
564
+ expected?: boolean
561
565
  [key: string]: any
562
566
  }
@@ -1,4 +1,5 @@
1
1
  import { NotFoundError } from '../../errors/errors.js'
2
+ import { isExpectedError } from '../../errors/error-handler.js'
2
3
  import { addFunction, runPikkuFunc } from '../../function/function-runner.js'
3
4
  import { pikkuState } from '../../pikku-state.js'
4
5
  import type {
@@ -543,10 +544,17 @@ export async function executeCLI({
543
544
  throw error
544
545
  }
545
546
 
546
- // Wrap other errors in CLIError
547
- console.error('Error:', error)
547
+ // An expected failure (a deliberate PikkuError, e.g. a build gate
548
+ // tripping) — its message says everything, so print that alone (no
549
+ // `Error:` prefix). Anything else is an uncaught error: log the object so
550
+ // its stack shows.
551
+ if (isExpectedError(error)) {
552
+ console.error(error.message)
553
+ } else {
554
+ console.error('Error:', error)
555
+ }
548
556
 
549
- // Show stack trace in verbose mode
557
+ // Show stack trace in verbose mode (even for expected errors).
550
558
  if (args.includes('--verbose') || args.includes('-v')) {
551
559
  console.error('Stack trace:', error.stack)
552
560
  }
@@ -1,4 +1,6 @@
1
- import { describe, test, afterEach, mock } from 'node:test'
1
+ import { describe, test, afterEach } from 'node:test'
2
+
3
+ const mockFn = <T extends (...args: any[]) => any>(fn: T): T => fn
2
4
  import * as assert from 'node:assert/strict'
3
5
  import { OAuth2Client } from './oauth2-client.js'
4
6
  import type { OAuth2Token, OAuth2AppCredential } from './oauth2.types.js'
@@ -94,7 +96,7 @@ describe('OAuth2Client', () => {
94
96
  APP_CREDS: defaultAppCredential,
95
97
  })
96
98
 
97
- globalThis.fetch = mock.fn(async () =>
99
+ globalThis.fetch = mockFn(async () =>
98
100
  mockFetchResponse({
99
101
  access_token: 'new-access-token',
100
102
  refresh_token: 'new-refresh-token',
@@ -141,7 +143,7 @@ describe('OAuth2Client', () => {
141
143
  APP_CREDS: defaultAppCredential,
142
144
  })
143
145
 
144
- globalThis.fetch = mock.fn(async () =>
146
+ globalThis.fetch = mockFn(async () =>
145
147
  mockFetchResponse({
146
148
  access_token: 'refreshed-token',
147
149
  expires_in: 3600,
@@ -183,7 +185,7 @@ describe('OAuth2Client', () => {
183
185
  APP_CREDS: defaultAppCredential,
184
186
  })
185
187
 
186
- globalThis.fetch = mock.fn(async () =>
188
+ globalThis.fetch = mockFn(async () =>
187
189
  mockFetchResponse({
188
190
  access_token: 'refreshed-due-to-buffer',
189
191
  expires_in: 3600,
@@ -215,7 +217,7 @@ describe('OAuth2Client', () => {
215
217
 
216
218
  let capturedRequest: { url: string; options: RequestInit } | null = null
217
219
 
218
- globalThis.fetch = mock.fn(
220
+ globalThis.fetch = mockFn(
219
221
  async (url: RequestInfo | URL, options?: RequestInit) => {
220
222
  capturedRequest = { url: url.toString(), options: options! }
221
223
  return mockFetchResponse({
@@ -259,7 +261,7 @@ describe('OAuth2Client', () => {
259
261
  })
260
262
 
261
263
  let fetchCallCount = 0
262
- globalThis.fetch = mock.fn(async () => {
264
+ globalThis.fetch = mockFn(async () => {
263
265
  fetchCallCount++
264
266
  return mockFetchResponse({
265
267
  access_token: 'new-cached-token',
@@ -293,7 +295,7 @@ describe('OAuth2Client', () => {
293
295
  APP_CREDS: defaultAppCredential,
294
296
  })
295
297
 
296
- globalThis.fetch = mock.fn(async () =>
298
+ globalThis.fetch = mockFn(async () =>
297
299
  mockFetchResponse({ error: 'invalid_grant' }, 400, false)
298
300
  )
299
301
 
@@ -339,7 +341,7 @@ describe('OAuth2Client', () => {
339
341
  APP_CREDS: defaultAppCredential,
340
342
  })
341
343
 
342
- globalThis.fetch = mock.fn(async () =>
344
+ globalThis.fetch = mockFn(async () =>
343
345
  mockFetchResponse({
344
346
  access_token: 'new-persisted-token',
345
347
  refresh_token: 'new-refresh-token',
@@ -374,7 +376,7 @@ describe('OAuth2Client', () => {
374
376
  })
375
377
 
376
378
  let fetchCallCount = 0
377
- globalThis.fetch = mock.fn(async () => {
379
+ globalThis.fetch = mockFn(async () => {
378
380
  fetchCallCount++
379
381
  // Simulate network delay
380
382
  await new Promise((resolve) => setTimeout(resolve, 50))
@@ -417,7 +419,7 @@ describe('OAuth2Client', () => {
417
419
  })
418
420
 
419
421
  // Response doesn't include refresh_token
420
- globalThis.fetch = mock.fn(async () =>
422
+ globalThis.fetch = mockFn(async () =>
421
423
  mockFetchResponse({
422
424
  access_token: 'new-token',
423
425
  expires_in: 3600,
@@ -453,7 +455,7 @@ describe('OAuth2Client', () => {
453
455
 
454
456
  let capturedHeaders: Record<string, string> | null = null
455
457
 
456
- globalThis.fetch = mock.fn(
458
+ globalThis.fetch = mockFn(
457
459
  async (_url: RequestInfo | URL, options?: RequestInit) => {
458
460
  capturedHeaders = options?.headers as Record<string, string>
459
461
  return mockFetchResponse({ data: 'test' })
@@ -482,7 +484,7 @@ describe('OAuth2Client', () => {
482
484
  })
483
485
 
484
486
  let requestCount = 0
485
- globalThis.fetch = mock.fn(
487
+ globalThis.fetch = mockFn(
486
488
  async (url: RequestInfo | URL, options?: RequestInit) => {
487
489
  requestCount++
488
490
  const urlStr = url.toString()
@@ -529,7 +531,7 @@ describe('OAuth2Client', () => {
529
531
  })
530
532
 
531
533
  let apiRequestCount = 0
532
- globalThis.fetch = mock.fn(async (url: RequestInfo | URL) => {
534
+ globalThis.fetch = mockFn(async (url: RequestInfo | URL) => {
533
535
  const urlStr = url.toString()
534
536
 
535
537
  if (urlStr === 'https://example.com/oauth/token') {
@@ -565,7 +567,7 @@ describe('OAuth2Client', () => {
565
567
  APP_CREDS: defaultAppCredential,
566
568
  })
567
569
 
568
- globalThis.fetch = mock.fn(async () =>
570
+ globalThis.fetch = mockFn(async () =>
569
571
  mockFetchResponse({ error: 'forbidden' }, 403, false)
570
572
  )
571
573
 
@@ -589,7 +591,7 @@ describe('OAuth2Client', () => {
589
591
 
590
592
  let capturedHeaders: Record<string, string> | null = null
591
593
 
592
- globalThis.fetch = mock.fn(
594
+ globalThis.fetch = mockFn(
593
595
  async (_url: RequestInfo | URL, options?: RequestInit) => {
594
596
  capturedHeaders = options?.headers as Record<string, string>
595
597
  return mockFetchResponse({ data: 'test' })
@@ -704,7 +706,7 @@ describe('OAuth2Client', () => {
704
706
 
705
707
  let capturedRequest: { url: string; options: RequestInit } | null = null
706
708
 
707
- globalThis.fetch = mock.fn(
709
+ globalThis.fetch = mockFn(
708
710
  async (url: RequestInfo | URL, options?: RequestInit) => {
709
711
  capturedRequest = { url: url.toString(), options: options! }
710
712
  return mockFetchResponse({
@@ -738,7 +740,7 @@ describe('OAuth2Client', () => {
738
740
  })
739
741
 
740
742
  let fetchCallCount = 0
741
- globalThis.fetch = mock.fn(async () => {
743
+ globalThis.fetch = mockFn(async () => {
742
744
  fetchCallCount++
743
745
  return mockFetchResponse({
744
746
  access_token: 'new-token',
@@ -767,7 +769,7 @@ describe('OAuth2Client', () => {
767
769
  APP_CREDS: defaultAppCredential,
768
770
  })
769
771
 
770
- globalThis.fetch = mock.fn(async () =>
772
+ globalThis.fetch = mockFn(async () =>
771
773
  mockFetchResponse({ error: 'invalid_code' }, 400, false)
772
774
  )
773
775
 
@@ -785,7 +787,7 @@ describe('OAuth2Client', () => {
785
787
  APP_CREDS: defaultAppCredential,
786
788
  })
787
789
 
788
- globalThis.fetch = mock.fn(async () =>
790
+ globalThis.fetch = mockFn(async () =>
789
791
  mockFetchResponse({
790
792
  access_token: 'token-only',
791
793
  // No refresh_token, expires_in, or scope
@@ -813,7 +815,7 @@ describe('OAuth2Client', () => {
813
815
  })
814
816
 
815
817
  // Response without access_token
816
- globalThis.fetch = mock.fn(async () =>
818
+ globalThis.fetch = mockFn(async () =>
817
819
  mockFetchResponse({
818
820
  refresh_token: 'refresh-only',
819
821
  expires_in: 3600,
@@ -835,7 +837,7 @@ describe('OAuth2Client', () => {
835
837
  })
836
838
 
837
839
  // Response with non-string access_token
838
- globalThis.fetch = mock.fn(async () =>
840
+ globalThis.fetch = mockFn(async () =>
839
841
  mockFetchResponse({
840
842
  access_token: 12345, // Number instead of string
841
843
  expires_in: 3600,
@@ -865,7 +867,7 @@ describe('OAuth2Client', () => {
865
867
  })
866
868
 
867
869
  // Error response with sensitive info
868
- globalThis.fetch = mock.fn(async () =>
870
+ globalThis.fetch = mockFn(async () =>
869
871
  mockFetchResponse(
870
872
  {
871
873
  error: 'invalid_grant',
@@ -910,7 +912,7 @@ describe('OAuth2Client', () => {
910
912
  // Instead, we verify that the signal is being passed
911
913
  // by checking that an abort signal is present in the fetch call
912
914
  let signalPassed = false
913
- globalThis.fetch = mock.fn(
915
+ globalThis.fetch = mockFn(
914
916
  async (_url: RequestInfo | URL, options?: RequestInit) => {
915
917
  signalPassed = options?.signal instanceof AbortSignal
916
918
  return mockFetchResponse({
@@ -295,10 +295,28 @@ export type WorkflowStepMeta =
295
295
  export interface WorkflowStepWire {
296
296
  /** The workflow run ID */
297
297
  runId: string
298
- /** The unique step ID */
298
+ /**
299
+ * The step row ID. Whether it stays the same or is minted fresh per attempt is
300
+ * STORE-SPECIFIC (in-memory mints a new one each attempt; the SQL store reuses
301
+ * the row) — do NOT use it as a dedupe key. Use `invocationId`.
302
+ */
299
303
  stepId: string
304
+ /**
305
+ * Stable identity of this step invocation — the idempotency / dedupe key.
306
+ * Identical across every retry of the same call (derived from runId + step
307
+ * name) regardless of storage backend, so a step can `ON CONFLICT (invocationId)`
308
+ * or pass it as an external idempotency key and have retries collapse onto the
309
+ * first attempt.
310
+ */
311
+ invocationId: string
300
312
  /** Current attempt number (1-indexed, increments on retry) */
301
313
  attemptCount: number
314
+ /**
315
+ * Invocation ID of the predecessor step this one was reached from (the walked
316
+ * transition/edge). Undefined for entry steps. Lets a step know its origin —
317
+ * e.g. in a cyclic graph `a → b → a → c`, the second `a` carries `b`'s id.
318
+ */
319
+ fromInvocationId?: string
302
320
  }
303
321
 
304
322
  /**
@@ -10,6 +10,7 @@ import {
10
10
  import type { WorkflowRuntimeMeta } from '../workflow.types.js'
11
11
  import { pikkuState } from '../../../pikku-state.js'
12
12
  import { RPCNotFoundError } from '../../rpc/rpc-runner.js'
13
+ import { DEFAULT_STEP_RETRIES } from '../pikku-workflow-service.js'
13
14
 
14
15
  describe('graph-runner bugs', () => {
15
16
  test('continueGraph should NOT mark workflow completed while nodes are still running', async () => {
@@ -240,6 +241,84 @@ describe('graph-runner bugs', () => {
240
241
  assert.deepEqual(queuedNodes, ['c'])
241
242
  })
242
243
 
244
+ test('continueGraph revisits a cyclic node and records the walked path via fromStepName', async () => {
245
+ const ws = new InMemoryWorkflowService()
246
+ const queued: Array<{ stepName: string; fromStepName?: string }> = []
247
+
248
+ pikkuState(null, 'package', 'singletonServices', {
249
+ queueService: {
250
+ add: async (_queueName: string, data: any) => {
251
+ if (data?.stepName) {
252
+ queued.push({
253
+ stepName: data.stepName,
254
+ fromStepName: data.fromStepName,
255
+ })
256
+ }
257
+ },
258
+ },
259
+ } as any)
260
+
261
+ // start → a, where a loops back through b once, then exits to c:
262
+ // start → a --retry--> b → a --done--> c
263
+ const meta: WorkflowRuntimeMeta = {
264
+ name: 'testCyclicGraph',
265
+ pikkuFuncId: 'testCyclicGraph',
266
+ source: 'graph',
267
+ entryNodeIds: ['start'],
268
+ graphHash: 'cyclic-hash',
269
+ nodes: {
270
+ start: { nodeId: 'start', rpcName: 'doStart', next: 'a' },
271
+ a: { nodeId: 'a', rpcName: 'doA', next: { retry: 'b', done: 'c' } },
272
+ b: { nodeId: 'b', rpcName: 'doB', next: 'a' },
273
+ c: { nodeId: 'c', rpcName: 'doC' },
274
+ },
275
+ }
276
+
277
+ const runId = await ws.createRun('testCyclicGraph', {}, false, 'cyclic-hash', {
278
+ type: 'test',
279
+ })
280
+
281
+ // Succeed a queued step (taking an optional branch), then advance the graph.
282
+ const advance = async (stepName: string, branch?: string) => {
283
+ const step = await ws.getStepState(runId, stepName)
284
+ await ws.setStepRunning(step.stepId)
285
+ if (branch) await ws.setBranchTaken(step.stepId, branch)
286
+ await ws.setStepResult(step.stepId, { ok: true })
287
+ await continueGraph(ws, runId, 'testCyclicGraph', meta)
288
+ }
289
+
290
+ await continueGraph(ws, runId, 'testCyclicGraph', meta) // fire entry
291
+ await advance('start')
292
+ await advance('a', 'retry') // a → b (b reaches a, but first visit ⇒ bare 'b')
293
+ await advance('b') // b → a revisit ⇒ a#1
294
+ await advance('a#1', 'done') // a#1 → c (forward edge, terminal)
295
+ await advance('c')
296
+
297
+ // Each step records the predecessor it was reached from; the cyclic
298
+ // revisit is a fresh ordinal instance (a#1) with its own provenance.
299
+ assert.deepEqual(queued, [
300
+ { stepName: 'start', fromStepName: undefined },
301
+ { stepName: 'a', fromStepName: 'start' },
302
+ { stepName: 'b', fromStepName: 'a' },
303
+ { stepName: 'a#1', fromStepName: 'b' },
304
+ { stepName: 'c', fromStepName: 'a#1' },
305
+ ])
306
+
307
+ const run = await ws.getRun(runId)
308
+ assert.equal(run?.status, 'completed')
309
+
310
+ // Reconstruct the walked path purely from the fromStepName chain.
311
+ const instances = await ws.getStepInstances(runId)
312
+ const from = new Map(instances.map((i) => [i.stepName, i.fromStepName]))
313
+ const path: string[] = []
314
+ let cursor: string | undefined = 'c'
315
+ while (cursor) {
316
+ path.unshift(cursor)
317
+ cursor = from.get(cursor) ?? undefined
318
+ }
319
+ assert.deepEqual(path, ['start', 'a', 'b', 'a#1', 'c'])
320
+ })
321
+
243
322
  test('inline graph should execute converging next node only once', async () => {
244
323
  const ws = new InMemoryWorkflowService()
245
324
  let cCalls = 0
@@ -337,6 +416,78 @@ describe('graph-runner bugs', () => {
337
416
  delete metaState['testInlineFailure']
338
417
  })
339
418
 
419
+ test('inline graph revisits a cyclic node and records the walked path via fromStepName', async () => {
420
+ const ws = new InMemoryWorkflowService()
421
+
422
+ // attempt loops back to itself via `again` until it converges to `done`.
423
+ const mockRpcService = {
424
+ rpcWithWire: async (rpcName: string, _data: any, wire: any) => {
425
+ if (rpcName === 'cyclicBegin') return { attempts: 3 }
426
+ if (rpcName === 'cyclicAttempt') {
427
+ const state = await wire.graph.getState()
428
+ const count = ((state.count as number) ?? 0) + 1
429
+ await wire.graph.setState('count', count)
430
+ wire.graph.branch(count >= 3 ? 'done' : 'again')
431
+ return { count }
432
+ }
433
+ if (rpcName === 'cyclicFinish') return { ok: true }
434
+ return {}
435
+ },
436
+ }
437
+
438
+ const metaState = pikkuState(null, 'workflows', 'meta')
439
+ metaState['testInlineCyclic'] = {
440
+ name: 'testInlineCyclic',
441
+ pikkuFuncId: 'testInlineCyclic',
442
+ source: 'graph',
443
+ entryNodeIds: ['begin'],
444
+ graphHash: 'inline-cyclic-hash',
445
+ nodes: {
446
+ begin: { nodeId: 'begin', rpcName: 'cyclicBegin', next: 'attempt' },
447
+ attempt: {
448
+ nodeId: 'attempt',
449
+ rpcName: 'cyclicAttempt',
450
+ next: { again: 'attempt', done: 'finish' },
451
+ },
452
+ finish: { nodeId: 'finish', rpcName: 'cyclicFinish' },
453
+ },
454
+ }
455
+
456
+ const { runId } = await runWorkflowGraph(
457
+ ws,
458
+ 'testInlineCyclic',
459
+ {},
460
+ mockRpcService,
461
+ true
462
+ )
463
+
464
+ const run = await ws.getRun(runId)
465
+ assert.equal(run?.status, 'completed')
466
+
467
+ // The self-cycle produced three ordinal instances of `attempt`.
468
+ const instances = await ws.getStepInstances(runId)
469
+ const names = instances.map((i) => i.stepName).sort()
470
+ assert.ok(
471
+ names.includes('attempt') &&
472
+ names.includes('attempt#1') &&
473
+ names.includes('attempt#2'),
474
+ `expected attempt/attempt#1/attempt#2, got ${JSON.stringify(names)}`
475
+ )
476
+
477
+ // Walk the fromStepName chain back from the terminal node — inline now
478
+ // records provenance identical to the queued path.
479
+ const from = new Map(instances.map((i) => [i.stepName, i.fromStepName]))
480
+ const path: string[] = []
481
+ let cursor: string | undefined = 'finish'
482
+ while (cursor) {
483
+ path.unshift(cursor)
484
+ cursor = from.get(cursor) ?? undefined
485
+ }
486
+ assert.deepEqual(path, ['begin', 'attempt', 'attempt#1', 'attempt#2', 'finish'])
487
+
488
+ delete metaState['testInlineCyclic']
489
+ })
490
+
340
491
  test('executeGraphStep should throw after queueing onError nodes', async () => {
341
492
  const ws = new InMemoryWorkflowService()
342
493
 
@@ -618,9 +769,14 @@ describe('graph-runner bugs', () => {
618
769
  const cJob = enqueued.find((e) => e.data?.stepName === 'c')
619
770
  assert.ok(cJob, 'node c should have been enqueued')
620
771
  assert.equal(
621
- cJob!.options,
622
- undefined,
623
- 'no retries → no queue options (preserves prior queue defaults)'
772
+ cJob!.options?.attempts,
773
+ DEFAULT_STEP_RETRIES + 1,
774
+ 'no retries → workflow default (5) + 1 attempt; queue never decides retries'
775
+ )
776
+ assert.equal(
777
+ cJob!.options?.backoff,
778
+ 'exponential',
779
+ 'default retries get exponential backoff so they ride out transient outages'
624
780
  )
625
781
  })
626
782
  })