@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
|
@@ -29,10 +29,11 @@ const resolveWorkflowMeta = (name) => {
|
|
|
29
29
|
};
|
|
30
30
|
const toKebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
31
31
|
import { continueGraph, executeGraphStep, runWorkflowGraph, runFromMeta, } from './graph/graph-runner.js';
|
|
32
|
-
import { PikkuError, addError } from '../../errors/error-handler.js';
|
|
32
|
+
import { PikkuError, addError, isExpectedError, } from '../../errors/error-handler.js';
|
|
33
33
|
import { RPCNotFoundError } from '../rpc/rpc-runner.js';
|
|
34
34
|
import { ChildWorkflowStartedException } from './graph/graph-runner.js';
|
|
35
35
|
import { deriveInvocationId } from './workflow-invocation-id.js';
|
|
36
|
+
import { buildRunTimeline, reconstructStateAt, } from './run-timeline.js';
|
|
36
37
|
/**
|
|
37
38
|
* Default number of retries for a workflow step when none is specified. The
|
|
38
39
|
* workflow — not the queue — owns retry policy; a step inherits this unless it
|
|
@@ -293,6 +294,7 @@ export class PikkuWorkflowService {
|
|
|
293
294
|
status: step.status,
|
|
294
295
|
startedAt: step.runningAt ?? step.createdAt,
|
|
295
296
|
completedAt: step.succeededAt ?? step.failedAt,
|
|
297
|
+
attempts: step.attemptCount,
|
|
296
298
|
});
|
|
297
299
|
}
|
|
298
300
|
}
|
|
@@ -302,6 +304,7 @@ export class PikkuWorkflowService {
|
|
|
302
304
|
duration: s.startedAt && s.completedAt
|
|
303
305
|
? s.completedAt.getTime() - s.startedAt.getTime()
|
|
304
306
|
: undefined,
|
|
307
|
+
attempts: s.attempts,
|
|
305
308
|
}));
|
|
306
309
|
return {
|
|
307
310
|
id: run.id,
|
|
@@ -317,6 +320,30 @@ export class PikkuWorkflowService {
|
|
|
317
320
|
: undefined,
|
|
318
321
|
};
|
|
319
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* Build the run's time-travel event stream from durable history.
|
|
325
|
+
* @param id - Run ID
|
|
326
|
+
* @returns Ordered timeline, or null if the run doesn't exist
|
|
327
|
+
*/
|
|
328
|
+
async getRunTimeline(id) {
|
|
329
|
+
const run = await this.getRun(id);
|
|
330
|
+
if (!run)
|
|
331
|
+
return null;
|
|
332
|
+
return buildRunTimeline(await this.getRunHistory(id));
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Reconstruct the run's state at a point in its timeline.
|
|
336
|
+
* @param id - Run ID
|
|
337
|
+
* @param at - A seq index (inclusive) or a Date (inclusive); omit for the
|
|
338
|
+
* final state.
|
|
339
|
+
* @returns Reconstructed state, or null if the run doesn't exist
|
|
340
|
+
*/
|
|
341
|
+
async reconstructRunStateAt(id, at) {
|
|
342
|
+
const timeline = await this.getRunTimeline(id);
|
|
343
|
+
if (!timeline)
|
|
344
|
+
return null;
|
|
345
|
+
return reconstructStateAt(timeline, at ?? timeline.length - 1);
|
|
346
|
+
}
|
|
320
347
|
/**
|
|
321
348
|
* Update workflow run status
|
|
322
349
|
* @param id - Run ID
|
|
@@ -390,6 +417,7 @@ export class PikkuWorkflowService {
|
|
|
390
417
|
message: error.message,
|
|
391
418
|
stack: error.stack,
|
|
392
419
|
code: error.code,
|
|
420
|
+
expected: isExpectedError(error),
|
|
393
421
|
};
|
|
394
422
|
await this.safeMirror(() => this.mirror.setStepError(stepId, serialized));
|
|
395
423
|
}
|
|
@@ -520,26 +548,19 @@ export class PikkuWorkflowService {
|
|
|
520
548
|
* through to the inline execution path.
|
|
521
549
|
*/
|
|
522
550
|
async dispatchStep(runId, stepName, rpcName, data, stepOptions, fromStepName) {
|
|
523
|
-
// Step execution is decided purely by the function's `
|
|
524
|
-
//
|
|
525
|
-
// the queue.
|
|
526
|
-
//
|
|
527
|
-
// normally-started workflow executes its steps in one orchestrator-worker
|
|
528
|
-
// pass instead of one queue round-trip per step.
|
|
551
|
+
// Step execution is decided purely by the function's `workflowQueued` flag
|
|
552
|
+
// (default false). Only a function explicitly marked `workflowQueued: true`
|
|
553
|
+
// dispatches via the queue. If the queue service is not configured that is
|
|
554
|
+
// a hard error — there is no inline fallback.
|
|
529
555
|
const functionsMeta = pikkuState(null, 'function', 'meta');
|
|
530
556
|
const rpcFuncId = pikkuState(null, 'rpc', 'meta')[rpcName];
|
|
531
557
|
const rpcMeta = typeof rpcFuncId === 'string' ? functionsMeta[rpcFuncId] : undefined;
|
|
532
|
-
const forceQueue = rpcMeta?.
|
|
558
|
+
const forceQueue = rpcMeta?.workflowQueued === true;
|
|
533
559
|
if (!forceQueue) {
|
|
534
560
|
return false;
|
|
535
561
|
}
|
|
536
|
-
// The function opted out of inline execution (`inline: false`) but no queue
|
|
537
|
-
// service is configured to dispatch it. Fall back to inline so the workflow
|
|
538
|
-
// still progresses, but warn loudly — silently swallowing this hides a real
|
|
539
|
-
// misconfiguration (the step won't get its own worker/retry isolation).
|
|
540
562
|
if (!getSingletonServices()?.queueService) {
|
|
541
|
-
|
|
542
|
-
return false;
|
|
563
|
+
throw new Error(`Workflow step '${stepName}' (function '${rpcName}') is marked 'workflowQueued: true' but no queue service is configured.`);
|
|
543
564
|
}
|
|
544
565
|
try {
|
|
545
566
|
await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })), this.resolveStepJobOptions(stepOptions));
|
|
@@ -626,7 +647,12 @@ export class PikkuWorkflowService {
|
|
|
626
647
|
message: error.message,
|
|
627
648
|
stack: error.stack,
|
|
628
649
|
});
|
|
629
|
-
|
|
650
|
+
// An expected failure (a PikkuError, e.g. a build gate tripping) —
|
|
651
|
+
// its message is the whole story, so don't dump the stack. The
|
|
652
|
+
// `expected` flag survives the step-boundary rehydration that strips
|
|
653
|
+
// the class. Anything else is an uncaught/unexpected error: log it in
|
|
654
|
+
// full so the trace is there to debug.
|
|
655
|
+
getSingletonServices().logger.error(`Workflow ${name} (run ${runId}) failed:`, isExpectedError(error) ? error.message : error);
|
|
630
656
|
throw error;
|
|
631
657
|
}
|
|
632
658
|
if (error.name === 'WorkflowDispatchException') {
|
|
@@ -924,17 +950,7 @@ export class PikkuWorkflowService {
|
|
|
924
950
|
}
|
|
925
951
|
}
|
|
926
952
|
else {
|
|
927
|
-
result = await
|
|
928
|
-
workflowStep: {
|
|
929
|
-
runId,
|
|
930
|
-
stepId: stepState.stepId,
|
|
931
|
-
invocationId: deriveInvocationId(runId, stepName),
|
|
932
|
-
attemptCount: stepState.attemptCount,
|
|
933
|
-
fromInvocationId: stepState.fromStepName
|
|
934
|
-
? deriveInvocationId(runId, stepState.fromStepName)
|
|
935
|
-
: undefined,
|
|
936
|
-
},
|
|
937
|
-
});
|
|
953
|
+
result = await this.invokeStepRpc(runId, stepName, stepState, rpcName, data, rpcService);
|
|
938
954
|
}
|
|
939
955
|
}
|
|
940
956
|
// Store result and mark succeeded
|
|
@@ -1004,6 +1020,63 @@ export class PikkuWorkflowService {
|
|
|
1004
1020
|
}
|
|
1005
1021
|
return getSingletonServices().queueService;
|
|
1006
1022
|
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Invoke a step's RPC with the workflow-step wire (step identity + provenance).
|
|
1025
|
+
* Identical for the queue executor and the inline executor — the only thing
|
|
1026
|
+
* that differs between transports is who calls it, not the call itself.
|
|
1027
|
+
*/
|
|
1028
|
+
async invokeStepRpc(runId, stepName, stepState, rpcName, data, rpcService) {
|
|
1029
|
+
return rpcService.rpcWithWire(rpcName, data, {
|
|
1030
|
+
workflowStep: {
|
|
1031
|
+
runId,
|
|
1032
|
+
stepId: stepState.stepId,
|
|
1033
|
+
invocationId: deriveInvocationId(runId, stepName),
|
|
1034
|
+
attemptCount: stepState.attemptCount,
|
|
1035
|
+
fromInvocationId: stepState.fromStepName
|
|
1036
|
+
? deriveInvocationId(runId, stepState.fromStepName)
|
|
1037
|
+
: undefined,
|
|
1038
|
+
},
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Inline (straight-through) step execution with an in-process retry loop —
|
|
1043
|
+
* shared by inline RPC steps and inline function steps. Same scaffolding
|
|
1044
|
+
* (running → result, or fail → retry-attempt → backoff → retry) wrapped
|
|
1045
|
+
* around a step-specific `doWork` body. Stays O(K): no suspend/replay.
|
|
1046
|
+
*
|
|
1047
|
+
* `onError` is an optional hook for terminal errors that must NOT retry
|
|
1048
|
+
* (e.g. RPC-not-found → suspend the run for redeploy). If it throws, the
|
|
1049
|
+
* loop exits immediately without recording a step error or retrying.
|
|
1050
|
+
*/
|
|
1051
|
+
async runInlineRetryLoop(stepState, retries, retryDelay, doWork, onError) {
|
|
1052
|
+
let currentStepState = stepState;
|
|
1053
|
+
while (true) {
|
|
1054
|
+
try {
|
|
1055
|
+
await this.setStepRunning(currentStepState.stepId);
|
|
1056
|
+
const result = await doWork(currentStepState);
|
|
1057
|
+
await this.setStepResult(currentStepState.stepId, result);
|
|
1058
|
+
return result;
|
|
1059
|
+
}
|
|
1060
|
+
catch (error) {
|
|
1061
|
+
if (onError)
|
|
1062
|
+
await onError(error);
|
|
1063
|
+
// Record the error (marks step as failed)
|
|
1064
|
+
await this.setStepError(currentStepState.stepId, error);
|
|
1065
|
+
if (currentStepState.attemptCount < retries) {
|
|
1066
|
+
// Create a new pending retry attempt, then back off if configured.
|
|
1067
|
+
currentStepState = await this.createRetryAttempt(currentStepState.stepId, 'pending');
|
|
1068
|
+
if (retryDelay) {
|
|
1069
|
+
await new Promise((resolve) => setTimeout(resolve, getDurationInMilliseconds(retryDelay)));
|
|
1070
|
+
}
|
|
1071
|
+
// Continue loop to retry
|
|
1072
|
+
}
|
|
1073
|
+
else {
|
|
1074
|
+
// No more retries, fail the workflow
|
|
1075
|
+
throw error;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1007
1080
|
async rpcStep(runId, logicalStepName, rpcName, data, rpcService, stepOptions) {
|
|
1008
1081
|
// Capture the predecessor before nextStepKey advances the lineage to us.
|
|
1009
1082
|
const fromStepName = this.lastStepName(runId);
|
|
@@ -1055,88 +1128,50 @@ export class PikkuWorkflowService {
|
|
|
1055
1128
|
await this.setStepScheduled(stepState.stepId);
|
|
1056
1129
|
throw new WorkflowAsyncException(runId, stepName);
|
|
1057
1130
|
}
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
while (true) {
|
|
1080
|
-
const childRun = await this.getRun(childRunId);
|
|
1081
|
-
if (!childRun) {
|
|
1082
|
-
throw new WorkflowRunNotFoundError(childRunId);
|
|
1083
|
-
}
|
|
1084
|
-
if (WORKFLOW_END_STATES.has(childRun.status)) {
|
|
1085
|
-
if (childRun.status === 'failed') {
|
|
1086
|
-
throw new Error(childRun.error?.message || 'Sub-workflow failed');
|
|
1087
|
-
}
|
|
1088
|
-
if (childRun.status === 'cancelled') {
|
|
1089
|
-
throw new Error('Sub-workflow was cancelled');
|
|
1090
|
-
}
|
|
1091
|
-
result = childRun.output;
|
|
1092
|
-
break;
|
|
1093
|
-
}
|
|
1094
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
else {
|
|
1098
|
-
result = await rpcService.rpcWithWire(rpcName, data, {
|
|
1099
|
-
workflowStep: {
|
|
1100
|
-
runId,
|
|
1101
|
-
stepId: currentStepState.stepId,
|
|
1102
|
-
invocationId: deriveInvocationId(runId, stepName),
|
|
1103
|
-
attemptCount: currentStepState.attemptCount,
|
|
1104
|
-
fromInvocationId: currentStepState.fromStepName
|
|
1105
|
-
? deriveInvocationId(runId, currentStepState.fromStepName)
|
|
1106
|
-
: undefined,
|
|
1107
|
-
},
|
|
1108
|
-
});
|
|
1109
|
-
}
|
|
1110
|
-
await this.setStepResult(currentStepState.stepId, result);
|
|
1111
|
-
return result;
|
|
1112
|
-
}
|
|
1113
|
-
catch (error) {
|
|
1114
|
-
if (error instanceof RPCNotFoundError) {
|
|
1115
|
-
await this.updateRunStatus(runId, 'suspended', undefined, {
|
|
1116
|
-
message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
|
|
1117
|
-
code: 'RPC_NOT_FOUND',
|
|
1118
|
-
});
|
|
1119
|
-
throw error;
|
|
1131
|
+
// Inline (no transport available) - execute locally with the shared retry
|
|
1132
|
+
// loop. The body resolves to a sub-workflow result or a plain RPC result.
|
|
1133
|
+
const retries = resolvedStepOptions.retries ?? this.getConfig().retries;
|
|
1134
|
+
const retryDelay = resolvedStepOptions.retryDelay;
|
|
1135
|
+
return this.runInlineRetryLoop(stepState, retries, retryDelay, async (currentStepState) => {
|
|
1136
|
+
// Check if the name refers to a workflow
|
|
1137
|
+
const workflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName];
|
|
1138
|
+
if (workflowMeta) {
|
|
1139
|
+
const childWire = {
|
|
1140
|
+
type: 'workflow',
|
|
1141
|
+
id: rpcName,
|
|
1142
|
+
parentRunId: runId,
|
|
1143
|
+
pikkuUserId: rpcService.wire?.pikkuUserId,
|
|
1144
|
+
};
|
|
1145
|
+
const { runId: childRunId } = await this.startWorkflow(rpcName, data, childWire, rpcService, { inline: true });
|
|
1146
|
+
await this.setStepChildRunId(currentStepState.stepId, childRunId);
|
|
1147
|
+
// Poll until child workflow completes
|
|
1148
|
+
while (true) {
|
|
1149
|
+
const childRun = await this.getRun(childRunId);
|
|
1150
|
+
if (!childRun) {
|
|
1151
|
+
throw new WorkflowRunNotFoundError(childRunId);
|
|
1120
1152
|
}
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
if (currentStepState.attemptCount < retries) {
|
|
1125
|
-
// Create a new pending retry attempt
|
|
1126
|
-
currentStepState = await this.createRetryAttempt(currentStepState.stepId, 'pending');
|
|
1127
|
-
// Wait for retry delay if specified
|
|
1128
|
-
if (retryDelay) {
|
|
1129
|
-
await new Promise((resolve) => setTimeout(resolve, getDurationInMilliseconds(retryDelay)));
|
|
1153
|
+
if (WORKFLOW_END_STATES.has(childRun.status)) {
|
|
1154
|
+
if (childRun.status === 'failed') {
|
|
1155
|
+
throw new Error(childRun.error?.message || 'Sub-workflow failed');
|
|
1130
1156
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
throw error;
|
|
1157
|
+
if (childRun.status === 'cancelled') {
|
|
1158
|
+
throw new Error('Sub-workflow was cancelled');
|
|
1159
|
+
}
|
|
1160
|
+
return childRun.output;
|
|
1136
1161
|
}
|
|
1162
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1137
1163
|
}
|
|
1138
1164
|
}
|
|
1139
|
-
|
|
1165
|
+
return this.invokeStepRpc(runId, stepName, currentStepState, rpcName, data, rpcService);
|
|
1166
|
+
}, async (error) => {
|
|
1167
|
+
if (error instanceof RPCNotFoundError) {
|
|
1168
|
+
await this.updateRunStatus(runId, 'suspended', undefined, {
|
|
1169
|
+
message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
|
|
1170
|
+
code: 'RPC_NOT_FOUND',
|
|
1171
|
+
});
|
|
1172
|
+
throw error;
|
|
1173
|
+
}
|
|
1174
|
+
});
|
|
1140
1175
|
}
|
|
1141
1176
|
async inlineStep(runId, logicalStepName, fn, stepOptions) {
|
|
1142
1177
|
const fromStepName = this.lastStepName(runId);
|
|
@@ -1157,39 +1192,14 @@ export class PikkuWorkflowService {
|
|
|
1157
1192
|
// Execute inline function
|
|
1158
1193
|
const retries = stepOptions?.retries ?? this.getConfig().retries;
|
|
1159
1194
|
const retryDelay = stepOptions?.retryDelay ?? this.getConfig().retryDelay;
|
|
1160
|
-
let currentStepState = stepState;
|
|
1161
1195
|
// Check if we're running inline (in-memory) or remote (queue-based)
|
|
1162
1196
|
if (this.isInline(runId)) {
|
|
1163
|
-
// Inline mode - execute with retry loop
|
|
1164
|
-
|
|
1165
|
-
try {
|
|
1166
|
-
await this.setStepRunning(currentStepState.stepId);
|
|
1167
|
-
const result = await fn();
|
|
1168
|
-
await this.setStepResult(currentStepState.stepId, result);
|
|
1169
|
-
return result;
|
|
1170
|
-
}
|
|
1171
|
-
catch (error) {
|
|
1172
|
-
// Record the error (marks step as failed)
|
|
1173
|
-
await this.setStepError(currentStepState.stepId, error);
|
|
1174
|
-
// Check if we should retry
|
|
1175
|
-
if (currentStepState.attemptCount < retries) {
|
|
1176
|
-
// Create a new pending retry attempt
|
|
1177
|
-
currentStepState = await this.createRetryAttempt(currentStepState.stepId, 'pending');
|
|
1178
|
-
// Wait for retry delay if specified
|
|
1179
|
-
if (retryDelay) {
|
|
1180
|
-
await new Promise((resolve) => setTimeout(resolve, getDurationInMilliseconds(retryDelay)));
|
|
1181
|
-
}
|
|
1182
|
-
// Continue loop to retry
|
|
1183
|
-
}
|
|
1184
|
-
else {
|
|
1185
|
-
// No more retries, fail the workflow
|
|
1186
|
-
throw error;
|
|
1187
|
-
}
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1197
|
+
// Inline mode - execute with the shared in-process retry loop.
|
|
1198
|
+
return this.runInlineRetryLoop(stepState, retries, retryDelay, () => fn());
|
|
1190
1199
|
}
|
|
1191
1200
|
else {
|
|
1192
|
-
// Remote mode -
|
|
1201
|
+
// Remote mode - single attempt, then suspend for orchestrator-driven retry.
|
|
1202
|
+
let currentStepState = stepState;
|
|
1193
1203
|
try {
|
|
1194
1204
|
await this.setStepRunning(currentStepState.stepId);
|
|
1195
1205
|
const result = await fn();
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { SerializedError } from '../../types/core.types.js';
|
|
2
|
+
import type { StepState, StepStatus } from './workflow.types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Time-travel over a workflow run.
|
|
5
|
+
*
|
|
6
|
+
* A run's durable history (`getRunHistory`) is one row per step *attempt*, each
|
|
7
|
+
* carrying lifecycle timestamps (created/scheduled/running/succeeded/failed).
|
|
8
|
+
* `buildRunTimeline` explodes those rows into a flat, chronologically-ordered
|
|
9
|
+
* event stream, and `reconstructStateAt` folds the stream up to any point to
|
|
10
|
+
* recover what the run "knew" then — the same step cache a replay would hold:
|
|
11
|
+
* per-step status, accumulated results, and the walked path.
|
|
12
|
+
*
|
|
13
|
+
* These are pure functions over history — no IO — so they're trivially testable
|
|
14
|
+
* and transport-independent (the same fold works for Redis/Kysely/in-memory).
|
|
15
|
+
*/
|
|
16
|
+
/** A single observable transition of one step attempt. */
|
|
17
|
+
export interface RunTimelineEvent {
|
|
18
|
+
/** Monotonic 0-based position in the timeline. */
|
|
19
|
+
seq: number;
|
|
20
|
+
/** When it happened. */
|
|
21
|
+
at: Date;
|
|
22
|
+
/** The step's new status at this event (matches StepStatus lifecycle). */
|
|
23
|
+
type: Extract<StepStatus, 'pending' | 'scheduled' | 'running' | 'succeeded' | 'failed'>;
|
|
24
|
+
/** Physical step name (includes revisit ordinal, e.g. `attempt#2`). */
|
|
25
|
+
stepName: string;
|
|
26
|
+
/** Which attempt this event belongs to (1-based). */
|
|
27
|
+
attemptCount: number;
|
|
28
|
+
/** Predecessor that scheduled this step — only on the `pending` (created)
|
|
29
|
+
* event; undefined for entry steps. Reconstructs the walked edge. */
|
|
30
|
+
fromStepName?: string;
|
|
31
|
+
/** Result snapshot — only on `succeeded`. */
|
|
32
|
+
result?: unknown;
|
|
33
|
+
/** Error snapshot — only on `failed`. */
|
|
34
|
+
error?: SerializedError;
|
|
35
|
+
}
|
|
36
|
+
export type RunTimeline = RunTimelineEvent[];
|
|
37
|
+
type HistoryEntry = StepState & {
|
|
38
|
+
stepName: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Build the ordered event stream for a run from its raw history.
|
|
42
|
+
*
|
|
43
|
+
* History is expected oldest-first but is sorted defensively by (timestamp,
|
|
44
|
+
* lifecycle, original index) so simultaneous timestamps stay deterministic.
|
|
45
|
+
*/
|
|
46
|
+
export declare function buildRunTimeline(history: HistoryEntry[]): RunTimeline;
|
|
47
|
+
/** A step's reconstructed state at a point in the timeline. */
|
|
48
|
+
export interface ReconstructedStep {
|
|
49
|
+
stepName: string;
|
|
50
|
+
status: StepStatus;
|
|
51
|
+
attemptCount: number;
|
|
52
|
+
fromStepName?: string;
|
|
53
|
+
result?: unknown;
|
|
54
|
+
error?: SerializedError;
|
|
55
|
+
}
|
|
56
|
+
/** Coarse run phase derived purely from step states (not the run's output). */
|
|
57
|
+
export type RunPhase = 'pending' | 'running' | 'failed' | 'idle';
|
|
58
|
+
/** The whole run's reconstructed state at a point in the timeline. */
|
|
59
|
+
export interface ReconstructedRunState {
|
|
60
|
+
/** seq of the last applied event, or -1 if the point precedes all events. */
|
|
61
|
+
seq: number;
|
|
62
|
+
/** Wall-clock time of the last applied event. */
|
|
63
|
+
at?: Date;
|
|
64
|
+
/** Per-step latest state, in first-seen (walked) order. */
|
|
65
|
+
steps: ReconstructedStep[];
|
|
66
|
+
/** Outputs available to downstream steps — the replay step cache at this
|
|
67
|
+
* point (succeeded steps only). */
|
|
68
|
+
results: Record<string, unknown>;
|
|
69
|
+
/** Step names in the order they were first created — the walked path. */
|
|
70
|
+
path: string[];
|
|
71
|
+
/** Derived phase: `running` if any step is in-flight, else `failed` if a step
|
|
72
|
+
* is failed with no in-flight work, else `idle` (between transitions). */
|
|
73
|
+
phase: RunPhase;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Fold the timeline up to `at` and return the run's state at that point.
|
|
77
|
+
*
|
|
78
|
+
* `at` is either a seq index (inclusive — apply events `0..at`) or a `Date`
|
|
79
|
+
* (inclusive — apply every event at or before that instant). A point before the
|
|
80
|
+
* first event yields the empty initial state.
|
|
81
|
+
*/
|
|
82
|
+
export declare function reconstructStateAt(timeline: RunTimeline, at: number | Date): ReconstructedRunState;
|
|
83
|
+
/** Reconstruct the final state (fold the whole timeline). */
|
|
84
|
+
export declare function reconstructFinalState(timeline: RunTimeline): ReconstructedRunState;
|
|
85
|
+
export {};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/** Lifecycle tiebreak for events that share a timestamp. */
|
|
2
|
+
const LIFECYCLE_ORDER = {
|
|
3
|
+
pending: 0,
|
|
4
|
+
scheduled: 1,
|
|
5
|
+
running: 2,
|
|
6
|
+
succeeded: 3,
|
|
7
|
+
failed: 3,
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Build the ordered event stream for a run from its raw history.
|
|
11
|
+
*
|
|
12
|
+
* History is expected oldest-first but is sorted defensively by (timestamp,
|
|
13
|
+
* lifecycle, original index) so simultaneous timestamps stay deterministic.
|
|
14
|
+
*/
|
|
15
|
+
export function buildRunTimeline(history) {
|
|
16
|
+
const raw = [];
|
|
17
|
+
history.forEach((entry, order) => {
|
|
18
|
+
const base = {
|
|
19
|
+
stepName: entry.stepName,
|
|
20
|
+
attemptCount: entry.attemptCount,
|
|
21
|
+
order,
|
|
22
|
+
};
|
|
23
|
+
// The created event always exists; it carries provenance.
|
|
24
|
+
raw.push({
|
|
25
|
+
...base,
|
|
26
|
+
at: entry.createdAt,
|
|
27
|
+
type: 'pending',
|
|
28
|
+
fromStepName: entry.fromStepName,
|
|
29
|
+
});
|
|
30
|
+
// Intermediate events are optional enrichment — emit only if the backend
|
|
31
|
+
// recorded their timestamp.
|
|
32
|
+
if (entry.scheduledAt) {
|
|
33
|
+
raw.push({ ...base, at: entry.scheduledAt, type: 'scheduled' });
|
|
34
|
+
}
|
|
35
|
+
if (entry.runningAt) {
|
|
36
|
+
raw.push({ ...base, at: entry.runningAt, type: 'running' });
|
|
37
|
+
}
|
|
38
|
+
// The terminal event is driven by the row's authoritative status (the
|
|
39
|
+
// lifecycle timestamps aren't populated by every backend — Kysely leaves
|
|
40
|
+
// them null), falling back to updatedAt when the specific stamp is absent.
|
|
41
|
+
if (entry.status === 'succeeded') {
|
|
42
|
+
raw.push({
|
|
43
|
+
...base,
|
|
44
|
+
at: entry.succeededAt ?? entry.updatedAt,
|
|
45
|
+
type: 'succeeded',
|
|
46
|
+
result: entry.result,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
else if (entry.status === 'failed') {
|
|
50
|
+
raw.push({
|
|
51
|
+
...base,
|
|
52
|
+
at: entry.failedAt ?? entry.updatedAt,
|
|
53
|
+
type: 'failed',
|
|
54
|
+
error: entry.error,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
raw.sort((a, b) => {
|
|
59
|
+
const ta = a.at.getTime();
|
|
60
|
+
const tb = b.at.getTime();
|
|
61
|
+
if (ta !== tb)
|
|
62
|
+
return ta - tb;
|
|
63
|
+
const la = LIFECYCLE_ORDER[a.type];
|
|
64
|
+
const lb = LIFECYCLE_ORDER[b.type];
|
|
65
|
+
if (la !== lb)
|
|
66
|
+
return la - lb;
|
|
67
|
+
return a.order - b.order;
|
|
68
|
+
});
|
|
69
|
+
return raw.map(({ order: _order, ...event }, seq) => ({ ...event, seq }));
|
|
70
|
+
}
|
|
71
|
+
const IN_FLIGHT = new Set([
|
|
72
|
+
'pending',
|
|
73
|
+
'scheduled',
|
|
74
|
+
'running',
|
|
75
|
+
]);
|
|
76
|
+
/**
|
|
77
|
+
* Fold the timeline up to `at` and return the run's state at that point.
|
|
78
|
+
*
|
|
79
|
+
* `at` is either a seq index (inclusive — apply events `0..at`) or a `Date`
|
|
80
|
+
* (inclusive — apply every event at or before that instant). A point before the
|
|
81
|
+
* first event yields the empty initial state.
|
|
82
|
+
*/
|
|
83
|
+
export function reconstructStateAt(timeline, at) {
|
|
84
|
+
const cutoff = (event) => typeof at === 'number'
|
|
85
|
+
? event.seq <= at
|
|
86
|
+
: event.at.getTime() <= at.getTime();
|
|
87
|
+
const steps = new Map();
|
|
88
|
+
const path = [];
|
|
89
|
+
let lastSeq = -1;
|
|
90
|
+
let lastAt;
|
|
91
|
+
for (const event of timeline) {
|
|
92
|
+
if (!cutoff(event))
|
|
93
|
+
break;
|
|
94
|
+
lastSeq = event.seq;
|
|
95
|
+
lastAt = event.at;
|
|
96
|
+
let step = steps.get(event.stepName);
|
|
97
|
+
if (!step) {
|
|
98
|
+
step = {
|
|
99
|
+
stepName: event.stepName,
|
|
100
|
+
status: event.type,
|
|
101
|
+
attemptCount: event.attemptCount,
|
|
102
|
+
fromStepName: event.fromStepName,
|
|
103
|
+
};
|
|
104
|
+
steps.set(event.stepName, step);
|
|
105
|
+
path.push(event.stepName);
|
|
106
|
+
}
|
|
107
|
+
step.status = event.type;
|
|
108
|
+
step.attemptCount = event.attemptCount;
|
|
109
|
+
if (event.fromStepName !== undefined) {
|
|
110
|
+
step.fromStepName = event.fromStepName;
|
|
111
|
+
}
|
|
112
|
+
// A retry's created event reopens the step — drop the prior outcome.
|
|
113
|
+
if (event.type === 'pending') {
|
|
114
|
+
delete step.result;
|
|
115
|
+
delete step.error;
|
|
116
|
+
}
|
|
117
|
+
if (event.type === 'succeeded') {
|
|
118
|
+
step.result = event.result;
|
|
119
|
+
step.error = undefined;
|
|
120
|
+
}
|
|
121
|
+
if (event.type === 'failed') {
|
|
122
|
+
step.error = event.error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const orderedSteps = path.map((name) => steps.get(name));
|
|
126
|
+
const results = {};
|
|
127
|
+
for (const step of orderedSteps) {
|
|
128
|
+
if (step.status === 'succeeded') {
|
|
129
|
+
results[step.stepName] = step.result;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
seq: lastSeq,
|
|
134
|
+
at: lastAt,
|
|
135
|
+
steps: orderedSteps,
|
|
136
|
+
results,
|
|
137
|
+
path,
|
|
138
|
+
phase: derivePhase(orderedSteps),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** Reconstruct the final state (fold the whole timeline). */
|
|
142
|
+
export function reconstructFinalState(timeline) {
|
|
143
|
+
return reconstructStateAt(timeline, timeline.length - 1);
|
|
144
|
+
}
|
|
145
|
+
function derivePhase(steps) {
|
|
146
|
+
if (steps.length === 0)
|
|
147
|
+
return 'pending';
|
|
148
|
+
if (steps.some((s) => IN_FLIGHT.has(s.status)))
|
|
149
|
+
return 'running';
|
|
150
|
+
if (steps.some((s) => s.status === 'failed'))
|
|
151
|
+
return 'failed';
|
|
152
|
+
return 'idle';
|
|
153
|
+
}
|
package/package.json
CHANGED
|
@@ -16,6 +16,16 @@ export class PikkuError extends Error {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Whether an error is a deliberate, expected failure rather than an uncaught
|
|
21
|
+
* bug. A `PikkuError` always counts; so does any error carrying `expected:
|
|
22
|
+
* true` — used to keep the marker alive when an error is serialized across a
|
|
23
|
+
* workflow step boundary and rehydrated as a plain `Error`. Callers log the
|
|
24
|
+
* message alone for expected errors and the full stack for everything else.
|
|
25
|
+
*/
|
|
26
|
+
export const isExpectedError = (error: unknown): boolean =>
|
|
27
|
+
error instanceof PikkuError || (error as any)?.expected === true
|
|
28
|
+
|
|
19
29
|
/**
|
|
20
30
|
* Interface for error details.
|
|
21
31
|
*/
|
|
@@ -295,8 +295,12 @@ export type CorePikkuFunctionConfig<
|
|
|
295
295
|
readonly?: boolean
|
|
296
296
|
deploy?: 'serverless' | 'server' | 'auto'
|
|
297
297
|
approvalRequired?: boolean
|
|
298
|
-
/** When
|
|
299
|
-
|
|
298
|
+
/** When true, workflow steps calling this function are dispatched via the queue. No queue service configured is a hard error. Defaults to false (inline). */
|
|
299
|
+
workflowQueued?: boolean
|
|
300
|
+
/** Number of retry attempts when this function is used as a workflow step. */
|
|
301
|
+
workflowRetries?: number
|
|
302
|
+
/** Timeout for this function when used as a workflow step (e.g. '30s', '5m'). */
|
|
303
|
+
workflowTimeout?: string
|
|
300
304
|
audit?:
|
|
301
305
|
| boolean
|
|
302
306
|
| {
|
package/src/index.ts
CHANGED
|
@@ -93,6 +93,7 @@ export type {
|
|
|
93
93
|
export { runQueueJob } from './wirings/queue/queue-runner.js'
|
|
94
94
|
export { runScheduledTask } from './wirings/scheduler/scheduler-runner.js'
|
|
95
95
|
export { NotFoundError } from './errors/errors.js'
|
|
96
|
+
export { PikkuError, isExpectedError } from './errors/error-handler.js'
|
|
96
97
|
export type { EventHubService } from './wirings/channel/eventhub-service.js'
|
|
97
98
|
export type { QueueService } from './wirings/queue/queue.types.js'
|
|
98
99
|
export type { JWTService } from './services/jwt-service.js'
|