@pikku/core 0.12.38 → 0.12.40

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 (42) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/dist/errors/error-handler.d.ts +8 -0
  3. package/dist/errors/error-handler.js +8 -0
  4. package/dist/function/functions.types.d.ts +6 -2
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.js +1 -0
  7. package/dist/services/in-memory-queue-service.d.ts +9 -0
  8. package/dist/services/in-memory-queue-service.js +42 -11
  9. package/dist/services/in-memory-workflow-service.js +2 -0
  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 +7 -2
  13. package/dist/wirings/cli/cli-runner.js +12 -3
  14. package/dist/wirings/workflow/graph/graph-runner.js +51 -61
  15. package/dist/wirings/workflow/index.d.ts +2 -0
  16. package/dist/wirings/workflow/index.js +2 -0
  17. package/dist/wirings/workflow/pikku-workflow-service.d.ts +32 -0
  18. package/dist/wirings/workflow/pikku-workflow-service.js +142 -132
  19. package/dist/wirings/workflow/run-timeline.d.ts +85 -0
  20. package/dist/wirings/workflow/run-timeline.js +153 -0
  21. package/dist/wirings/workflow/workflow.types.d.ts +2 -0
  22. package/package.json +1 -1
  23. package/src/errors/error-handler.ts +10 -0
  24. package/src/function/functions.types.ts +6 -2
  25. package/src/index.ts +1 -0
  26. package/src/services/in-memory-queue-service.test.ts +97 -0
  27. package/src/services/in-memory-queue-service.ts +51 -16
  28. package/src/services/in-memory-workflow-service.ts +2 -0
  29. package/src/services/workflow-service.ts +9 -0
  30. package/src/testing/service-tests.ts +65 -0
  31. package/src/types/core.types.ts +10 -2
  32. package/src/wirings/cli/cli-runner.ts +11 -3
  33. package/src/wirings/workflow/graph/graph-runner.test.ts +72 -0
  34. package/src/wirings/workflow/graph/graph-runner.ts +95 -86
  35. package/src/wirings/workflow/index.ts +14 -0
  36. package/src/wirings/workflow/pikku-workflow-service.test.ts +13 -24
  37. package/src/wirings/workflow/pikku-workflow-service.ts +200 -151
  38. package/src/wirings/workflow/run-timeline.test.ts +211 -0
  39. package/src/wirings/workflow/run-timeline.ts +241 -0
  40. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +3 -3
  41. package/src/wirings/workflow/workflow.types.ts +2 -0
  42. package/tsconfig.tsbuildinfo +1 -1
@@ -66,10 +66,20 @@ import {
66
66
  runFromMeta,
67
67
  } from './graph/graph-runner.js'
68
68
  import type { WorkflowService } from '../../services/workflow-service.js'
69
- import { PikkuError, addError } from '../../errors/error-handler.js'
69
+ import {
70
+ PikkuError,
71
+ addError,
72
+ isExpectedError,
73
+ } from '../../errors/error-handler.js'
70
74
  import { RPCNotFoundError } from '../rpc/rpc-runner.js'
71
75
  import { ChildWorkflowStartedException } from './graph/graph-runner.js'
72
76
  import { deriveInvocationId } from './workflow-invocation-id.js'
77
+ import {
78
+ buildRunTimeline,
79
+ reconstructStateAt,
80
+ type RunTimeline,
81
+ type ReconstructedRunState,
82
+ } from './run-timeline.js'
73
83
  import type { JobOptions } from '../queue/queue.types.js'
74
84
 
75
85
  /**
@@ -416,7 +426,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
416
426
  // Build step summaries from history (latest attempt per step)
417
427
  const stepMap = new Map<
418
428
  string,
419
- { status: StepStatus; startedAt?: Date; completedAt?: Date }
429
+ { status: StepStatus; startedAt?: Date; completedAt?: Date; attempts: number }
420
430
  >()
421
431
  for (const step of history) {
422
432
  const existing = stepMap.get(step.stepName)
@@ -425,6 +435,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
425
435
  status: step.status,
426
436
  startedAt: step.runningAt ?? step.createdAt,
427
437
  completedAt: step.succeededAt ?? step.failedAt,
438
+ attempts: step.attemptCount,
428
439
  })
429
440
  }
430
441
  }
@@ -436,6 +447,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
436
447
  s.startedAt && s.completedAt
437
448
  ? s.completedAt.getTime() - s.startedAt.getTime()
438
449
  : undefined,
450
+ attempts: s.attempts,
439
451
  }))
440
452
 
441
453
  return {
@@ -453,6 +465,33 @@ export abstract class PikkuWorkflowService implements WorkflowService {
453
465
  }
454
466
  }
455
467
 
468
+ /**
469
+ * Build the run's time-travel event stream from durable history.
470
+ * @param id - Run ID
471
+ * @returns Ordered timeline, or null if the run doesn't exist
472
+ */
473
+ public async getRunTimeline(id: string): Promise<RunTimeline | null> {
474
+ const run = await this.getRun(id)
475
+ if (!run) return null
476
+ return buildRunTimeline(await this.getRunHistory(id))
477
+ }
478
+
479
+ /**
480
+ * Reconstruct the run's state at a point in its timeline.
481
+ * @param id - Run ID
482
+ * @param at - A seq index (inclusive) or a Date (inclusive); omit for the
483
+ * final state.
484
+ * @returns Reconstructed state, or null if the run doesn't exist
485
+ */
486
+ public async reconstructRunStateAt(
487
+ id: string,
488
+ at?: number | Date
489
+ ): Promise<ReconstructedRunState | null> {
490
+ const timeline = await this.getRunTimeline(id)
491
+ if (!timeline) return null
492
+ return reconstructStateAt(timeline, at ?? timeline.length - 1)
493
+ }
494
+
456
495
  /**
457
496
  * Get workflow run history (all step attempts in chronological order)
458
497
  * @param runId - Run ID
@@ -607,6 +646,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
607
646
  message: error.message,
608
647
  stack: error.stack,
609
648
  code: (error as any).code,
649
+ expected: isExpectedError(error),
610
650
  }
611
651
  await this.safeMirror(() => this.mirror!.setStepError(stepId, serialized))
612
652
  }
@@ -942,29 +982,22 @@ export abstract class PikkuWorkflowService implements WorkflowService {
942
982
  stepOptions?: WorkflowStepOptions,
943
983
  fromStepName?: string
944
984
  ): Promise<boolean> {
945
- // Step execution is decided purely by the function's `inline` flag (default
946
- // true). Only a function explicitly marked `inline: false` dispatches via
947
- // the queue. The run-level inline state is intentionally NOT consulted
948
- // here: default steps run inline even inside a queue-dispatched run, so a
949
- // normally-started workflow executes its steps in one orchestrator-worker
950
- // pass instead of one queue round-trip per step.
985
+ // Step execution is decided purely by the function's `workflowQueued` flag
986
+ // (default false). Only a function explicitly marked `workflowQueued: true`
987
+ // dispatches via the queue. If the queue service is not configured that is
988
+ // a hard error there is no inline fallback.
951
989
  const functionsMeta = pikkuState(null, 'function', 'meta')
952
990
  const rpcFuncId = pikkuState(null, 'rpc', 'meta')[rpcName]
953
991
  const rpcMeta =
954
992
  typeof rpcFuncId === 'string' ? functionsMeta[rpcFuncId] : undefined
955
- const forceQueue = rpcMeta?.inline === false
993
+ const forceQueue = rpcMeta?.workflowQueued === true
956
994
  if (!forceQueue) {
957
995
  return false
958
996
  }
959
- // The function opted out of inline execution (`inline: false`) but no queue
960
- // service is configured to dispatch it. Fall back to inline so the workflow
961
- // still progresses, but warn loudly — silently swallowing this hides a real
962
- // misconfiguration (the step won't get its own worker/retry isolation).
963
997
  if (!getSingletonServices()?.queueService) {
964
- getSingletonServices()?.logger.warn(
965
- `Workflow step '${stepName}' (function '${rpcName}') is marked 'inline: false' but no queue service is configured — running it inline instead of dispatching to a queue.`
998
+ throw new Error(
999
+ `Workflow step '${stepName}' (function '${rpcName}') is marked 'workflowQueued: true' but no queue service is configured.`
966
1000
  )
967
- return false
968
1001
  }
969
1002
  try {
970
1003
  await getSingletonServices()!.queueService!.add(
@@ -1102,9 +1135,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1102
1135
  message: error.message,
1103
1136
  stack: error.stack,
1104
1137
  })
1138
+ // An expected failure (a PikkuError, e.g. a build gate tripping) —
1139
+ // its message is the whole story, so don't dump the stack. The
1140
+ // `expected` flag survives the step-boundary rehydration that strips
1141
+ // the class. Anything else is an uncaught/unexpected error: log it in
1142
+ // full so the trace is there to debug.
1105
1143
  getSingletonServices()!.logger.error(
1106
1144
  `Workflow ${name} (run ${runId}) failed:`,
1107
- error
1145
+ isExpectedError(error) ? error.message : error
1108
1146
  )
1109
1147
  throw error
1110
1148
  }
@@ -1509,17 +1547,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1509
1547
  )
1510
1548
  }
1511
1549
  } else {
1512
- result = await rpcService.rpcWithWire(rpcName, data, {
1513
- workflowStep: {
1514
- runId,
1515
- stepId: stepState.stepId,
1516
- invocationId: deriveInvocationId(runId, stepName),
1517
- attemptCount: stepState.attemptCount,
1518
- fromInvocationId: stepState.fromStepName
1519
- ? deriveInvocationId(runId, stepState.fromStepName)
1520
- : undefined,
1521
- },
1522
- })
1550
+ result = await this.invokeStepRpc(
1551
+ runId,
1552
+ stepName,
1553
+ stepState,
1554
+ rpcName,
1555
+ data,
1556
+ rpcService
1557
+ )
1523
1558
  }
1524
1559
  }
1525
1560
 
@@ -1613,6 +1648,82 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1613
1648
  return getSingletonServices()!.queueService!
1614
1649
  }
1615
1650
 
1651
+ /**
1652
+ * Invoke a step's RPC with the workflow-step wire (step identity + provenance).
1653
+ * Identical for the queue executor and the inline executor — the only thing
1654
+ * that differs between transports is who calls it, not the call itself.
1655
+ */
1656
+ private async invokeStepRpc(
1657
+ runId: string,
1658
+ stepName: string,
1659
+ stepState: StepState,
1660
+ rpcName: string,
1661
+ data: any,
1662
+ rpcService: any
1663
+ ): Promise<any> {
1664
+ return rpcService.rpcWithWire(rpcName, data, {
1665
+ workflowStep: {
1666
+ runId,
1667
+ stepId: stepState.stepId,
1668
+ invocationId: deriveInvocationId(runId, stepName),
1669
+ attemptCount: stepState.attemptCount,
1670
+ fromInvocationId: stepState.fromStepName
1671
+ ? deriveInvocationId(runId, stepState.fromStepName)
1672
+ : undefined,
1673
+ },
1674
+ })
1675
+ }
1676
+
1677
+ /**
1678
+ * Inline (straight-through) step execution with an in-process retry loop —
1679
+ * shared by inline RPC steps and inline function steps. Same scaffolding
1680
+ * (running → result, or fail → retry-attempt → backoff → retry) wrapped
1681
+ * around a step-specific `doWork` body. Stays O(K): no suspend/replay.
1682
+ *
1683
+ * `onError` is an optional hook for terminal errors that must NOT retry
1684
+ * (e.g. RPC-not-found → suspend the run for redeploy). If it throws, the
1685
+ * loop exits immediately without recording a step error or retrying.
1686
+ */
1687
+ private async runInlineRetryLoop(
1688
+ stepState: StepState,
1689
+ retries: number,
1690
+ retryDelay: WorkflowStepOptions['retryDelay'],
1691
+ doWork: (currentStepState: StepState) => Promise<any>,
1692
+ onError?: (error: any) => Promise<void>
1693
+ ): Promise<any> {
1694
+ let currentStepState = stepState
1695
+ while (true) {
1696
+ try {
1697
+ await this.setStepRunning(currentStepState.stepId)
1698
+ const result = await doWork(currentStepState)
1699
+ await this.setStepResult(currentStepState.stepId, result)
1700
+ return result
1701
+ } catch (error: any) {
1702
+ if (onError) await onError(error)
1703
+
1704
+ // Record the error (marks step as failed)
1705
+ await this.setStepError(currentStepState.stepId, error)
1706
+
1707
+ if (currentStepState.attemptCount < retries) {
1708
+ // Create a new pending retry attempt, then back off if configured.
1709
+ currentStepState = await this.createRetryAttempt(
1710
+ currentStepState.stepId,
1711
+ 'pending'
1712
+ )
1713
+ if (retryDelay) {
1714
+ await new Promise((resolve) =>
1715
+ setTimeout(resolve, getDurationInMilliseconds(retryDelay))
1716
+ )
1717
+ }
1718
+ // Continue loop to retry
1719
+ } else {
1720
+ // No more retries, fail the workflow
1721
+ throw error
1722
+ }
1723
+ }
1724
+ }
1725
+ }
1726
+
1616
1727
  private async rpcStep(
1617
1728
  runId: string,
1618
1729
  logicalStepName: string,
@@ -1691,102 +1802,70 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1691
1802
  throw new WorkflowAsyncException(runId, stepName)
1692
1803
  }
1693
1804
 
1694
- {
1695
- // Inline (no transport available) - execute locally with retry loop
1696
- const retries = resolvedStepOptions.retries ?? this.getConfig().retries
1697
- const retryDelay = resolvedStepOptions.retryDelay
1698
- let currentStepState = stepState
1699
-
1700
- while (true) {
1701
- try {
1702
- await this.setStepRunning(currentStepState.stepId)
1703
- // Check if the name refers to a workflow
1704
- const workflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName]
1705
- let result: any
1706
- if (workflowMeta) {
1707
- const childWire = {
1708
- type: 'workflow',
1709
- id: rpcName,
1710
- parentRunId: runId,
1711
- pikkuUserId: rpcService.wire?.pikkuUserId,
1805
+ // Inline (no transport available) - execute locally with the shared retry
1806
+ // loop. The body resolves to a sub-workflow result or a plain RPC result.
1807
+ const retries = resolvedStepOptions.retries ?? this.getConfig().retries
1808
+ const retryDelay = resolvedStepOptions.retryDelay
1809
+
1810
+ return this.runInlineRetryLoop(
1811
+ stepState,
1812
+ retries,
1813
+ retryDelay,
1814
+ async (currentStepState) => {
1815
+ // Check if the name refers to a workflow
1816
+ const workflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName]
1817
+ if (workflowMeta) {
1818
+ const childWire = {
1819
+ type: 'workflow',
1820
+ id: rpcName,
1821
+ parentRunId: runId,
1822
+ pikkuUserId: rpcService.wire?.pikkuUserId,
1823
+ }
1824
+ const { runId: childRunId } = await this.startWorkflow(
1825
+ rpcName,
1826
+ data,
1827
+ childWire,
1828
+ rpcService,
1829
+ { inline: true }
1830
+ )
1831
+ await this.setStepChildRunId(currentStepState.stepId, childRunId)
1832
+ // Poll until child workflow completes
1833
+ while (true) {
1834
+ const childRun = await this.getRun(childRunId)
1835
+ if (!childRun) {
1836
+ throw new WorkflowRunNotFoundError(childRunId)
1712
1837
  }
1713
- const { runId: childRunId } = await this.startWorkflow(
1714
- rpcName,
1715
- data,
1716
- childWire,
1717
- rpcService,
1718
- { inline: true }
1719
- )
1720
- await this.setStepChildRunId(currentStepState.stepId, childRunId)
1721
- // Poll until child workflow completes
1722
- while (true) {
1723
- const childRun = await this.getRun(childRunId)
1724
- if (!childRun) {
1725
- throw new WorkflowRunNotFoundError(childRunId)
1838
+ if (WORKFLOW_END_STATES.has(childRun.status)) {
1839
+ if (childRun.status === 'failed') {
1840
+ throw new Error(childRun.error?.message || 'Sub-workflow failed')
1726
1841
  }
1727
- if (WORKFLOW_END_STATES.has(childRun.status)) {
1728
- if (childRun.status === 'failed') {
1729
- throw new Error(
1730
- childRun.error?.message || 'Sub-workflow failed'
1731
- )
1732
- }
1733
- if (childRun.status === 'cancelled') {
1734
- throw new Error('Sub-workflow was cancelled')
1735
- }
1736
- result = childRun.output
1737
- break
1842
+ if (childRun.status === 'cancelled') {
1843
+ throw new Error('Sub-workflow was cancelled')
1738
1844
  }
1739
- await new Promise((resolve) => setTimeout(resolve, 500))
1845
+ return childRun.output
1740
1846
  }
1741
- } else {
1742
- result = await rpcService.rpcWithWire(rpcName, data, {
1743
- workflowStep: {
1744
- runId,
1745
- stepId: currentStepState.stepId,
1746
- invocationId: deriveInvocationId(runId, stepName),
1747
- attemptCount: currentStepState.attemptCount,
1748
- fromInvocationId: currentStepState.fromStepName
1749
- ? deriveInvocationId(runId, currentStepState.fromStepName)
1750
- : undefined,
1751
- },
1752
- })
1753
- }
1754
- await this.setStepResult(currentStepState.stepId, result)
1755
- return result
1756
- } catch (error: any) {
1757
- if (error instanceof RPCNotFoundError) {
1758
- await this.updateRunStatus(runId, 'suspended', undefined, {
1759
- message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
1760
- code: 'RPC_NOT_FOUND',
1761
- })
1762
- throw error
1763
- }
1764
-
1765
- // Record the error (marks step as failed)
1766
- await this.setStepError(currentStepState.stepId, error)
1767
-
1768
- // Check if we should retry
1769
- if (currentStepState.attemptCount < retries) {
1770
- // Create a new pending retry attempt
1771
- currentStepState = await this.createRetryAttempt(
1772
- currentStepState.stepId,
1773
- 'pending'
1774
- )
1775
-
1776
- // Wait for retry delay if specified
1777
- if (retryDelay) {
1778
- await new Promise((resolve) =>
1779
- setTimeout(resolve, getDurationInMilliseconds(retryDelay))
1780
- )
1781
- }
1782
- // Continue loop to retry
1783
- } else {
1784
- // No more retries, fail the workflow
1785
- throw error
1847
+ await new Promise((resolve) => setTimeout(resolve, 500))
1786
1848
  }
1787
1849
  }
1850
+ return this.invokeStepRpc(
1851
+ runId,
1852
+ stepName,
1853
+ currentStepState,
1854
+ rpcName,
1855
+ data,
1856
+ rpcService
1857
+ )
1858
+ },
1859
+ async (error) => {
1860
+ if (error instanceof RPCNotFoundError) {
1861
+ await this.updateRunStatus(runId, 'suspended', undefined, {
1862
+ message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
1863
+ code: 'RPC_NOT_FOUND',
1864
+ })
1865
+ throw error
1866
+ }
1788
1867
  }
1789
- }
1868
+ )
1790
1869
  }
1791
1870
 
1792
1871
  private async inlineStep(
@@ -1821,44 +1900,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1821
1900
  // Execute inline function
1822
1901
  const retries = stepOptions?.retries ?? this.getConfig().retries
1823
1902
  const retryDelay = stepOptions?.retryDelay ?? this.getConfig().retryDelay
1824
- let currentStepState = stepState
1825
1903
 
1826
1904
  // Check if we're running inline (in-memory) or remote (queue-based)
1827
1905
  if (this.isInline(runId)) {
1828
- // Inline mode - execute with retry loop
1829
- while (true) {
1830
- try {
1831
- await this.setStepRunning(currentStepState.stepId)
1832
- const result = await fn()
1833
- await this.setStepResult(currentStepState.stepId, result)
1834
- return result
1835
- } catch (error: any) {
1836
- // Record the error (marks step as failed)
1837
- await this.setStepError(currentStepState.stepId, error)
1838
-
1839
- // Check if we should retry
1840
- if (currentStepState.attemptCount < retries) {
1841
- // Create a new pending retry attempt
1842
- currentStepState = await this.createRetryAttempt(
1843
- currentStepState.stepId,
1844
- 'pending'
1845
- )
1846
-
1847
- // Wait for retry delay if specified
1848
- if (retryDelay) {
1849
- await new Promise((resolve) =>
1850
- setTimeout(resolve, getDurationInMilliseconds(retryDelay))
1851
- )
1852
- }
1853
- // Continue loop to retry
1854
- } else {
1855
- // No more retries, fail the workflow
1856
- throw error
1857
- }
1858
- }
1859
- }
1906
+ // Inline mode - execute with the shared in-process retry loop.
1907
+ return this.runInlineRetryLoop(stepState, retries, retryDelay, () => fn())
1860
1908
  } else {
1861
- // Remote mode - use queue-based retry
1909
+ // Remote mode - single attempt, then suspend for orchestrator-driven retry.
1910
+ let currentStepState = stepState
1862
1911
  try {
1863
1912
  await this.setStepRunning(currentStepState.stepId)
1864
1913
  const result = await fn()
@@ -0,0 +1,211 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import {
5
+ buildRunTimeline,
6
+ reconstructStateAt,
7
+ reconstructFinalState,
8
+ } from './run-timeline.js'
9
+ import type { StepState } from './workflow.types.js'
10
+
11
+ type HistoryEntry = StepState & { stepName: string }
12
+
13
+ const T = (ms: number) => new Date(1_700_000_000_000 + ms)
14
+
15
+ /** A succeeded step attempt with the standard lifecycle timestamps. */
16
+ const ok = (
17
+ stepName: string,
18
+ base: number,
19
+ opts: { from?: string; result?: unknown; attempt?: number } = {}
20
+ ): HistoryEntry =>
21
+ ({
22
+ stepId: `${stepName}-${opts.attempt ?? 1}`,
23
+ stepName,
24
+ status: 'succeeded',
25
+ attemptCount: opts.attempt ?? 1,
26
+ fromStepName: opts.from,
27
+ result: opts.result,
28
+ createdAt: T(base),
29
+ runningAt: T(base + 1),
30
+ succeededAt: T(base + 2),
31
+ updatedAt: T(base + 2),
32
+ }) as HistoryEntry
33
+
34
+ const failed = (
35
+ stepName: string,
36
+ base: number,
37
+ opts: { from?: string; attempt?: number; message?: string } = {}
38
+ ): HistoryEntry =>
39
+ ({
40
+ stepId: `${stepName}-${opts.attempt ?? 1}`,
41
+ stepName,
42
+ status: 'failed',
43
+ attemptCount: opts.attempt ?? 1,
44
+ fromStepName: opts.from,
45
+ error: { message: opts.message ?? 'boom' },
46
+ createdAt: T(base),
47
+ runningAt: T(base + 1),
48
+ failedAt: T(base + 2),
49
+ updatedAt: T(base + 2),
50
+ }) as HistoryEntry
51
+
52
+ describe('buildRunTimeline', () => {
53
+ test('explodes each attempt into ordered lifecycle events', () => {
54
+ const tl = buildRunTimeline([ok('begin', 0, { result: { n: 1 } })])
55
+ assert.deepEqual(
56
+ tl.map((e) => e.type),
57
+ ['pending', 'running', 'succeeded']
58
+ )
59
+ // seq is monotonic from 0
60
+ assert.deepEqual(
61
+ tl.map((e) => e.seq),
62
+ [0, 1, 2]
63
+ )
64
+ // result rides only on the succeeded event
65
+ assert.deepEqual(tl[2]!.result, { n: 1 })
66
+ assert.equal(tl[0]!.result, undefined)
67
+ })
68
+
69
+ test('orders across steps by timestamp, lifecycle, then history index', () => {
70
+ // two steps; b created at the same instant begin succeeds
71
+ const tl = buildRunTimeline([
72
+ ok('begin', 0, { result: 1 }),
73
+ ok('next', 2, { from: 'begin', result: 2 }),
74
+ ])
75
+ // begin.pending(0) begin.running(1) begin.succeeded(2)==next.pending(2)
76
+ // → succeeded sorts before pending at the same instant (lifecycle 3 vs 0)?
77
+ // No: pending=0 < succeeded=3, so next.pending precedes begin.succeeded.
78
+ const at2 = tl.filter((e) => e.at.getTime() === T(2).getTime())
79
+ assert.deepEqual(
80
+ at2.map((e) => `${e.stepName}:${e.type}`),
81
+ ['next:pending', 'begin:succeeded']
82
+ )
83
+ })
84
+
85
+ test('carries provenance on the created event only', () => {
86
+ const tl = buildRunTimeline([ok('next', 5, { from: 'begin' })])
87
+ const created = tl.find((e) => e.type === 'pending')!
88
+ assert.equal(created.fromStepName, 'begin')
89
+ assert.equal(
90
+ tl.find((e) => e.type === 'succeeded')!.fromStepName,
91
+ undefined
92
+ )
93
+ })
94
+
95
+ test('terminal event is driven by status, not lifecycle timestamps', () => {
96
+ // Kysely leaves succeededAt/runningAt null but the row's status + result
97
+ // are authoritative — the terminal event must still fire (off updatedAt).
98
+ const noStamps = {
99
+ stepId: 's-1',
100
+ stepName: 'begin',
101
+ status: 'succeeded',
102
+ attemptCount: 1,
103
+ result: { ok: 1 },
104
+ createdAt: T(0),
105
+ updatedAt: T(5),
106
+ } as HistoryEntry
107
+ const tl = buildRunTimeline([noStamps])
108
+ assert.deepEqual(
109
+ tl.map((e) => e.type),
110
+ ['pending', 'succeeded']
111
+ )
112
+ const succeeded = tl.find((e) => e.type === 'succeeded')!
113
+ assert.equal(succeeded.at.getTime(), T(5).getTime(), 'falls back to updatedAt')
114
+ assert.deepEqual(succeeded.result, { ok: 1 })
115
+ assert.deepEqual(reconstructFinalState(tl).results, { begin: { ok: 1 } })
116
+ })
117
+ })
118
+
119
+ describe('reconstructStateAt', () => {
120
+ // begin → next → finish, each succeeding in order
121
+ const history: HistoryEntry[] = [
122
+ ok('begin', 0, { result: { step: 'begin' } }),
123
+ ok('next', 10, { from: 'begin', result: { step: 'next' } }),
124
+ ok('finish', 20, { from: 'next', result: { step: 'finish' } }),
125
+ ]
126
+ const tl = buildRunTimeline(history)
127
+
128
+ test('a point before the first event is the empty initial state', () => {
129
+ const s = reconstructStateAt(tl, -1)
130
+ assert.equal(s.seq, -1)
131
+ assert.deepEqual(s.steps, [])
132
+ assert.deepEqual(s.results, {})
133
+ assert.equal(s.phase, 'pending')
134
+ })
135
+
136
+ test('mid-run by seq: only steps up to that event exist', () => {
137
+ // fold through begin.succeeded (seq 2)
138
+ const s = reconstructStateAt(tl, 2)
139
+ assert.deepEqual(s.path, ['begin'])
140
+ assert.equal(s.steps[0]!.status, 'succeeded')
141
+ assert.deepEqual(s.results, { begin: { step: 'begin' } })
142
+ // next hasn't been created yet at this point
143
+ assert.equal(s.phase, 'idle')
144
+ })
145
+
146
+ test('mid-run captures an in-flight step as running', () => {
147
+ // begin done (0,1,2), next created+running (3,4) but not yet succeeded
148
+ const s = reconstructStateAt(tl, 4)
149
+ assert.deepEqual(s.path, ['begin', 'next'])
150
+ assert.equal(s.steps[1]!.stepName, 'next')
151
+ assert.equal(s.steps[1]!.status, 'running')
152
+ assert.equal(s.steps[1]!.fromStepName, 'begin')
153
+ assert.equal(s.phase, 'running')
154
+ // next's result not yet available in the replay cache
155
+ assert.deepEqual(s.results, { begin: { step: 'begin' } })
156
+ })
157
+
158
+ test('time-travel by Date folds every event at or before the instant', () => {
159
+ const s = reconstructStateAt(tl, T(12))
160
+ // begin fully done (<=2), next created(10)+running(11) but succeeded at 12
161
+ assert.deepEqual(s.path, ['begin', 'next'])
162
+ assert.equal(s.steps[1]!.status, 'succeeded')
163
+ assert.deepEqual(s.results, {
164
+ begin: { step: 'begin' },
165
+ next: { step: 'next' },
166
+ })
167
+ })
168
+
169
+ test('final state has the full walked path and all results', () => {
170
+ const s = reconstructFinalState(tl)
171
+ assert.deepEqual(s.path, ['begin', 'next', 'finish'])
172
+ assert.deepEqual(s.results, {
173
+ begin: { step: 'begin' },
174
+ next: { step: 'next' },
175
+ finish: { step: 'finish' },
176
+ })
177
+ assert.equal(s.phase, 'idle')
178
+ })
179
+ })
180
+
181
+ describe('reconstructStateAt — retries', () => {
182
+ // attempt 1 fails, attempt 2 (created later) succeeds
183
+ const history: HistoryEntry[] = [
184
+ failed('flaky', 0, { message: 'first try' }),
185
+ ok('flaky', 10, { result: { ok: true }, attempt: 2 }),
186
+ ]
187
+ const tl = buildRunTimeline(history)
188
+
189
+ test('between attempts the step is failed', () => {
190
+ const s = reconstructStateAt(tl, T(2)) // through failedAt of attempt 1
191
+ assert.equal(s.steps[0]!.status, 'failed')
192
+ assert.equal(s.steps[0]!.error?.message, 'first try')
193
+ assert.equal(s.phase, 'failed')
194
+ assert.deepEqual(s.results, {})
195
+ })
196
+
197
+ test("the retry's created event reopens the step and clears the error", () => {
198
+ const s = reconstructStateAt(tl, T(10)) // attempt-2 pending
199
+ assert.equal(s.steps[0]!.status, 'pending')
200
+ assert.equal(s.steps[0]!.error, undefined)
201
+ assert.equal(s.steps[0]!.attemptCount, 2)
202
+ assert.equal(s.phase, 'running')
203
+ })
204
+
205
+ test('after the retry succeeds the result is available', () => {
206
+ const s = reconstructFinalState(tl)
207
+ assert.equal(s.steps[0]!.status, 'succeeded')
208
+ assert.deepEqual(s.results, { flaky: { ok: true } })
209
+ assert.equal(s.path.length, 1, 'a retried step is still one path entry')
210
+ })
211
+ })