@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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,116 @@
|
|
|
1
|
+
## 0.12.39
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 4be205f: Dedupe DSL step execution: extract a shared `invokeStepRpc` (step RPC + provenance wire, used by both the queue and inline executors) and a shared `runInlineRetryLoop` (the in-process running→result→retry scaffolding, used by inline RPC steps and inline function steps). No behavior change — the inline path stays straight-through O(K); the queue path keeps its suspend/replay model.
|
|
6
|
+
- 061c717: fix(cli): log just the message for expected failures, keep the stack for uncaught errors
|
|
7
|
+
|
|
8
|
+
A deliberate, expected failure — e.g. `pikku all` aborting because a build gate
|
|
9
|
+
(blocking diagnostics) tripped — was dumping a full workflow stack trace, burying
|
|
10
|
+
the one line that matters. Errors are now classified: a `PikkuError` (or any error
|
|
11
|
+
carrying an `expected` marker) prints its message alone, while a genuinely uncaught
|
|
12
|
+
error still prints the full stack so it can be debugged.
|
|
13
|
+
- New `isExpectedError(error)` helper (exported from `@pikku/core`): true for a
|
|
14
|
+
`PikkuError` or an error flagged `expected`.
|
|
15
|
+
- The `expected` flag is threaded through `SerializedError` and the in-memory
|
|
16
|
+
workflow step store so it survives the step-boundary rehydration that strips the
|
|
17
|
+
error's class.
|
|
18
|
+
- The CLI runner's top-level catch, the `CLILogger`, and the workflow runner's
|
|
19
|
+
failure log all honour it.
|
|
20
|
+
- The blocking-diagnostics abort now throws a `PikkuError` subclass so it is
|
|
21
|
+
treated as expected.
|
|
22
|
+
|
|
23
|
+
- 2c55e13: fix(queue): `InMemoryQueueService` redelivers failed jobs up to `options.attempts` with backoff
|
|
24
|
+
|
|
25
|
+
Previously the in-memory queue ran each job once and dropped it on failure, so a
|
|
26
|
+
transiently-failing workflow step dispatched via `inline: false` would stall the
|
|
27
|
+
run forever (the orchestrator was never resumed). It now honors the `attempts`
|
|
28
|
+
and `backoff` already produced by the workflow step job options, redelivering on
|
|
29
|
+
failure — matching pg-boss/bullmq semantics so local/dev runs recover from
|
|
30
|
+
transient step failures exactly as production does.
|
|
31
|
+
|
|
32
|
+
- c745c26: fix(workflow): inline graph runs use the same transition planner as the queue
|
|
33
|
+
|
|
34
|
+
`continueGraphInline` had its own, weaker graph traversal that couldn't revisit a
|
|
35
|
+
node (no cycles) and never recorded `fromStepName`, so an inline-run graph stored
|
|
36
|
+
different step state than the same graph run through a queue. It now uses the
|
|
37
|
+
shared `planGraphTransitions` planner — inline graphs get joins, cycle revisits
|
|
38
|
+
(`node`, `node#1`, …) and step provenance identical to the queued path, and the
|
|
39
|
+
duplicate traversal logic is removed.
|
|
40
|
+
|
|
41
|
+
- 57900b5: Add workflow run time-travel. A run's durable history (`getRunHistory`) is one row per step attempt with lifecycle timestamps; `buildRunTimeline(history)` explodes those into a flat, chronologically-ordered event stream and `reconstructStateAt(timeline, at)` folds it up to any point — a seq index or a `Date` — to recover what the run "knew" then: per-step status, the accumulated step-result cache, the walked path (via `fromStepName`), and a derived phase. These are pure, transport-independent functions (same fold for Redis/Kysely/in-memory), exported from `@pikku/core/workflow` alongside `reconstructFinalState`. `PikkuWorkflowService` gains `getRunTimeline(id)` and `reconstructRunStateAt(id, at?)` that wrap them over a run's history, inherited by every backend. Correctly handles retries (a retry's created event reopens the step and clears the prior outcome) and graph cycles (revisit ordinals are distinct path entries).
|
|
42
|
+
- 72694f6: feat(workflow): expose per-step attempt count + record running/succeeded/failed timestamps
|
|
43
|
+
|
|
44
|
+
`getRunStatus` now returns `attempts` (the latest attempt count) per step, so
|
|
45
|
+
consumers can show retry counts without a second history query. It already
|
|
46
|
+
computed `duration` from `runningAt`/`succeededAt`, but the kysely and mongodb
|
|
47
|
+
workflow stores only stamped those timestamps on the _insert_ path — the
|
|
48
|
+
`running` / `succeeded` / `failed` status transitions updated the history row's
|
|
49
|
+
status without setting `runningAt` / `succeededAt` / `failedAt`, so `duration`
|
|
50
|
+
was always undefined. The transitions now stamp the matching timestamp, so step
|
|
51
|
+
duration is populated for kysely- and mongodb-backed runs. (Redis already
|
|
52
|
+
stamped on transition.) A shared service-suite test guards both behaviours.
|
|
53
|
+
|
|
54
|
+
## 0.12.38
|
|
55
|
+
|
|
56
|
+
### Patch Changes
|
|
57
|
+
|
|
58
|
+
- 92cd5b1: feat(workflow): workflow-owned step retries + stable invocationId
|
|
59
|
+
|
|
60
|
+
The workflow — not the queue — now owns step retry policy, and each step
|
|
61
|
+
invocation gets a stable idempotency key.
|
|
62
|
+
- **Default `retries: 5` with exponential backoff.** A step with no `retries`
|
|
63
|
+
previously inherited the queue's bare default (e.g. pg-boss `retry_limit 2`,
|
|
64
|
+
no backoff) so retries fired instantly and couldn't outlast a transient
|
|
65
|
+
outage. Retries now default to 5 with backoff, resolved at the workflow layer.
|
|
66
|
+
- **`retries: 0` is honored.** Dispatch previously passed `undefined` options
|
|
67
|
+
for `retries: 0`, letting the queue re-run a non-idempotent step up to its own
|
|
68
|
+
default. The resolved policy now always sets `attempts` (`retries: 0` →
|
|
69
|
+
`attempts: 1`), so the queue never second-guesses the workflow. The persisted
|
|
70
|
+
step retries and the dispatched `attempts` are resolved together so
|
|
71
|
+
"retries exhausted" and "no more redeliveries" are the same event.
|
|
72
|
+
- **`workflowStep.invocationId`** — a deterministic, dependency-free
|
|
73
|
+
`uuidv5(runId:stepName)` handed to every step. Unlike `stepId` (minted per
|
|
74
|
+
attempt), it is identical across retries, so a step can dedupe on it
|
|
75
|
+
(`ON CONFLICT (invocationId)`, Stripe idempotency keys, etc.).
|
|
76
|
+
- **queue-bullmq**: `mapPikkuJobToBull` now maps `backoff` (previously dropped,
|
|
77
|
+
so a step's backoff silently never applied on Redis), and `registerQueues`
|
|
78
|
+
throws a clear error when no logger is available (matching queue-pg-boss).
|
|
79
|
+
- **Dispatch failures are recoverable, not fatal.** A step is now marked
|
|
80
|
+
`scheduled` only _after_ it is successfully handed to its transport (queue or
|
|
81
|
+
scheduler) — a failed hand-off leaves it `pending` so a replay re-dispatches
|
|
82
|
+
it, instead of stranding it in `scheduled` (replay would pause forever on a
|
|
83
|
+
job that was never enqueued). A transport outage (e.g. pg-boss momentarily
|
|
84
|
+
down) is surfaced as a new `WorkflowDispatchException`, which the orchestrator
|
|
85
|
+
treats as transient: the run is left running and the orchestrator job is
|
|
86
|
+
rethrown for redelivery (it replays idempotently from the snapshot) rather
|
|
87
|
+
than the whole run being marked `failed`. The orchestrator job now also
|
|
88
|
+
carries its own retry policy, so this holds even when the orchestrator queue
|
|
89
|
+
is configured `retry_limit 0`. A genuine step error still fails the run.
|
|
90
|
+
- **Same step name can be invoked multiple times in one run.** Step rows are now
|
|
91
|
+
keyed per _invocation_: the Nth reach of a step name in a replay resolves to a
|
|
92
|
+
physical key (`name` for the first, `name#N` for repeats), so a literal
|
|
93
|
+
duplicate name no longer clobbers the earlier step's state. The first reach
|
|
94
|
+
keeps the bare name, so existing rows, graph-node matching and `invocationId`s
|
|
95
|
+
are unchanged. Ordinals are derived deterministically from DSL execution order
|
|
96
|
+
and reset each replay.
|
|
97
|
+
- **Step provenance (`fromStepName`) + graph cycles.** Every step now records
|
|
98
|
+
the predecessor it was scheduled from (`fromStepName`; entry steps have none),
|
|
99
|
+
persisted on the step row across all stores (in-memory, kysely, redis,
|
|
100
|
+
mongodb, cloudflare DO) and carried in the queued payload. The DSL wire
|
|
101
|
+
exposes the derived `fromInvocationId` (`uuidv5(runId:fromStepName)`) so
|
|
102
|
+
consumers get the stable predecessor key without a second persisted id —
|
|
103
|
+
`fromStepName` is the source of truth (it is replay-deterministic; `stepId`,
|
|
104
|
+
minted per row, is not). This makes the walked path reconstructable even when
|
|
105
|
+
a node is reached more than once: in `a → b → a → c` the second `a` is a
|
|
106
|
+
distinct ordinal instance (`a#1`) whose `fromStepName` is `b`.
|
|
107
|
+
The graph runner now supports **cycles**: a forward edge into an
|
|
108
|
+
already-started node still collapses to a single run (joins/diamonds are
|
|
109
|
+
unchanged), but a _back-edge_ — one whose target can reach its source — fires
|
|
110
|
+
a fresh ordinal instance, so a node can loop back to itself. Termination is
|
|
111
|
+
the graph's responsibility (branch routing must converge); the engine enforces
|
|
112
|
+
no visit cap.
|
|
113
|
+
|
|
1
114
|
## 0.12.37
|
|
2
115
|
|
|
3
116
|
### Patch Changes
|
|
@@ -9,6 +9,14 @@ export declare class PikkuError extends Error {
|
|
|
9
9
|
*/
|
|
10
10
|
constructor(message?: string);
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Whether an error is a deliberate, expected failure rather than an uncaught
|
|
14
|
+
* bug. A `PikkuError` always counts; so does any error carrying `expected:
|
|
15
|
+
* true` — used to keep the marker alive when an error is serialized across a
|
|
16
|
+
* workflow step boundary and rehydrated as a plain `Error`. Callers log the
|
|
17
|
+
* message alone for expected errors and the full stack for everything else.
|
|
18
|
+
*/
|
|
19
|
+
export declare const isExpectedError: (error: unknown) => boolean;
|
|
12
20
|
/**
|
|
13
21
|
* Interface for error details.
|
|
14
22
|
*/
|
|
@@ -14,6 +14,14 @@ export class PikkuError extends Error {
|
|
|
14
14
|
this.name = new.target.name;
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Whether an error is a deliberate, expected failure rather than an uncaught
|
|
19
|
+
* bug. A `PikkuError` always counts; so does any error carrying `expected:
|
|
20
|
+
* true` — used to keep the marker alive when an error is serialized across a
|
|
21
|
+
* workflow step boundary and rehydrated as a plain `Error`. Callers log the
|
|
22
|
+
* message alone for expected errors and the full stack for everything else.
|
|
23
|
+
*/
|
|
24
|
+
export const isExpectedError = (error) => error instanceof PikkuError || error?.expected === true;
|
|
17
25
|
/**
|
|
18
26
|
* Adds an error to the API errors map.
|
|
19
27
|
* @param error - The error to add.
|
package/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export type { MCPToolResponse, MCPResourceResponse, MCPPromptResponse, } from '.
|
|
|
19
19
|
export { runQueueJob } from './wirings/queue/queue-runner.js';
|
|
20
20
|
export { runScheduledTask } from './wirings/scheduler/scheduler-runner.js';
|
|
21
21
|
export { NotFoundError } from './errors/errors.js';
|
|
22
|
+
export { PikkuError, isExpectedError } from './errors/error-handler.js';
|
|
22
23
|
export type { EventHubService } from './wirings/channel/eventhub-service.js';
|
|
23
24
|
export type { QueueService } from './wirings/queue/queue.types.js';
|
|
24
25
|
export type { JWTService } from './services/jwt-service.js';
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ export { runMCPTool, runMCPResource, runMCPPrompt, } from './wirings/mcp/mcp-run
|
|
|
11
11
|
export { runQueueJob } from './wirings/queue/queue-runner.js';
|
|
12
12
|
export { runScheduledTask } from './wirings/scheduler/scheduler-runner.js';
|
|
13
13
|
export { NotFoundError } from './errors/errors.js';
|
|
14
|
+
export { PikkuError, isExpectedError } from './errors/error-handler.js';
|
|
14
15
|
export { NoopAuditService, createInvocationAudit, resolveAuditActorFromWire, resolveAuditConfig, } from './services/audit-service.js';
|
|
15
16
|
export { createGraph } from './wirings/workflow/graph/graph-node.js';
|
|
16
17
|
export { wireAddon } from './wirings/rpc/wire-addon.js';
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import type { QueueService, JobOptions } from '../wirings/queue/queue.types.js';
|
|
2
|
+
/**
|
|
3
|
+
* In-process queue for local/dev runs. Schedules jobs on the macrotask queue
|
|
4
|
+
* (setTimeout) so dispatch is genuinely asynchronous — the same timing shape as
|
|
5
|
+
* a real queue — and redelivers a failed job up to `options.attempts` times with
|
|
6
|
+
* backoff, so a transiently-failing workflow step recovers exactly as it would
|
|
7
|
+
* on pg-boss/bullmq instead of being silently dropped on its first error.
|
|
8
|
+
*/
|
|
2
9
|
export declare class InMemoryQueueService implements QueueService {
|
|
3
10
|
readonly supportsResults = false;
|
|
4
11
|
private jobCounter;
|
|
5
12
|
add<T>(queueName: string, data: T, options?: JobOptions): Promise<string>;
|
|
13
|
+
/** Delay before the next redelivery, honoring the job's backoff policy. */
|
|
14
|
+
private backoffDelay;
|
|
6
15
|
getJob(): Promise<null>;
|
|
7
16
|
}
|
|
@@ -1,27 +1,58 @@
|
|
|
1
1
|
import { runQueueJob } from '../wirings/queue/queue-runner.js';
|
|
2
|
+
/**
|
|
3
|
+
* In-process queue for local/dev runs. Schedules jobs on the macrotask queue
|
|
4
|
+
* (setTimeout) so dispatch is genuinely asynchronous — the same timing shape as
|
|
5
|
+
* a real queue — and redelivers a failed job up to `options.attempts` times with
|
|
6
|
+
* backoff, so a transiently-failing workflow step recovers exactly as it would
|
|
7
|
+
* on pg-boss/bullmq instead of being silently dropped on its first error.
|
|
8
|
+
*/
|
|
2
9
|
export class InMemoryQueueService {
|
|
3
10
|
supportsResults = false;
|
|
4
11
|
jobCounter = 0;
|
|
5
12
|
async add(queueName, data, options) {
|
|
6
13
|
const jobId = `inmem-${++this.jobCounter}`;
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
const maxAttempts = Math.max(1, options?.attempts ?? 1);
|
|
15
|
+
let attemptsMade = 0;
|
|
16
|
+
const createdAt = new Date();
|
|
17
|
+
const runAttempt = async () => {
|
|
18
|
+
attemptsMade++;
|
|
19
|
+
const job = {
|
|
20
|
+
id: jobId,
|
|
21
|
+
queueName,
|
|
22
|
+
data,
|
|
23
|
+
status: () => 'active',
|
|
24
|
+
metadata: () => ({ attemptsMade, maxAttempts, createdAt }),
|
|
25
|
+
pikkuUserId: options?.pikkuUserId,
|
|
26
|
+
};
|
|
16
27
|
try {
|
|
17
28
|
await runQueueJob({ job });
|
|
18
29
|
}
|
|
19
30
|
catch (e) {
|
|
20
|
-
|
|
31
|
+
if (attemptsMade < maxAttempts) {
|
|
32
|
+
// Transient failure — redeliver with backoff, mirroring a real queue.
|
|
33
|
+
setTimeout(runAttempt, this.backoffDelay(options?.backoff, attemptsMade));
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
console.error(`[InMemoryQueue] Job ${jobId} on ${queueName} failed after ${attemptsMade} attempt(s):`, e.message);
|
|
37
|
+
}
|
|
21
38
|
}
|
|
22
|
-
}
|
|
39
|
+
};
|
|
40
|
+
const delay = options?.delay ?? 100 + Math.floor(Math.random() * 201);
|
|
41
|
+
setTimeout(runAttempt, delay);
|
|
23
42
|
return jobId;
|
|
24
43
|
}
|
|
44
|
+
/** Delay before the next redelivery, honoring the job's backoff policy. */
|
|
45
|
+
backoffDelay(backoff, attemptsMade) {
|
|
46
|
+
const baseDelay = typeof backoff === 'object' ? (backoff.delay ?? 50) : 50;
|
|
47
|
+
if (backoff === 'exponential' ||
|
|
48
|
+
(typeof backoff === 'object' && backoff?.type === 'exponential')) {
|
|
49
|
+
return Math.min(2 ** (attemptsMade - 1) * baseDelay, 2000);
|
|
50
|
+
}
|
|
51
|
+
if (typeof backoff === 'object' && backoff?.type === 'fixed') {
|
|
52
|
+
return backoff.delay ?? 50;
|
|
53
|
+
}
|
|
54
|
+
return 50;
|
|
55
|
+
}
|
|
25
56
|
async getJob() {
|
|
26
57
|
return null;
|
|
27
58
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PikkuWorkflowService } from '../wirings/workflow/pikku-workflow-service.js';
|
|
2
2
|
import type { SerializedError } from '../types/core.types.js';
|
|
3
|
-
import type { WorkflowPlannedStep, WorkflowRun, WorkflowRunService, WorkflowRunWire, StepState, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from '../wirings/workflow/workflow.types.js';
|
|
3
|
+
import type { WorkflowPlannedStep, WorkflowRun, WorkflowRunService, WorkflowRunWire, StepState, StepStatus, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from '../wirings/workflow/workflow.types.js';
|
|
4
4
|
/**
|
|
5
5
|
* In-memory implementation of WorkflowService for inline-only execution
|
|
6
6
|
*
|
|
@@ -35,7 +35,7 @@ export declare class InMemoryWorkflowService extends PikkuWorkflowService implem
|
|
|
35
35
|
stepName: string;
|
|
36
36
|
}>>;
|
|
37
37
|
protected updateRunStatusImpl(id: string, status: WorkflowStatus, output?: any, error?: SerializedError): Promise<void>;
|
|
38
|
-
protected insertStepStateImpl(runId: string, stepName: string, rpcName: string | null, data: any, stepOptions?: WorkflowStepOptions): Promise<StepState>;
|
|
38
|
+
protected insertStepStateImpl(runId: string, stepName: string, rpcName: string | null, data: any, stepOptions?: WorkflowStepOptions, fromStepName?: string): Promise<StepState>;
|
|
39
39
|
getStepState(runId: string, stepName: string): Promise<StepState>;
|
|
40
40
|
protected setStepRunningImpl(stepId: string): Promise<void>;
|
|
41
41
|
protected setStepScheduledImpl(stepId: string): Promise<void>;
|
|
@@ -65,6 +65,11 @@ export declare class InMemoryWorkflowService extends PikkuWorkflowService implem
|
|
|
65
65
|
branchKeys: Record<string, string>;
|
|
66
66
|
}>;
|
|
67
67
|
getNodesWithoutSteps(runId: string, nodeIds: string[]): Promise<string[]>;
|
|
68
|
+
getStepInstances(runId: string): Promise<Array<{
|
|
69
|
+
stepName: string;
|
|
70
|
+
status: StepStatus;
|
|
71
|
+
fromStepName?: string;
|
|
72
|
+
}>>;
|
|
68
73
|
getNodeResults(runId: string, nodeIds: string[]): Promise<Record<string, any>>;
|
|
69
74
|
setBranchKey(runId: string, nodeId: string, branchKey: string): Promise<void>;
|
|
70
75
|
protected setBranchTakenImpl(stepId: string, branchKey: string): Promise<void>;
|
|
@@ -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
|
/**
|
|
4
5
|
* In-memory implementation of WorkflowService for inline-only execution
|
|
5
6
|
*
|
|
@@ -78,7 +79,7 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
|
|
|
78
79
|
run.error = error;
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
|
-
async insertStepStateImpl(runId, stepName, rpcName, data, stepOptions) {
|
|
82
|
+
async insertStepStateImpl(runId, stepName, rpcName, data, stepOptions, fromStepName) {
|
|
82
83
|
const stepId = randomUUID();
|
|
83
84
|
const now = new Date();
|
|
84
85
|
const step = {
|
|
@@ -87,6 +88,7 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
|
|
|
87
88
|
attemptCount: 1,
|
|
88
89
|
retries: stepOptions?.retries,
|
|
89
90
|
retryDelay: stepOptions?.retryDelay,
|
|
91
|
+
fromStepName,
|
|
90
92
|
createdAt: now,
|
|
91
93
|
updatedAt: now,
|
|
92
94
|
stepName,
|
|
@@ -147,6 +149,7 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
|
|
|
147
149
|
name: error.name,
|
|
148
150
|
message: error.message,
|
|
149
151
|
stack: error.stack,
|
|
152
|
+
expected: isExpectedError(error),
|
|
150
153
|
};
|
|
151
154
|
step.failedAt = new Date();
|
|
152
155
|
step.updatedAt = new Date();
|
|
@@ -189,6 +192,7 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
|
|
|
189
192
|
attemptCount: failedStep.attemptCount + 1,
|
|
190
193
|
retries: failedStep.retries,
|
|
191
194
|
retryDelay: failedStep.retryDelay,
|
|
195
|
+
fromStepName: failedStep.fromStepName,
|
|
192
196
|
createdAt: now,
|
|
193
197
|
updatedAt: now,
|
|
194
198
|
stepName: stepName,
|
|
@@ -317,6 +321,20 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
|
|
|
317
321
|
}
|
|
318
322
|
return nodeIds.filter((id) => !existingSteps.has(id));
|
|
319
323
|
}
|
|
324
|
+
async getStepInstances(runId) {
|
|
325
|
+
const prefix = `${runId}:`;
|
|
326
|
+
const instances = [];
|
|
327
|
+
for (const [key, step] of this.steps.entries()) {
|
|
328
|
+
if (!key.startsWith(prefix))
|
|
329
|
+
continue;
|
|
330
|
+
instances.push({
|
|
331
|
+
stepName: key.substring(prefix.length),
|
|
332
|
+
status: step.status,
|
|
333
|
+
fromStepName: step.fromStepName,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return instances;
|
|
337
|
+
}
|
|
320
338
|
async getNodeResults(runId, nodeIds) {
|
|
321
339
|
const results = {};
|
|
322
340
|
for (const nodeId of nodeIds) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SerializedError } from '../types/core.types.js';
|
|
2
2
|
import type { WorkflowRun, WorkflowPlannedStep, WorkflowRunWire, WorkflowRunStatus, StepState, WorkflowStatus, WorkflowVersionStatus } from '../wirings/workflow/workflow.types.js';
|
|
3
|
+
import type { RunTimeline, ReconstructedRunState } from '../wirings/workflow/run-timeline.js';
|
|
3
4
|
/**
|
|
4
5
|
* Interface for workflow orchestration
|
|
5
6
|
* Handles workflow execution, replay, orchestration logic, and run-level state
|
|
@@ -14,6 +15,8 @@ export interface WorkflowService {
|
|
|
14
15
|
getRunHistory(runId: string): Promise<Array<StepState & {
|
|
15
16
|
stepName: string;
|
|
16
17
|
}>>;
|
|
18
|
+
getRunTimeline(id: string): Promise<RunTimeline | null>;
|
|
19
|
+
reconstructRunStateAt(id: string, at?: number | Date): Promise<ReconstructedRunState | null>;
|
|
17
20
|
updateRunStatus(id: string, status: WorkflowStatus, output?: any, error?: SerializedError): Promise<void>;
|
|
18
21
|
withRunLock<T>(id: string, fn: () => Promise<T>): Promise<T>;
|
|
19
22
|
close(): Promise<void>;
|
|
@@ -175,6 +175,41 @@ export function defineServiceTests(config) {
|
|
|
175
175
|
assert.equal(retried.error, undefined);
|
|
176
176
|
assert.equal(retried.result, undefined);
|
|
177
177
|
});
|
|
178
|
+
test('getRunStatus reports per-step duration and attempts', async () => {
|
|
179
|
+
const runId = await service.createRun('status-timing-workflow', {}, false, 'hash-timing', wire);
|
|
180
|
+
const step = await service.insertStepState(runId, 'timed-step', 'myRpc', {});
|
|
181
|
+
await service.setStepRunning(step.stepId);
|
|
182
|
+
await service.setStepResult(step.stepId, { ok: true });
|
|
183
|
+
const status = await service.getRunStatus(runId);
|
|
184
|
+
assert.ok(status);
|
|
185
|
+
const timed = status.steps.find((s) => s.name === 'timed-step');
|
|
186
|
+
assert.ok(timed, 'step appears in run status');
|
|
187
|
+
// running→succeeded must stamp runningAt/succeededAt so a duration is
|
|
188
|
+
// computable. Regression guard: the kysely store previously updated
|
|
189
|
+
// only the status on transitions, leaving the timestamps null and
|
|
190
|
+
// duration permanently undefined.
|
|
191
|
+
assert.ok(timed.duration !== undefined, 'duration is computed from stamped transition timestamps');
|
|
192
|
+
assert.ok(timed.duration >= 0, 'duration is non-negative');
|
|
193
|
+
assert.equal(timed.attempts, 1, 'single attempt');
|
|
194
|
+
});
|
|
195
|
+
test('getRunStatus surfaces retried attempt count', async () => {
|
|
196
|
+
const runId = await service.createRun('status-retry-workflow', {}, false, 'hash-retry-status', wire);
|
|
197
|
+
const step = await service.insertStepState(runId, 'flaky-step', 'myRpc', {}, { retries: 2 });
|
|
198
|
+
await service.setStepRunning(step.stepId);
|
|
199
|
+
await service.setStepError(step.stepId, new Error('first try'));
|
|
200
|
+
// Guarantee the retry's history row sorts after the failed attempt so
|
|
201
|
+
// getRunStatus picks the latest attempt (it tie-breaks on timestamps).
|
|
202
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
203
|
+
const retry = await service.createRetryAttempt(step.stepId, 'pending');
|
|
204
|
+
await service.setStepRunning(retry.stepId);
|
|
205
|
+
await service.setStepResult(retry.stepId, { ok: true });
|
|
206
|
+
const status = await service.getRunStatus(runId);
|
|
207
|
+
assert.ok(status);
|
|
208
|
+
const flaky = status.steps.find((s) => s.name === 'flaky-step');
|
|
209
|
+
assert.ok(flaky, 'retried step collapses to a single status entry');
|
|
210
|
+
assert.equal(flaky.status, 'succeeded', 'latest attempt wins');
|
|
211
|
+
assert.equal(flaky.attempts, 2, 'attempt count surfaces in status');
|
|
212
|
+
});
|
|
178
213
|
test('getNodesWithoutSteps', async () => {
|
|
179
214
|
const runId = await service.createRun('graph-workflow', {}, false, 'hash-7', wire);
|
|
180
215
|
await service.insertStepState(runId, 'existing-node', 'rpc', {});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NotFoundError } from '../../errors/errors.js';
|
|
2
|
+
import { isExpectedError } from '../../errors/error-handler.js';
|
|
2
3
|
import { addFunction, runPikkuFunc } from '../../function/function-runner.js';
|
|
3
4
|
import { pikkuState } from '../../pikku-state.js';
|
|
4
5
|
import { PikkuSessionService, createMiddlewareSessionWireProps, } from '../../services/user-session-service.js';
|
|
@@ -361,9 +362,17 @@ export async function executeCLI({ programName, args, createConfig, createSingle
|
|
|
361
362
|
if (error instanceof CLIError) {
|
|
362
363
|
throw error;
|
|
363
364
|
}
|
|
364
|
-
//
|
|
365
|
-
|
|
366
|
-
//
|
|
365
|
+
// An expected failure (a deliberate PikkuError, e.g. a build gate
|
|
366
|
+
// tripping) — its message says everything, so print that alone (no
|
|
367
|
+
// `Error:` prefix). Anything else is an uncaught error: log the object so
|
|
368
|
+
// its stack shows.
|
|
369
|
+
if (isExpectedError(error)) {
|
|
370
|
+
console.error(error.message);
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
console.error('Error:', error);
|
|
374
|
+
}
|
|
375
|
+
// Show stack trace in verbose mode (even for expected errors).
|
|
367
376
|
if (args.includes('--verbose') || args.includes('-v')) {
|
|
368
377
|
console.error('Stack trace:', error.stack);
|
|
369
378
|
}
|
|
@@ -271,10 +271,28 @@ export type WorkflowStepMeta = RpcStepMeta | BranchStepMeta | ParallelGroupStepM
|
|
|
271
271
|
export interface WorkflowStepWire {
|
|
272
272
|
/** The workflow run ID */
|
|
273
273
|
runId: string;
|
|
274
|
-
/**
|
|
274
|
+
/**
|
|
275
|
+
* The step row ID. Whether it stays the same or is minted fresh per attempt is
|
|
276
|
+
* STORE-SPECIFIC (in-memory mints a new one each attempt; the SQL store reuses
|
|
277
|
+
* the row) — do NOT use it as a dedupe key. Use `invocationId`.
|
|
278
|
+
*/
|
|
275
279
|
stepId: string;
|
|
280
|
+
/**
|
|
281
|
+
* Stable identity of this step invocation — the idempotency / dedupe key.
|
|
282
|
+
* Identical across every retry of the same call (derived from runId + step
|
|
283
|
+
* name) regardless of storage backend, so a step can `ON CONFLICT (invocationId)`
|
|
284
|
+
* or pass it as an external idempotency key and have retries collapse onto the
|
|
285
|
+
* first attempt.
|
|
286
|
+
*/
|
|
287
|
+
invocationId: string;
|
|
276
288
|
/** Current attempt number (1-indexed, increments on retry) */
|
|
277
289
|
attemptCount: number;
|
|
290
|
+
/**
|
|
291
|
+
* Invocation ID of the predecessor step this one was reached from (the walked
|
|
292
|
+
* transition/edge). Undefined for entry steps. Lets a step know its origin —
|
|
293
|
+
* e.g. in a cyclic graph `a → b → a → c`, the second `a` carries `b`'s id.
|
|
294
|
+
*/
|
|
295
|
+
fromInvocationId?: string;
|
|
278
296
|
}
|
|
279
297
|
/**
|
|
280
298
|
* Workflow wire object for DSL workflows
|