@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.
- package/CHANGELOG.md +53 -0
- package/dist/errors/error-handler.d.ts +8 -0
- package/dist/errors/error-handler.js +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/services/in-memory-queue-service.d.ts +9 -0
- package/dist/services/in-memory-queue-service.js +42 -11
- package/dist/services/in-memory-workflow-service.js +2 -0
- package/dist/services/workflow-service.d.ts +3 -0
- package/dist/testing/service-tests.js +35 -0
- package/dist/types/core.types.d.ts +1 -0
- package/dist/wirings/cli/cli-runner.js +12 -3
- package/dist/wirings/workflow/graph/graph-runner.js +51 -61
- package/dist/wirings/workflow/index.d.ts +2 -0
- package/dist/wirings/workflow/index.js +2 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +32 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +136 -119
- package/dist/wirings/workflow/run-timeline.d.ts +85 -0
- package/dist/wirings/workflow/run-timeline.js +153 -0
- package/dist/wirings/workflow/workflow.types.d.ts +2 -0
- package/package.json +1 -1
- package/src/errors/error-handler.ts +10 -0
- package/src/index.ts +1 -0
- package/src/services/in-memory-queue-service.test.ts +97 -0
- package/src/services/in-memory-queue-service.ts +51 -16
- package/src/services/in-memory-workflow-service.ts +2 -0
- package/src/services/workflow-service.ts +9 -0
- package/src/testing/service-tests.ts +65 -0
- package/src/types/core.types.ts +4 -0
- package/src/wirings/cli/cli-runner.ts +11 -3
- package/src/wirings/workflow/graph/graph-runner.test.ts +72 -0
- package/src/wirings/workflow/graph/graph-runner.ts +95 -86
- package/src/wirings/workflow/index.ts +14 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +193 -137
- package/src/wirings/workflow/run-timeline.test.ts +211 -0
- package/src/wirings/workflow/run-timeline.ts +241 -0
- package/src/wirings/workflow/workflow.types.ts +2 -0
- 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 {
|
|
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
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
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
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
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
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
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 (
|
|
1728
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 -
|
|
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
|
+
})
|