@pikku/core 0.12.36 → 0.12.38
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 +67 -0
- package/dist/services/in-memory-workflow-service.d.ts +7 -2
- package/dist/services/in-memory-workflow-service.js +17 -1
- package/dist/services/meta-service.d.ts +4 -0
- package/dist/services/meta-service.js +9 -0
- package/dist/wirings/gateway/gateway.types.d.ts +4 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +19 -1
- package/dist/wirings/workflow/graph/graph-runner.js +117 -45
- package/dist/wirings/workflow/index.d.ts +2 -1
- package/dist/wirings/workflow/index.js +2 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +63 -5
- package/dist/wirings/workflow/pikku-workflow-service.js +197 -66
- 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 +5 -0
- package/package.json +2 -1
- package/src/dev/hot-reload.test.ts +5 -0
- package/src/services/in-memory-workflow-service.ts +25 -1
- package/src/services/meta-service.ts +15 -0
- package/src/wirings/gateway/gateway.types.ts +6 -1
- 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 +87 -3
- package/src/wirings/workflow/graph/graph-runner.ts +158 -56
- package/src/wirings/workflow/index.ts +3 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +264 -86
- 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 +5 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -32,6 +32,15 @@ import { continueGraph, executeGraphStep, runWorkflowGraph, runFromMeta, } from
|
|
|
32
32
|
import { PikkuError, addError } from '../../errors/error-handler.js';
|
|
33
33
|
import { RPCNotFoundError } from '../rpc/rpc-runner.js';
|
|
34
34
|
import { ChildWorkflowStartedException } from './graph/graph-runner.js';
|
|
35
|
+
import { deriveInvocationId } from './workflow-invocation-id.js';
|
|
36
|
+
/**
|
|
37
|
+
* Default number of retries for a workflow step when none is specified. The
|
|
38
|
+
* workflow — not the queue — owns retry policy; a step inherits this unless it
|
|
39
|
+
* sets its own `retries` (including `retries: 0` to opt out entirely). Picked >0
|
|
40
|
+
* so a transient failure (a DB blip, a downstream restart, a deploy) is ridden
|
|
41
|
+
* out by default; safe because every step gets a stable `invocationId` to dedupe on.
|
|
42
|
+
*/
|
|
43
|
+
export const DEFAULT_STEP_RETRIES = 5;
|
|
35
44
|
/**
|
|
36
45
|
* Exception thrown when workflow needs to pause for async step
|
|
37
46
|
*/
|
|
@@ -71,6 +80,24 @@ export class WorkflowSuspendedException extends Error {
|
|
|
71
80
|
this.name = 'WorkflowSuspendedException';
|
|
72
81
|
}
|
|
73
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Thrown when a step (or the orchestrator) could not be enqueued — the queue
|
|
85
|
+
* itself failed (e.g. pg-boss is momentarily down), NOT the step's own logic.
|
|
86
|
+
* This is transient infrastructure failure: the run is left untouched (the step
|
|
87
|
+
* stays `pending`, the run stays running) and the orchestrator job is rethrown
|
|
88
|
+
* so the queue redelivers it and the workflow replays from its snapshot. Treat
|
|
89
|
+
* it as non-terminal — never mark the run `failed` for it.
|
|
90
|
+
*/
|
|
91
|
+
export class WorkflowDispatchException extends Error {
|
|
92
|
+
runId;
|
|
93
|
+
stepName;
|
|
94
|
+
constructor(runId, stepName, options) {
|
|
95
|
+
super(`Failed to dispatch workflow step '${stepName}' (run ${runId})`, options);
|
|
96
|
+
this.runId = runId;
|
|
97
|
+
this.stepName = stepName;
|
|
98
|
+
this.name = 'WorkflowDispatchException';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
74
101
|
/**
|
|
75
102
|
* Error class for workflow not found
|
|
76
103
|
*/
|
|
@@ -309,8 +336,8 @@ export class PikkuWorkflowService {
|
|
|
309
336
|
* @param stepOptions - Step options (retries, retryDelay)
|
|
310
337
|
* @returns Step state with generated stepId
|
|
311
338
|
*/
|
|
312
|
-
async insertStepState(runId, stepName, rpcName, data, stepOptions) {
|
|
313
|
-
const step = await this.insertStepStateImpl(runId, stepName, rpcName, data, stepOptions);
|
|
339
|
+
async insertStepState(runId, stepName, rpcName, data, stepOptions, fromStepName) {
|
|
340
|
+
const step = await this.insertStepStateImpl(runId, stepName, rpcName, data, stepOptions, fromStepName);
|
|
314
341
|
await this.safeMirror(() => this.mirror.insertStepState(runId, { ...step, stepName, rpcName, data }));
|
|
315
342
|
return step;
|
|
316
343
|
}
|
|
@@ -423,29 +450,38 @@ export class PikkuWorkflowService {
|
|
|
423
450
|
const run = await this.getRun(runId);
|
|
424
451
|
workflowName = run?.workflow;
|
|
425
452
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
453
|
+
// Carry an explicit retry policy on the orchestrator job too. Orchestrator
|
|
454
|
+
// runs are idempotent (they replay from the snapshot, returning cached step
|
|
455
|
+
// results), so redelivery is always safe — and it's what lets a transient
|
|
456
|
+
// dispatch/infra failure recover: the job is rethrown and retried instead of
|
|
457
|
+
// the run hanging. Passing `attempts` per-job overrides the queue default, so
|
|
458
|
+
// this holds even when the orchestrator queue is configured `retry_limit 0`.
|
|
459
|
+
await queueService.add(this.getOrchestratorQueueName(workflowName), { runId }, this.resolveStepJobOptions());
|
|
429
460
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
461
|
+
/**
|
|
462
|
+
* Resolve a step's retry policy into queue job options. The workflow is the
|
|
463
|
+
* sole source of truth for retries: an explicitly-set `retries` (including 0)
|
|
464
|
+
* is always honored, an unset one defaults to {@link DEFAULT_STEP_RETRIES},
|
|
465
|
+
* and we ALWAYS pass `attempts` so the queue can never fall back to its own
|
|
466
|
+
* default — which would re-run a step the workflow said not to retry. Backoff
|
|
467
|
+
* defaults to exponential whenever there's at least one retry, so retries ride
|
|
468
|
+
* out a transient outage instead of firing instantly.
|
|
469
|
+
*/
|
|
470
|
+
resolveStepJobOptions(stepOptions) {
|
|
471
|
+
const retries = stepOptions?.retries ?? DEFAULT_STEP_RETRIES;
|
|
433
472
|
const retryDelay = stepOptions?.retryDelay;
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
: undefined,
|
|
447
|
-
}
|
|
448
|
-
: undefined);
|
|
473
|
+
const backoff = typeof retryDelay === 'number'
|
|
474
|
+
? { type: 'fixed', delay: retryDelay }
|
|
475
|
+
: retryDelay === 'exponential'
|
|
476
|
+
? 'exponential'
|
|
477
|
+
: retries > 0
|
|
478
|
+
? 'exponential'
|
|
479
|
+
: undefined;
|
|
480
|
+
return { attempts: retries + 1, ...(backoff ? { backoff } : {}) };
|
|
481
|
+
}
|
|
482
|
+
async queueStepWorker(runId, stepName, rpcName, data, stepOptions, fromStepName) {
|
|
483
|
+
const queueService = this.verifyQueueService();
|
|
484
|
+
await queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })), this.resolveStepJobOptions(stepOptions));
|
|
449
485
|
}
|
|
450
486
|
/**
|
|
451
487
|
* Execute a workflow sleep step completion
|
|
@@ -483,7 +519,7 @@ export class PikkuWorkflowService {
|
|
|
483
519
|
* @returns true if dispatch was async (caller should pause), false to fall
|
|
484
520
|
* through to the inline execution path.
|
|
485
521
|
*/
|
|
486
|
-
async dispatchStep(runId, stepName, rpcName, data, stepOptions) {
|
|
522
|
+
async dispatchStep(runId, stepName, rpcName, data, stepOptions, fromStepName) {
|
|
487
523
|
// Step execution is decided purely by the function's `inline` flag (default
|
|
488
524
|
// true). Only a function explicitly marked `inline: false` dispatches via
|
|
489
525
|
// the queue. The run-level inline state is intentionally NOT consulted
|
|
@@ -505,21 +541,16 @@ export class PikkuWorkflowService {
|
|
|
505
541
|
getSingletonServices()?.logger.warn(`Workflow step '${stepName}' (function '${rpcName}') is marked 'inline: false' but no queue service is configured — running it inline instead of dispatching to a queue.`);
|
|
506
542
|
return false;
|
|
507
543
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
? { type: 'fixed', delay: retryDelay }
|
|
519
|
-
: retryDelay === 'exponential'
|
|
520
|
-
? 'exponential'
|
|
521
|
-
: undefined,
|
|
522
|
-
});
|
|
544
|
+
try {
|
|
545
|
+
await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })), this.resolveStepJobOptions(stepOptions));
|
|
546
|
+
}
|
|
547
|
+
catch (cause) {
|
|
548
|
+
// The queue is down/unreachable — NOT a step failure. Surface it as a
|
|
549
|
+
// transient dispatch error so the caller leaves the step `pending` and the
|
|
550
|
+
// orchestrator job is retried (replayed from snapshot) rather than the run
|
|
551
|
+
// being marked failed. `add` already throws on failure in every adapter.
|
|
552
|
+
throw new WorkflowDispatchException(runId, stepName, { cause });
|
|
553
|
+
}
|
|
523
554
|
return true;
|
|
524
555
|
}
|
|
525
556
|
/**
|
|
@@ -587,7 +618,9 @@ export class PikkuWorkflowService {
|
|
|
587
618
|
catch (error) {
|
|
588
619
|
if (error.name !== 'WorkflowAsyncException' &&
|
|
589
620
|
error.name !== 'WorkflowCancelledException' &&
|
|
590
|
-
error.name !== 'WorkflowSuspendedException'
|
|
621
|
+
error.name !== 'WorkflowSuspendedException' &&
|
|
622
|
+
// Transient queue failure — leave the run resumable, don't fail it.
|
|
623
|
+
error.name !== 'WorkflowDispatchException') {
|
|
591
624
|
await this.updateRunStatus(runId, 'failed', undefined, {
|
|
592
625
|
name: error.name,
|
|
593
626
|
message: error.message,
|
|
@@ -596,6 +629,11 @@ export class PikkuWorkflowService {
|
|
|
596
629
|
getSingletonServices().logger.error(`Workflow ${name} (run ${runId}) failed:`, error);
|
|
597
630
|
throw error;
|
|
598
631
|
}
|
|
632
|
+
if (error.name === 'WorkflowDispatchException') {
|
|
633
|
+
// Rethrow so the caller (poll loop / starter) sees the transient
|
|
634
|
+
// failure; the run stays running and can be resumed.
|
|
635
|
+
throw error;
|
|
636
|
+
}
|
|
599
637
|
}
|
|
600
638
|
finally {
|
|
601
639
|
this.inlineRuns.delete(runId);
|
|
@@ -626,7 +664,48 @@ export class PikkuWorkflowService {
|
|
|
626
664
|
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
627
665
|
}
|
|
628
666
|
}
|
|
667
|
+
// Per-run, per-replay ordinal counters (runId → stepName → count).
|
|
668
|
+
stepOrdinals = new Map();
|
|
669
|
+
// Previous step key reached in the current DSL walk (runId → stepName), so a
|
|
670
|
+
// new step records where it came from. Rebuilt each replay alongside ordinals.
|
|
671
|
+
stepLineage = new Map();
|
|
672
|
+
resetStepOrdinals(runId) {
|
|
673
|
+
this.stepOrdinals.set(runId, new Map());
|
|
674
|
+
this.stepLineage.delete(runId);
|
|
675
|
+
}
|
|
676
|
+
/** The step the DSL walk last reached (the predecessor for the next step). */
|
|
677
|
+
lastStepName(runId) {
|
|
678
|
+
return this.stepLineage.get(runId);
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Physical, replay-stable key for the Nth reach of `logicalStepName` in a run:
|
|
682
|
+
* bare name for the first reach (ordinal 0, unchanged behavior), `name#N` for
|
|
683
|
+
* repeats — so the same literal step name can be invoked multiple times without
|
|
684
|
+
* the rows clobbering. Deterministic given a deterministic DSL body.
|
|
685
|
+
*/
|
|
686
|
+
nextStepKey(runId, logicalStepName) {
|
|
687
|
+
let perRun = this.stepOrdinals.get(runId);
|
|
688
|
+
if (!perRun) {
|
|
689
|
+
perRun = new Map();
|
|
690
|
+
this.stepOrdinals.set(runId, perRun);
|
|
691
|
+
}
|
|
692
|
+
const ordinal = perRun.get(logicalStepName) ?? 0;
|
|
693
|
+
perRun.set(logicalStepName, ordinal + 1);
|
|
694
|
+
const stepName = ordinal === 0 ? logicalStepName : `${logicalStepName}#${ordinal}`;
|
|
695
|
+
this.stepLineage.set(runId, stepName);
|
|
696
|
+
return stepName;
|
|
697
|
+
}
|
|
629
698
|
async runWorkflowJob(runId, rpcService) {
|
|
699
|
+
// Fresh ordinal counters per replay so step keys are deterministic.
|
|
700
|
+
this.resetStepOrdinals(runId);
|
|
701
|
+
try {
|
|
702
|
+
await this.runWorkflowJobInner(runId, rpcService);
|
|
703
|
+
}
|
|
704
|
+
finally {
|
|
705
|
+
this.stepOrdinals.delete(runId);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
async runWorkflowJobInner(runId, rpcService) {
|
|
630
709
|
const run = await this.getRun(runId);
|
|
631
710
|
if (!run) {
|
|
632
711
|
throw new WorkflowRunNotFoundError(runId);
|
|
@@ -799,10 +878,22 @@ export class PikkuWorkflowService {
|
|
|
799
878
|
const workflowMeta = meta[run.workflow];
|
|
800
879
|
const isGraphWorkflow = workflowMeta?.source === 'graph' ||
|
|
801
880
|
workflowMeta?.source === 'dynamic-workflow';
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
881
|
+
// Map the physical step key back to its logical node: a revisit instance
|
|
882
|
+
// is `node#N` (ordinal), which isn't a literal key in `nodes`.
|
|
883
|
+
let graphNodeId;
|
|
884
|
+
if (isGraphWorkflow && workflowMeta?.nodes) {
|
|
885
|
+
if (stepName in workflowMeta.nodes) {
|
|
886
|
+
graphNodeId = stepName;
|
|
887
|
+
}
|
|
888
|
+
else {
|
|
889
|
+
const hash = stepName.lastIndexOf('#');
|
|
890
|
+
const base = hash > 0 ? stepName.slice(0, hash) : undefined;
|
|
891
|
+
if (base && base in workflowMeta.nodes)
|
|
892
|
+
graphNodeId = base;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (graphNodeId) {
|
|
896
|
+
result = await executeGraphStep(this, rpcService, runId, stepState.stepId, graphNodeId, rpcName, data, run.workflow);
|
|
806
897
|
}
|
|
807
898
|
else {
|
|
808
899
|
// Check if rpcName refers to a sub-workflow
|
|
@@ -837,7 +928,11 @@ export class PikkuWorkflowService {
|
|
|
837
928
|
workflowStep: {
|
|
838
929
|
runId,
|
|
839
930
|
stepId: stepState.stepId,
|
|
931
|
+
invocationId: deriveInvocationId(runId, stepName),
|
|
840
932
|
attemptCount: stepState.attemptCount,
|
|
933
|
+
fromInvocationId: stepState.fromStepName
|
|
934
|
+
? deriveInvocationId(runId, stepState.fromStepName)
|
|
935
|
+
: undefined,
|
|
841
936
|
},
|
|
842
937
|
});
|
|
843
938
|
}
|
|
@@ -862,7 +957,7 @@ export class PikkuWorkflowService {
|
|
|
862
957
|
}
|
|
863
958
|
// Store error and mark failed
|
|
864
959
|
await this.setStepError(stepState.stepId, error);
|
|
865
|
-
const maxAttempts = (stepState.retries ??
|
|
960
|
+
const maxAttempts = (stepState.retries ?? DEFAULT_STEP_RETRIES) + 1;
|
|
866
961
|
const retriesExhausted = stepState.attemptCount >= maxAttempts;
|
|
867
962
|
if (retriesExhausted) {
|
|
868
963
|
// No more retries - resume orchestrator to mark workflow as failed
|
|
@@ -888,6 +983,13 @@ export class PikkuWorkflowService {
|
|
|
888
983
|
error.name === 'WorkflowSuspendedException') {
|
|
889
984
|
return;
|
|
890
985
|
}
|
|
986
|
+
if (error.name === 'WorkflowDispatchException') {
|
|
987
|
+
// Transient: the queue was unreachable, not a workflow failure. Leave the
|
|
988
|
+
// run running and rethrow so the orchestrator job is redelivered and the
|
|
989
|
+
// workflow replays from its snapshot. Do NOT mark the run failed.
|
|
990
|
+
getSingletonServices().logger.warn(`Workflow run ${runId} could not dispatch a step (queue unavailable); leaving run for orchestrator retry`, error);
|
|
991
|
+
throw error;
|
|
992
|
+
}
|
|
891
993
|
await this.updateRunStatus(runId, 'failed', undefined, {
|
|
892
994
|
message: error.message,
|
|
893
995
|
stack: error.stack,
|
|
@@ -902,7 +1004,18 @@ export class PikkuWorkflowService {
|
|
|
902
1004
|
}
|
|
903
1005
|
return getSingletonServices().queueService;
|
|
904
1006
|
}
|
|
905
|
-
async rpcStep(runId,
|
|
1007
|
+
async rpcStep(runId, logicalStepName, rpcName, data, rpcService, stepOptions) {
|
|
1008
|
+
// Capture the predecessor before nextStepKey advances the lineage to us.
|
|
1009
|
+
const fromStepName = this.lastStepName(runId);
|
|
1010
|
+
const stepName = this.nextStepKey(runId, logicalStepName);
|
|
1011
|
+
// Resolve the retry policy ONCE here so the value persisted on the step
|
|
1012
|
+
// (which drives `retriesExhausted`) is the same one the queue dispatch turns
|
|
1013
|
+
// into `attempts`. Without this the queue could retry N times while the
|
|
1014
|
+
// engine thinks retries are already exhausted (or vice-versa).
|
|
1015
|
+
const resolvedStepOptions = {
|
|
1016
|
+
retries: stepOptions?.retries ?? DEFAULT_STEP_RETRIES,
|
|
1017
|
+
retryDelay: stepOptions?.retryDelay,
|
|
1018
|
+
};
|
|
906
1019
|
// Check if step already exists
|
|
907
1020
|
let stepState;
|
|
908
1021
|
try {
|
|
@@ -910,7 +1023,7 @@ export class PikkuWorkflowService {
|
|
|
910
1023
|
}
|
|
911
1024
|
catch {
|
|
912
1025
|
// Step doesn't exist - create it
|
|
913
|
-
stepState = await this.insertStepState(runId, stepName, rpcName, data,
|
|
1026
|
+
stepState = await this.insertStepState(runId, stepName, rpcName, data, resolvedStepOptions, fromStepName);
|
|
914
1027
|
}
|
|
915
1028
|
if (stepState.status === 'succeeded') {
|
|
916
1029
|
// Return cached result
|
|
@@ -930,18 +1043,22 @@ export class PikkuWorkflowService {
|
|
|
930
1043
|
// Step is already scheduled, pause workflow
|
|
931
1044
|
throw new WorkflowAsyncException(runId, stepName);
|
|
932
1045
|
}
|
|
933
|
-
// Step is pending - schedule it
|
|
934
|
-
await this.setStepScheduled(stepState.stepId);
|
|
935
1046
|
// Hand off to subclass-overridable transport. Default behavior enqueues
|
|
936
1047
|
// via the queue service; DO-style subclasses RPC to a step worker.
|
|
937
|
-
|
|
1048
|
+
// Dispatch BEFORE marking the step `scheduled`: if the queue is down,
|
|
1049
|
+
// dispatchStep throws WorkflowDispatchException and the step stays `pending`,
|
|
1050
|
+
// so the orchestrator's next replay re-dispatches it. Marking `scheduled`
|
|
1051
|
+
// first would strand the step (replay sees `scheduled`, pauses, never
|
|
1052
|
+
// re-enqueues the job that was never created).
|
|
1053
|
+
const dispatched = await this.dispatchStep(runId, stepName, rpcName, data, resolvedStepOptions, fromStepName);
|
|
938
1054
|
if (dispatched) {
|
|
1055
|
+
await this.setStepScheduled(stepState.stepId);
|
|
939
1056
|
throw new WorkflowAsyncException(runId, stepName);
|
|
940
1057
|
}
|
|
941
1058
|
{
|
|
942
1059
|
// Inline (no transport available) - execute locally with retry loop
|
|
943
|
-
const retries =
|
|
944
|
-
const retryDelay =
|
|
1060
|
+
const retries = resolvedStepOptions.retries ?? this.getConfig().retries;
|
|
1061
|
+
const retryDelay = resolvedStepOptions.retryDelay;
|
|
945
1062
|
let currentStepState = stepState;
|
|
946
1063
|
while (true) {
|
|
947
1064
|
try {
|
|
@@ -982,7 +1099,11 @@ export class PikkuWorkflowService {
|
|
|
982
1099
|
workflowStep: {
|
|
983
1100
|
runId,
|
|
984
1101
|
stepId: currentStepState.stepId,
|
|
1102
|
+
invocationId: deriveInvocationId(runId, stepName),
|
|
985
1103
|
attemptCount: currentStepState.attemptCount,
|
|
1104
|
+
fromInvocationId: currentStepState.fromStepName
|
|
1105
|
+
? deriveInvocationId(runId, currentStepState.fromStepName)
|
|
1106
|
+
: undefined,
|
|
986
1107
|
},
|
|
987
1108
|
});
|
|
988
1109
|
}
|
|
@@ -1017,7 +1138,9 @@ export class PikkuWorkflowService {
|
|
|
1017
1138
|
}
|
|
1018
1139
|
}
|
|
1019
1140
|
}
|
|
1020
|
-
async inlineStep(runId,
|
|
1141
|
+
async inlineStep(runId, logicalStepName, fn, stepOptions) {
|
|
1142
|
+
const fromStepName = this.lastStepName(runId);
|
|
1143
|
+
const stepName = this.nextStepKey(runId, logicalStepName);
|
|
1021
1144
|
// Check if step already exists
|
|
1022
1145
|
let stepState;
|
|
1023
1146
|
try {
|
|
@@ -1025,7 +1148,7 @@ export class PikkuWorkflowService {
|
|
|
1025
1148
|
}
|
|
1026
1149
|
catch {
|
|
1027
1150
|
// Step doesn't exist - create it (inline, no RPC)
|
|
1028
|
-
stepState = await this.insertStepState(runId, stepName, null, null, stepOptions);
|
|
1151
|
+
stepState = await this.insertStepState(runId, stepName, null, null, stepOptions, fromStepName);
|
|
1029
1152
|
}
|
|
1030
1153
|
if (stepState.status === 'succeeded') {
|
|
1031
1154
|
// Return cached result
|
|
@@ -1090,7 +1213,9 @@ export class PikkuWorkflowService {
|
|
|
1090
1213
|
}
|
|
1091
1214
|
}
|
|
1092
1215
|
}
|
|
1093
|
-
async sleepStep(runId,
|
|
1216
|
+
async sleepStep(runId, logicalStepName, duration) {
|
|
1217
|
+
const fromStepName = this.lastStepName(runId);
|
|
1218
|
+
const stepName = this.nextStepKey(runId, logicalStepName);
|
|
1094
1219
|
// Check if step already exists
|
|
1095
1220
|
let stepState;
|
|
1096
1221
|
try {
|
|
@@ -1098,9 +1223,7 @@ export class PikkuWorkflowService {
|
|
|
1098
1223
|
}
|
|
1099
1224
|
catch {
|
|
1100
1225
|
// Step doesn't exist - create it (sleep step, no RPC)
|
|
1101
|
-
stepState = await this.insertStepState(runId, stepName, null, {
|
|
1102
|
-
duration,
|
|
1103
|
-
});
|
|
1226
|
+
stepState = await this.insertStepState(runId, stepName, null, { duration }, undefined, fromStepName);
|
|
1104
1227
|
}
|
|
1105
1228
|
if (stepState.status === 'succeeded') {
|
|
1106
1229
|
// Sleep already completed, return immediately
|
|
@@ -1110,13 +1233,20 @@ export class PikkuWorkflowService {
|
|
|
1110
1233
|
// Sleep is already scheduled, pause workflow
|
|
1111
1234
|
throw new WorkflowAsyncException(runId, stepName);
|
|
1112
1235
|
}
|
|
1113
|
-
// Step is pending - schedule it
|
|
1114
|
-
await this.setStepScheduled(stepState.stepId);
|
|
1115
1236
|
// Hand off to subclass-overridable transport. Default behavior schedules
|
|
1116
1237
|
// a delayed sleeper RPC via the scheduler service; DO-style subclasses
|
|
1117
|
-
// override to use native timer primitives (e.g. setAlarm).
|
|
1118
|
-
|
|
1238
|
+
// override to use native timer primitives (e.g. setAlarm). Schedule BEFORE
|
|
1239
|
+
// marking `scheduled` so a scheduler outage leaves the step `pending` for
|
|
1240
|
+
// re-scheduling on replay instead of stranding it (see rpcStep).
|
|
1241
|
+
let scheduled;
|
|
1242
|
+
try {
|
|
1243
|
+
scheduled = await this.scheduleSleep(runId, stepState.stepId, duration);
|
|
1244
|
+
}
|
|
1245
|
+
catch (cause) {
|
|
1246
|
+
throw new WorkflowDispatchException(runId, stepName, { cause });
|
|
1247
|
+
}
|
|
1119
1248
|
if (scheduled) {
|
|
1249
|
+
await this.setStepScheduled(stepState.stepId);
|
|
1120
1250
|
throw new WorkflowAsyncException(runId, stepName);
|
|
1121
1251
|
}
|
|
1122
1252
|
// Inline mode - use setTimeout with actual duration
|
|
@@ -1138,7 +1268,8 @@ export class PikkuWorkflowService {
|
|
|
1138
1268
|
return `__workflow_suspend:${reason}`;
|
|
1139
1269
|
}
|
|
1140
1270
|
async suspendStep(runId, reason) {
|
|
1141
|
-
const
|
|
1271
|
+
const fromStepName = this.lastStepName(runId);
|
|
1272
|
+
const suspendStepName = this.nextStepKey(runId, this.getSuspendStepName(reason));
|
|
1142
1273
|
await this.withStepLock(runId, suspendStepName, async () => {
|
|
1143
1274
|
let stepState;
|
|
1144
1275
|
try {
|
|
@@ -1147,12 +1278,12 @@ export class PikkuWorkflowService {
|
|
|
1147
1278
|
catch {
|
|
1148
1279
|
stepState = await this.insertStepState(runId, suspendStepName, 'pikkuWorkflowSuspend', {
|
|
1149
1280
|
reason,
|
|
1150
|
-
});
|
|
1281
|
+
}, undefined, fromStepName);
|
|
1151
1282
|
}
|
|
1152
1283
|
if (!stepState.stepId) {
|
|
1153
1284
|
stepState = await this.insertStepState(runId, suspendStepName, 'pikkuWorkflowSuspend', {
|
|
1154
1285
|
reason,
|
|
1155
|
-
});
|
|
1286
|
+
}, undefined, fromStepName);
|
|
1156
1287
|
}
|
|
1157
1288
|
if (stepState.status === 'succeeded') {
|
|
1158
1289
|
return;
|
|
@@ -1206,7 +1337,7 @@ export class PikkuWorkflowService {
|
|
|
1206
1337
|
const singletonServices = getSingletonServices();
|
|
1207
1338
|
const workflow = singletonServices.config?.workflow;
|
|
1208
1339
|
return {
|
|
1209
|
-
retries: workflow?.retries ??
|
|
1340
|
+
retries: workflow?.retries ?? DEFAULT_STEP_RETRIES,
|
|
1210
1341
|
retryDelay: workflow?.retryDelay ?? 0,
|
|
1211
1342
|
orchestratorQueueName: workflow?.orchestratorQueueName ?? 'pikku-workflow-orchestrator',
|
|
1212
1343
|
stepWorkerQueueName: workflow?.stepWorkerQueueName ?? 'pikku-workflow-step-worker',
|
|
@@ -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 */
|
|
@@ -269,6 +272,8 @@ export type WorkflowStepInput = {
|
|
|
269
272
|
stepName: string;
|
|
270
273
|
rpcName: string;
|
|
271
274
|
data: any;
|
|
275
|
+
/** Predecessor step name (the walked transition); undefined for entry steps. */
|
|
276
|
+
fromStepName?: string;
|
|
272
277
|
};
|
|
273
278
|
export type WorkflowOrchestratorInput = {
|
|
274
279
|
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.38",
|
|
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
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
WorkflowRunService,
|
|
8
8
|
WorkflowRunWire,
|
|
9
9
|
StepState,
|
|
10
|
+
StepStatus,
|
|
10
11
|
WorkflowStatus,
|
|
11
12
|
WorkflowVersionStatus,
|
|
12
13
|
WorkflowStepOptions,
|
|
@@ -140,7 +141,8 @@ export class InMemoryWorkflowService
|
|
|
140
141
|
stepName: string,
|
|
141
142
|
rpcName: string | null,
|
|
142
143
|
data: any,
|
|
143
|
-
stepOptions?: WorkflowStepOptions
|
|
144
|
+
stepOptions?: WorkflowStepOptions,
|
|
145
|
+
fromStepName?: string
|
|
144
146
|
): Promise<StepState> {
|
|
145
147
|
const stepId = randomUUID()
|
|
146
148
|
const now = new Date()
|
|
@@ -151,6 +153,7 @@ export class InMemoryWorkflowService
|
|
|
151
153
|
attemptCount: 1,
|
|
152
154
|
retries: stepOptions?.retries,
|
|
153
155
|
retryDelay: stepOptions?.retryDelay,
|
|
156
|
+
fromStepName,
|
|
154
157
|
createdAt: now,
|
|
155
158
|
updatedAt: now,
|
|
156
159
|
stepName,
|
|
@@ -281,6 +284,7 @@ export class InMemoryWorkflowService
|
|
|
281
284
|
attemptCount: failedStep.attemptCount + 1,
|
|
282
285
|
retries: failedStep.retries,
|
|
283
286
|
retryDelay: failedStep.retryDelay,
|
|
287
|
+
fromStepName: failedStep.fromStepName,
|
|
284
288
|
createdAt: now,
|
|
285
289
|
updatedAt: now,
|
|
286
290
|
stepName: stepName,
|
|
@@ -445,6 +449,26 @@ export class InMemoryWorkflowService
|
|
|
445
449
|
return nodeIds.filter((id) => !existingSteps.has(id))
|
|
446
450
|
}
|
|
447
451
|
|
|
452
|
+
async getStepInstances(runId: string): Promise<
|
|
453
|
+
Array<{ stepName: string; status: StepStatus; fromStepName?: string }>
|
|
454
|
+
> {
|
|
455
|
+
const prefix = `${runId}:`
|
|
456
|
+
const instances: Array<{
|
|
457
|
+
stepName: string
|
|
458
|
+
status: StepStatus
|
|
459
|
+
fromStepName?: string
|
|
460
|
+
}> = []
|
|
461
|
+
for (const [key, step] of this.steps.entries()) {
|
|
462
|
+
if (!key.startsWith(prefix)) continue
|
|
463
|
+
instances.push({
|
|
464
|
+
stepName: key.substring(prefix.length),
|
|
465
|
+
status: step.status,
|
|
466
|
+
fromStepName: step.fromStepName,
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
return instances
|
|
470
|
+
}
|
|
471
|
+
|
|
448
472
|
async getNodeResults(
|
|
449
473
|
runId: string,
|
|
450
474
|
nodeIds: string[]
|