@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.
- package/CHANGELOG.md +76 -0
- package/dist/errors/error-handler.d.ts +8 -0
- package/dist/errors/error-handler.js +8 -0
- package/dist/function/functions.types.d.ts +6 -2
- 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 +7 -2
- 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 +142 -132
- 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/function/functions.types.ts +6 -2
- 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 +10 -2
- 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.test.ts +13 -24
- package/src/wirings/workflow/pikku-workflow-service.ts +200 -151
- package/src/wirings/workflow/run-timeline.test.ts +211 -0
- package/src/wirings/workflow/run-timeline.ts +241 -0
- package/src/wirings/workflow/workflow-dispatch-durability.test.ts +3 -3
- 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
|
}
|
|
@@ -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 `
|
|
946
|
-
//
|
|
947
|
-
// the queue.
|
|
948
|
-
//
|
|
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?.
|
|
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
|
-
|
|
965
|
-
`Workflow step '${stepName}' (function '${rpcName}') is marked '
|
|
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
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
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
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
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
|
-
|
|
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)
|
|
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 (
|
|
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
|
|
1842
|
+
if (childRun.status === 'cancelled') {
|
|
1843
|
+
throw new Error('Sub-workflow was cancelled')
|
|
1738
1844
|
}
|
|
1739
|
-
|
|
1845
|
+
return childRun.output
|
|
1740
1846
|
}
|
|
1741
|
-
|
|
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
|
-
|
|
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 -
|
|
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
|
+
})
|