@pikku/core 0.12.38 → 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 (38) hide show
  1. package/CHANGELOG.md +53 -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.js +2 -0
  9. package/dist/services/workflow-service.d.ts +3 -0
  10. package/dist/testing/service-tests.js +35 -0
  11. package/dist/types/core.types.d.ts +1 -0
  12. package/dist/wirings/cli/cli-runner.js +12 -3
  13. package/dist/wirings/workflow/graph/graph-runner.js +51 -61
  14. package/dist/wirings/workflow/index.d.ts +2 -0
  15. package/dist/wirings/workflow/index.js +2 -0
  16. package/dist/wirings/workflow/pikku-workflow-service.d.ts +32 -0
  17. package/dist/wirings/workflow/pikku-workflow-service.js +136 -119
  18. package/dist/wirings/workflow/run-timeline.d.ts +85 -0
  19. package/dist/wirings/workflow/run-timeline.js +153 -0
  20. package/dist/wirings/workflow/workflow.types.d.ts +2 -0
  21. package/package.json +1 -1
  22. package/src/errors/error-handler.ts +10 -0
  23. package/src/index.ts +1 -0
  24. package/src/services/in-memory-queue-service.test.ts +97 -0
  25. package/src/services/in-memory-queue-service.ts +51 -16
  26. package/src/services/in-memory-workflow-service.ts +2 -0
  27. package/src/services/workflow-service.ts +9 -0
  28. package/src/testing/service-tests.ts +65 -0
  29. package/src/types/core.types.ts +4 -0
  30. package/src/wirings/cli/cli-runner.ts +11 -3
  31. package/src/wirings/workflow/graph/graph-runner.test.ts +72 -0
  32. package/src/wirings/workflow/graph/graph-runner.ts +95 -86
  33. package/src/wirings/workflow/index.ts +14 -0
  34. package/src/wirings/workflow/pikku-workflow-service.ts +193 -137
  35. package/src/wirings/workflow/run-timeline.test.ts +211 -0
  36. package/src/wirings/workflow/run-timeline.ts +241 -0
  37. package/src/wirings/workflow/workflow.types.ts +2 -0
  38. 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
  }
@@ -1102,9 +1142,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1102
1142
  message: error.message,
1103
1143
  stack: error.stack,
1104
1144
  })
1145
+ // An expected failure (a PikkuError, e.g. a build gate tripping) —
1146
+ // its message is the whole story, so don't dump the stack. The
1147
+ // `expected` flag survives the step-boundary rehydration that strips
1148
+ // the class. Anything else is an uncaught/unexpected error: log it in
1149
+ // full so the trace is there to debug.
1105
1150
  getSingletonServices()!.logger.error(
1106
1151
  `Workflow ${name} (run ${runId}) failed:`,
1107
- error
1152
+ isExpectedError(error) ? error.message : error
1108
1153
  )
1109
1154
  throw error
1110
1155
  }
@@ -1509,17 +1554,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1509
1554
  )
1510
1555
  }
1511
1556
  } 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
- })
1557
+ result = await this.invokeStepRpc(
1558
+ runId,
1559
+ stepName,
1560
+ stepState,
1561
+ rpcName,
1562
+ data,
1563
+ rpcService
1564
+ )
1523
1565
  }
1524
1566
  }
1525
1567
 
@@ -1613,6 +1655,82 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1613
1655
  return getSingletonServices()!.queueService!
1614
1656
  }
1615
1657
 
1658
+ /**
1659
+ * Invoke a step's RPC with the workflow-step wire (step identity + provenance).
1660
+ * Identical for the queue executor and the inline executor — the only thing
1661
+ * that differs between transports is who calls it, not the call itself.
1662
+ */
1663
+ private async invokeStepRpc(
1664
+ runId: string,
1665
+ stepName: string,
1666
+ stepState: StepState,
1667
+ rpcName: string,
1668
+ data: any,
1669
+ rpcService: any
1670
+ ): Promise<any> {
1671
+ return rpcService.rpcWithWire(rpcName, data, {
1672
+ workflowStep: {
1673
+ runId,
1674
+ stepId: stepState.stepId,
1675
+ invocationId: deriveInvocationId(runId, stepName),
1676
+ attemptCount: stepState.attemptCount,
1677
+ fromInvocationId: stepState.fromStepName
1678
+ ? deriveInvocationId(runId, stepState.fromStepName)
1679
+ : undefined,
1680
+ },
1681
+ })
1682
+ }
1683
+
1684
+ /**
1685
+ * Inline (straight-through) step execution with an in-process retry loop —
1686
+ * shared by inline RPC steps and inline function steps. Same scaffolding
1687
+ * (running → result, or fail → retry-attempt → backoff → retry) wrapped
1688
+ * around a step-specific `doWork` body. Stays O(K): no suspend/replay.
1689
+ *
1690
+ * `onError` is an optional hook for terminal errors that must NOT retry
1691
+ * (e.g. RPC-not-found → suspend the run for redeploy). If it throws, the
1692
+ * loop exits immediately without recording a step error or retrying.
1693
+ */
1694
+ private async runInlineRetryLoop(
1695
+ stepState: StepState,
1696
+ retries: number,
1697
+ retryDelay: WorkflowStepOptions['retryDelay'],
1698
+ doWork: (currentStepState: StepState) => Promise<any>,
1699
+ onError?: (error: any) => Promise<void>
1700
+ ): Promise<any> {
1701
+ let currentStepState = stepState
1702
+ while (true) {
1703
+ try {
1704
+ await this.setStepRunning(currentStepState.stepId)
1705
+ const result = await doWork(currentStepState)
1706
+ await this.setStepResult(currentStepState.stepId, result)
1707
+ return result
1708
+ } catch (error: any) {
1709
+ if (onError) await onError(error)
1710
+
1711
+ // Record the error (marks step as failed)
1712
+ await this.setStepError(currentStepState.stepId, error)
1713
+
1714
+ if (currentStepState.attemptCount < retries) {
1715
+ // Create a new pending retry attempt, then back off if configured.
1716
+ currentStepState = await this.createRetryAttempt(
1717
+ currentStepState.stepId,
1718
+ 'pending'
1719
+ )
1720
+ if (retryDelay) {
1721
+ await new Promise((resolve) =>
1722
+ setTimeout(resolve, getDurationInMilliseconds(retryDelay))
1723
+ )
1724
+ }
1725
+ // Continue loop to retry
1726
+ } else {
1727
+ // No more retries, fail the workflow
1728
+ throw error
1729
+ }
1730
+ }
1731
+ }
1732
+ }
1733
+
1616
1734
  private async rpcStep(
1617
1735
  runId: string,
1618
1736
  logicalStepName: string,
@@ -1691,102 +1809,70 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1691
1809
  throw new WorkflowAsyncException(runId, stepName)
1692
1810
  }
1693
1811
 
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,
1812
+ // Inline (no transport available) - execute locally with the shared retry
1813
+ // loop. The body resolves to a sub-workflow result or a plain RPC result.
1814
+ const retries = resolvedStepOptions.retries ?? this.getConfig().retries
1815
+ const retryDelay = resolvedStepOptions.retryDelay
1816
+
1817
+ return this.runInlineRetryLoop(
1818
+ stepState,
1819
+ retries,
1820
+ retryDelay,
1821
+ async (currentStepState) => {
1822
+ // Check if the name refers to a workflow
1823
+ const workflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName]
1824
+ if (workflowMeta) {
1825
+ const childWire = {
1826
+ type: 'workflow',
1827
+ id: rpcName,
1828
+ parentRunId: runId,
1829
+ pikkuUserId: rpcService.wire?.pikkuUserId,
1830
+ }
1831
+ const { runId: childRunId } = await this.startWorkflow(
1832
+ rpcName,
1833
+ data,
1834
+ childWire,
1835
+ rpcService,
1836
+ { inline: true }
1837
+ )
1838
+ await this.setStepChildRunId(currentStepState.stepId, childRunId)
1839
+ // Poll until child workflow completes
1840
+ while (true) {
1841
+ const childRun = await this.getRun(childRunId)
1842
+ if (!childRun) {
1843
+ throw new WorkflowRunNotFoundError(childRunId)
1712
1844
  }
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)
1845
+ if (WORKFLOW_END_STATES.has(childRun.status)) {
1846
+ if (childRun.status === 'failed') {
1847
+ throw new Error(childRun.error?.message || 'Sub-workflow failed')
1726
1848
  }
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
1849
+ if (childRun.status === 'cancelled') {
1850
+ throw new Error('Sub-workflow was cancelled')
1738
1851
  }
1739
- await new Promise((resolve) => setTimeout(resolve, 500))
1740
- }
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
- )
1852
+ return childRun.output
1781
1853
  }
1782
- // Continue loop to retry
1783
- } else {
1784
- // No more retries, fail the workflow
1785
- throw error
1854
+ await new Promise((resolve) => setTimeout(resolve, 500))
1786
1855
  }
1787
1856
  }
1857
+ return this.invokeStepRpc(
1858
+ runId,
1859
+ stepName,
1860
+ currentStepState,
1861
+ rpcName,
1862
+ data,
1863
+ rpcService
1864
+ )
1865
+ },
1866
+ async (error) => {
1867
+ if (error instanceof RPCNotFoundError) {
1868
+ await this.updateRunStatus(runId, 'suspended', undefined, {
1869
+ message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
1870
+ code: 'RPC_NOT_FOUND',
1871
+ })
1872
+ throw error
1873
+ }
1788
1874
  }
1789
- }
1875
+ )
1790
1876
  }
1791
1877
 
1792
1878
  private async inlineStep(
@@ -1821,44 +1907,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1821
1907
  // Execute inline function
1822
1908
  const retries = stepOptions?.retries ?? this.getConfig().retries
1823
1909
  const retryDelay = stepOptions?.retryDelay ?? this.getConfig().retryDelay
1824
- let currentStepState = stepState
1825
1910
 
1826
1911
  // Check if we're running inline (in-memory) or remote (queue-based)
1827
1912
  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
- }
1913
+ // Inline mode - execute with the shared in-process retry loop.
1914
+ return this.runInlineRetryLoop(stepState, retries, retryDelay, () => fn())
1860
1915
  } else {
1861
- // Remote mode - use queue-based retry
1916
+ // Remote mode - single attempt, then suspend for orchestrator-driven retry.
1917
+ let currentStepState = stepState
1862
1918
  try {
1863
1919
  await this.setStepRunning(currentStepState.stepId)
1864
1920
  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
+ })