@pikku/core 0.12.37 → 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 +113 -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.d.ts +7 -2
- package/dist/services/in-memory-workflow-service.js +19 -1
- 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/dsl/workflow-dsl.types.d.ts +19 -1
- package/dist/wirings/workflow/graph/graph-runner.js +168 -106
- package/dist/wirings/workflow/index.d.ts +4 -1
- package/dist/wirings/workflow/index.js +4 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +95 -5
- package/dist/wirings/workflow/pikku-workflow-service.js +323 -175
- package/dist/wirings/workflow/run-timeline.d.ts +85 -0
- package/dist/wirings/workflow/run-timeline.js +153 -0
- package/dist/wirings/workflow/workflow-invocation-id.d.ts +20 -0
- package/dist/wirings/workflow/workflow-invocation-id.js +38 -0
- package/dist/wirings/workflow/workflow-queue-workers.d.ts +2 -0
- package/dist/wirings/workflow/workflow.types.d.ts +7 -0
- package/package.json +2 -1
- package/src/dev/hot-reload.test.ts +5 -0
- 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 +27 -1
- 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/oauth2/oauth2-client.test.ts +25 -23
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +19 -1
- package/src/wirings/workflow/graph/graph-runner.test.ts +159 -3
- package/src/wirings/workflow/graph/graph-runner.ts +253 -142
- package/src/wirings/workflow/index.ts +17 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +447 -213
- 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 +117 -0
- package/src/wirings/workflow/workflow-invocation-id.test.ts +53 -0
- package/src/wirings/workflow/workflow-invocation-id.ts +48 -0
- package/src/wirings/workflow/workflow-queue-workers.ts +2 -0
- package/src/wirings/workflow/workflow-retry-policy.test.ts +111 -0
- package/src/wirings/workflow/workflow-step-ordinal.test.ts +79 -0
- package/src/wirings/workflow/workflow.types.ts +7 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 4122 v5 (SHA-1, name-based) UUID. Deterministic: the same name +
|
|
3
|
+
* namespace always yields the same UUID — no `uuid` dependency needed.
|
|
4
|
+
*/
|
|
5
|
+
export declare const uuidv5: (name: string, namespace?: string) => string;
|
|
6
|
+
/**
|
|
7
|
+
* The stable identity of one step *invocation* within a run — the idempotency /
|
|
8
|
+
* dedupe key handed to a step. Unlike `stepId` (minted fresh per attempt), this
|
|
9
|
+
* stays identical across every retry of the same call, because it is derived
|
|
10
|
+
* purely from `runId` + `stepName`, both of which are stable across replays.
|
|
11
|
+
*
|
|
12
|
+
* Same inputs → same UUID, so a step can safely
|
|
13
|
+
* `INSERT … ON CONFLICT (invocation_id)` / pass it as a Stripe idempotency key,
|
|
14
|
+
* and a retry of a half-applied side effect is collapsed onto the first attempt.
|
|
15
|
+
*
|
|
16
|
+
* NOTE: calling the *same* `stepName` more than once in a run is not yet
|
|
17
|
+
* disambiguated here (the store still keys steps by `runId:stepName`); when
|
|
18
|
+
* per-invocation ordinals land, the ordinal becomes the third hash component.
|
|
19
|
+
*/
|
|
20
|
+
export declare const deriveInvocationId: (runId: string, stepName: string) => string;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
// Fixed namespace for pikku workflow step invocation IDs. Frozen — changing it
|
|
3
|
+
// would alter every derived invocationId and break dedupe across a deploy.
|
|
4
|
+
const PIKKU_WORKFLOW_NAMESPACE = '70696b6b-7500-5770-9f6c-6f77000a0001';
|
|
5
|
+
const parseUuid = (uuid) => Buffer.from(uuid.replace(/-/g, ''), 'hex');
|
|
6
|
+
const formatUuid = (bytes) => {
|
|
7
|
+
const hex = bytes.toString('hex');
|
|
8
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* RFC 4122 v5 (SHA-1, name-based) UUID. Deterministic: the same name +
|
|
12
|
+
* namespace always yields the same UUID — no `uuid` dependency needed.
|
|
13
|
+
*/
|
|
14
|
+
export const uuidv5 = (name, namespace = PIKKU_WORKFLOW_NAMESPACE) => {
|
|
15
|
+
const hash = createHash('sha1')
|
|
16
|
+
.update(parseUuid(namespace))
|
|
17
|
+
.update(name, 'utf8')
|
|
18
|
+
.digest();
|
|
19
|
+
const bytes = hash.subarray(0, 16);
|
|
20
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x50; // version 5
|
|
21
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
|
|
22
|
+
return formatUuid(bytes);
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The stable identity of one step *invocation* within a run — the idempotency /
|
|
26
|
+
* dedupe key handed to a step. Unlike `stepId` (minted fresh per attempt), this
|
|
27
|
+
* stays identical across every retry of the same call, because it is derived
|
|
28
|
+
* purely from `runId` + `stepName`, both of which are stable across replays.
|
|
29
|
+
*
|
|
30
|
+
* Same inputs → same UUID, so a step can safely
|
|
31
|
+
* `INSERT … ON CONFLICT (invocation_id)` / pass it as a Stripe idempotency key,
|
|
32
|
+
* and a retry of a half-applied side effect is collapsed onto the first attempt.
|
|
33
|
+
*
|
|
34
|
+
* NOTE: calling the *same* `stepName` more than once in a run is not yet
|
|
35
|
+
* disambiguated here (the store still keys steps by `runId:stepName`); when
|
|
36
|
+
* per-invocation ordinals land, the ordinal becomes the third hash component.
|
|
37
|
+
*/
|
|
38
|
+
export const deriveInvocationId = (runId, stepName) => uuidv5(`${runId}:${stepName}`);
|
|
@@ -11,6 +11,8 @@ export interface WorkflowStepInput {
|
|
|
11
11
|
stepName: string;
|
|
12
12
|
rpcName: string;
|
|
13
13
|
data: unknown;
|
|
14
|
+
/** Predecessor step name (the walked transition); undefined for entry steps. */
|
|
15
|
+
fromStepName?: string;
|
|
14
16
|
}
|
|
15
17
|
export interface PikkuWorkflowOrchestratorInput {
|
|
16
18
|
runId: string;
|
|
@@ -83,6 +83,9 @@ export interface StepState {
|
|
|
83
83
|
retries?: number;
|
|
84
84
|
/** Delay between retries */
|
|
85
85
|
retryDelay?: string | number;
|
|
86
|
+
/** Step name of the predecessor that scheduled this step (the transition/edge
|
|
87
|
+
* walked to reach it); undefined for entry steps. Reconstructs the path. */
|
|
88
|
+
fromStepName?: string;
|
|
86
89
|
/** Creation timestamp */
|
|
87
90
|
createdAt: Date;
|
|
88
91
|
/** Last update timestamp */
|
|
@@ -109,6 +112,8 @@ export interface WorkflowRunStatus {
|
|
|
109
112
|
name: string;
|
|
110
113
|
status: StepStatus;
|
|
111
114
|
duration?: number;
|
|
115
|
+
/** Number of attempts for this step (1 = first try; > 1 means it retried). */
|
|
116
|
+
attempts?: number;
|
|
112
117
|
}>;
|
|
113
118
|
output?: unknown;
|
|
114
119
|
error?: {
|
|
@@ -269,6 +274,8 @@ export type WorkflowStepInput = {
|
|
|
269
274
|
stepName: string;
|
|
270
275
|
rpcName: string;
|
|
271
276
|
data: any;
|
|
277
|
+
/** Predecessor step name (the walked transition); undefined for entry steps. */
|
|
278
|
+
fromStepName?: string;
|
|
272
279
|
};
|
|
273
280
|
export type WorkflowOrchestratorInput = {
|
|
274
281
|
runId: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/core",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.39",
|
|
4
4
|
"author": "yasser.fadl@gmail.com",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"module": "dist/index.js",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@standard-schema/spec": "^1.1.0",
|
|
57
|
+
"@types/json-schema": "^7.0.15",
|
|
57
58
|
"cookie": "^1.1.1",
|
|
58
59
|
"json-schema": "^0.4.0",
|
|
59
60
|
"path-to-regexp": "^8.3.0",
|
|
@@ -32,6 +32,11 @@ const ensureRecursiveWatchAvailable = async (
|
|
|
32
32
|
t: TestContext,
|
|
33
33
|
dir: string
|
|
34
34
|
): Promise<boolean> => {
|
|
35
|
+
if ('bun' in process.versions) {
|
|
36
|
+
t.skip('tsx esm dynamic reload not supported under bun')
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
35
40
|
if (process.platform === 'darwin') {
|
|
36
41
|
t.skip('recursive fs.watch is unreliable on darwin')
|
|
37
42
|
return false
|
|
@@ -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
|
*/
|
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'
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, test, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { InMemoryQueueService } from './in-memory-queue-service.js'
|
|
5
|
+
import { wireQueueWorker } from '../wirings/queue/queue-runner.js'
|
|
6
|
+
import { resetPikkuState, pikkuState } from '../pikku-state.js'
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
resetPikkuState()
|
|
10
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
11
|
+
logger: { error() {}, info() {}, warn() {}, debug() {} },
|
|
12
|
+
} as any)
|
|
13
|
+
pikkuState(null, 'package', 'factories', {
|
|
14
|
+
createWireServices: async () => ({}),
|
|
15
|
+
} as any)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
// Register a queue worker whose handler runs `behavior` (which may throw) and
|
|
19
|
+
// counts invocations. Returns the live call counter.
|
|
20
|
+
const registerWorker = (
|
|
21
|
+
queueName: string,
|
|
22
|
+
behavior: (count: number) => void
|
|
23
|
+
) => {
|
|
24
|
+
const calls = { count: 0 }
|
|
25
|
+
const funcId = `queue_${queueName}`
|
|
26
|
+
pikkuState(null, 'queue', 'meta')[queueName] = { pikkuFuncId: funcId, name: queueName }
|
|
27
|
+
pikkuState(null, 'function', 'meta')[funcId] = {
|
|
28
|
+
pikkuFuncId: funcId,
|
|
29
|
+
inputSchemaName: null,
|
|
30
|
+
outputSchemaName: null,
|
|
31
|
+
sessionless: true,
|
|
32
|
+
} as any
|
|
33
|
+
wireQueueWorker({
|
|
34
|
+
name: queueName,
|
|
35
|
+
func: {
|
|
36
|
+
auth: false,
|
|
37
|
+
func: async () => {
|
|
38
|
+
calls.count++
|
|
39
|
+
behavior(calls.count)
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
} as any)
|
|
43
|
+
return calls
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const waitUntil = async (
|
|
47
|
+
predicate: () => boolean,
|
|
48
|
+
timeoutMs = 2000
|
|
49
|
+
): Promise<boolean> => {
|
|
50
|
+
const deadline = Date.now() + timeoutMs
|
|
51
|
+
while (Date.now() < deadline) {
|
|
52
|
+
if (predicate()) return true
|
|
53
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
54
|
+
}
|
|
55
|
+
return predicate()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('InMemoryQueueService retry', () => {
|
|
59
|
+
test('redelivers a transiently-failing job until it succeeds', async () => {
|
|
60
|
+
const calls = registerWorker('flaky', (count) => {
|
|
61
|
+
if (count <= 2) throw new Error(`transient ${count}`)
|
|
62
|
+
})
|
|
63
|
+
const queue = new InMemoryQueueService()
|
|
64
|
+
|
|
65
|
+
await queue.add('flaky', {}, { attempts: 5, delay: 0 })
|
|
66
|
+
|
|
67
|
+
// Succeeds on the 3rd attempt; must not exceed the configured attempts.
|
|
68
|
+
assert.equal(await waitUntil(() => calls.count >= 3), true)
|
|
69
|
+
await new Promise((r) => setTimeout(r, 150))
|
|
70
|
+
assert.equal(calls.count, 3, 'should stop retrying once the job succeeds')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('stops after `attempts` when a job always fails', async () => {
|
|
74
|
+
const calls = registerWorker('always-fails', () => {
|
|
75
|
+
throw new Error('boom')
|
|
76
|
+
})
|
|
77
|
+
const queue = new InMemoryQueueService()
|
|
78
|
+
|
|
79
|
+
await queue.add('always-fails', {}, { attempts: 3, delay: 0 })
|
|
80
|
+
|
|
81
|
+
assert.equal(await waitUntil(() => calls.count >= 3), true)
|
|
82
|
+
await new Promise((r) => setTimeout(r, 150))
|
|
83
|
+
assert.equal(calls.count, 3, 'must not redeliver beyond `attempts`')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('runs once and does not retry when no attempts are configured', async () => {
|
|
87
|
+
const calls = registerWorker('no-retry', () => {
|
|
88
|
+
throw new Error('boom')
|
|
89
|
+
})
|
|
90
|
+
const queue = new InMemoryQueueService()
|
|
91
|
+
|
|
92
|
+
await queue.add('no-retry', {}, { delay: 0 })
|
|
93
|
+
|
|
94
|
+
await new Promise((r) => setTimeout(r, 150))
|
|
95
|
+
assert.equal(calls.count, 1, 'a job without attempts runs exactly once')
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -5,6 +5,13 @@ import type {
|
|
|
5
5
|
} from '../wirings/queue/queue.types.js'
|
|
6
6
|
import { runQueueJob } from '../wirings/queue/queue-runner.js'
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* In-process queue for local/dev runs. Schedules jobs on the macrotask queue
|
|
10
|
+
* (setTimeout) so dispatch is genuinely asynchronous — the same timing shape as
|
|
11
|
+
* a real queue — and redelivers a failed job up to `options.attempts` times with
|
|
12
|
+
* backoff, so a transiently-failing workflow step recovers exactly as it would
|
|
13
|
+
* on pg-boss/bullmq instead of being silently dropped on its first error.
|
|
14
|
+
*/
|
|
8
15
|
export class InMemoryQueueService implements QueueService {
|
|
9
16
|
readonly supportsResults = false
|
|
10
17
|
private jobCounter = 0
|
|
@@ -15,31 +22,59 @@ export class InMemoryQueueService implements QueueService {
|
|
|
15
22
|
options?: JobOptions
|
|
16
23
|
): Promise<string> {
|
|
17
24
|
const jobId = `inmem-${++this.jobCounter}`
|
|
25
|
+
const maxAttempts = Math.max(1, options?.attempts ?? 1)
|
|
26
|
+
let attemptsMade = 0
|
|
27
|
+
const createdAt = new Date()
|
|
18
28
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
setTimeout(async () => {
|
|
29
|
+
const runAttempt = async () => {
|
|
30
|
+
attemptsMade++
|
|
31
|
+
const job: QueueJob<T> = {
|
|
32
|
+
id: jobId,
|
|
33
|
+
queueName,
|
|
34
|
+
data,
|
|
35
|
+
status: () => 'active',
|
|
36
|
+
metadata: () => ({ attemptsMade, maxAttempts, createdAt }),
|
|
37
|
+
pikkuUserId: options?.pikkuUserId,
|
|
38
|
+
}
|
|
30
39
|
try {
|
|
31
40
|
await runQueueJob({ job })
|
|
32
41
|
} catch (e: any) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
if (attemptsMade < maxAttempts) {
|
|
43
|
+
// Transient failure — redeliver with backoff, mirroring a real queue.
|
|
44
|
+
setTimeout(runAttempt, this.backoffDelay(options?.backoff, attemptsMade))
|
|
45
|
+
} else {
|
|
46
|
+
console.error(
|
|
47
|
+
`[InMemoryQueue] Job ${jobId} on ${queueName} failed after ${attemptsMade} attempt(s):`,
|
|
48
|
+
e.message
|
|
49
|
+
)
|
|
50
|
+
}
|
|
37
51
|
}
|
|
38
|
-
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const delay = options?.delay ?? 100 + Math.floor(Math.random() * 201)
|
|
55
|
+
setTimeout(runAttempt, delay)
|
|
39
56
|
|
|
40
57
|
return jobId
|
|
41
58
|
}
|
|
42
59
|
|
|
60
|
+
/** Delay before the next redelivery, honoring the job's backoff policy. */
|
|
61
|
+
private backoffDelay(
|
|
62
|
+
backoff: JobOptions['backoff'],
|
|
63
|
+
attemptsMade: number
|
|
64
|
+
): number {
|
|
65
|
+
const baseDelay = typeof backoff === 'object' ? (backoff.delay ?? 50) : 50
|
|
66
|
+
if (
|
|
67
|
+
backoff === 'exponential' ||
|
|
68
|
+
(typeof backoff === 'object' && backoff?.type === 'exponential')
|
|
69
|
+
) {
|
|
70
|
+
return Math.min(2 ** (attemptsMade - 1) * baseDelay, 2000)
|
|
71
|
+
}
|
|
72
|
+
if (typeof backoff === 'object' && backoff?.type === 'fixed') {
|
|
73
|
+
return backoff.delay ?? 50
|
|
74
|
+
}
|
|
75
|
+
return 50
|
|
76
|
+
}
|
|
77
|
+
|
|
43
78
|
async getJob(): Promise<null> {
|
|
44
79
|
return null
|
|
45
80
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'crypto'
|
|
2
2
|
import { PikkuWorkflowService } from '../wirings/workflow/pikku-workflow-service.js'
|
|
3
|
+
import { isExpectedError } from '../errors/error-handler.js'
|
|
3
4
|
import type { SerializedError } from '../types/core.types.js'
|
|
4
5
|
import type {
|
|
5
6
|
WorkflowPlannedStep,
|
|
@@ -7,6 +8,7 @@ import type {
|
|
|
7
8
|
WorkflowRunService,
|
|
8
9
|
WorkflowRunWire,
|
|
9
10
|
StepState,
|
|
11
|
+
StepStatus,
|
|
10
12
|
WorkflowStatus,
|
|
11
13
|
WorkflowVersionStatus,
|
|
12
14
|
WorkflowStepOptions,
|
|
@@ -140,7 +142,8 @@ export class InMemoryWorkflowService
|
|
|
140
142
|
stepName: string,
|
|
141
143
|
rpcName: string | null,
|
|
142
144
|
data: any,
|
|
143
|
-
stepOptions?: WorkflowStepOptions
|
|
145
|
+
stepOptions?: WorkflowStepOptions,
|
|
146
|
+
fromStepName?: string
|
|
144
147
|
): Promise<StepState> {
|
|
145
148
|
const stepId = randomUUID()
|
|
146
149
|
const now = new Date()
|
|
@@ -151,6 +154,7 @@ export class InMemoryWorkflowService
|
|
|
151
154
|
attemptCount: 1,
|
|
152
155
|
retries: stepOptions?.retries,
|
|
153
156
|
retryDelay: stepOptions?.retryDelay,
|
|
157
|
+
fromStepName,
|
|
154
158
|
createdAt: now,
|
|
155
159
|
updatedAt: now,
|
|
156
160
|
stepName,
|
|
@@ -227,6 +231,7 @@ export class InMemoryWorkflowService
|
|
|
227
231
|
name: error.name,
|
|
228
232
|
message: error.message,
|
|
229
233
|
stack: error.stack,
|
|
234
|
+
expected: isExpectedError(error),
|
|
230
235
|
}
|
|
231
236
|
step.failedAt = new Date()
|
|
232
237
|
step.updatedAt = new Date()
|
|
@@ -281,6 +286,7 @@ export class InMemoryWorkflowService
|
|
|
281
286
|
attemptCount: failedStep.attemptCount + 1,
|
|
282
287
|
retries: failedStep.retries,
|
|
283
288
|
retryDelay: failedStep.retryDelay,
|
|
289
|
+
fromStepName: failedStep.fromStepName,
|
|
284
290
|
createdAt: now,
|
|
285
291
|
updatedAt: now,
|
|
286
292
|
stepName: stepName,
|
|
@@ -445,6 +451,26 @@ export class InMemoryWorkflowService
|
|
|
445
451
|
return nodeIds.filter((id) => !existingSteps.has(id))
|
|
446
452
|
}
|
|
447
453
|
|
|
454
|
+
async getStepInstances(runId: string): Promise<
|
|
455
|
+
Array<{ stepName: string; status: StepStatus; fromStepName?: string }>
|
|
456
|
+
> {
|
|
457
|
+
const prefix = `${runId}:`
|
|
458
|
+
const instances: Array<{
|
|
459
|
+
stepName: string
|
|
460
|
+
status: StepStatus
|
|
461
|
+
fromStepName?: string
|
|
462
|
+
}> = []
|
|
463
|
+
for (const [key, step] of this.steps.entries()) {
|
|
464
|
+
if (!key.startsWith(prefix)) continue
|
|
465
|
+
instances.push({
|
|
466
|
+
stepName: key.substring(prefix.length),
|
|
467
|
+
status: step.status,
|
|
468
|
+
fromStepName: step.fromStepName,
|
|
469
|
+
})
|
|
470
|
+
}
|
|
471
|
+
return instances
|
|
472
|
+
}
|
|
473
|
+
|
|
448
474
|
async getNodeResults(
|
|
449
475
|
runId: string,
|
|
450
476
|
nodeIds: string[]
|
|
@@ -8,6 +8,10 @@ import type {
|
|
|
8
8
|
WorkflowStatus,
|
|
9
9
|
WorkflowVersionStatus,
|
|
10
10
|
} from '../wirings/workflow/workflow.types.js'
|
|
11
|
+
import type {
|
|
12
|
+
RunTimeline,
|
|
13
|
+
ReconstructedRunState,
|
|
14
|
+
} from '../wirings/workflow/run-timeline.js'
|
|
11
15
|
|
|
12
16
|
/**
|
|
13
17
|
* Interface for workflow orchestration
|
|
@@ -29,6 +33,11 @@ export interface WorkflowService {
|
|
|
29
33
|
getRun(id: string): Promise<WorkflowRun | null>
|
|
30
34
|
getRunStatus(id: string): Promise<WorkflowRunStatus | null>
|
|
31
35
|
getRunHistory(runId: string): Promise<Array<StepState & { stepName: string }>>
|
|
36
|
+
getRunTimeline(id: string): Promise<RunTimeline | null>
|
|
37
|
+
reconstructRunStateAt(
|
|
38
|
+
id: string,
|
|
39
|
+
at?: number | Date
|
|
40
|
+
): Promise<ReconstructedRunState | null>
|
|
32
41
|
updateRunStatus(
|
|
33
42
|
id: string,
|
|
34
43
|
status: WorkflowStatus,
|