@pikku/core 0.12.148 → 0.12.151

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.
Files changed (139) hide show
  1. package/dist/function/function-runner.js +23 -0
  2. package/dist/function/trace-queue-service.d.ts +26 -0
  3. package/dist/function/trace-queue-service.js +48 -0
  4. package/dist/mesh.d.ts +2 -2
  5. package/dist/mesh.js +3 -3
  6. package/dist/middleware/index.d.ts +1 -1
  7. package/dist/middleware/index.js +1 -1
  8. package/dist/middleware/mesh-auth.d.ts +3 -2
  9. package/dist/middleware/mesh-auth.js +7 -2
  10. package/dist/middleware/remote-jobs-secret.d.ts +1 -1
  11. package/dist/middleware/require-origin.d.ts +19 -0
  12. package/dist/middleware/require-origin.js +36 -2
  13. package/dist/services/deployment-service.d.ts +1 -1
  14. package/dist/services/incoming-webhook-service.d.ts +4 -2
  15. package/dist/services/incoming-webhook-service.js +4 -3
  16. package/dist/services/queue-webhook-service.js +7 -1
  17. package/dist/services/remote-jobs.d.ts +6 -1
  18. package/dist/services/scheduler-service.d.ts +3 -1
  19. package/dist/services/webhook-service.d.ts +2 -0
  20. package/dist/services/workflow-service.d.ts +4 -1
  21. package/dist/testing/index.d.ts +4 -0
  22. package/dist/testing/index.js +2 -0
  23. package/dist/testing/queue-identity-transport-tests.d.ts +18 -0
  24. package/dist/testing/queue-identity-transport-tests.js +162 -0
  25. package/dist/testing/queue-trace-conformance.d.ts +26 -0
  26. package/dist/testing/queue-trace-conformance.js +259 -0
  27. package/dist/testing/service-tests/channel-store-tests.js +11 -0
  28. package/dist/testing/service-tests/queued-workflow-harness.d.ts +18 -1
  29. package/dist/testing/service-tests/queued-workflow-harness.js +19 -3
  30. package/dist/testing/service-tests/workflow-service-tests.js +12 -0
  31. package/dist/testing/trace-matrix/assertions.d.ts +3 -0
  32. package/dist/testing/trace-matrix/assertions.js +190 -0
  33. package/dist/testing/trace-matrix/chain-harness.d.ts +119 -0
  34. package/dist/testing/trace-matrix/chain-harness.js +777 -0
  35. package/dist/testing/trace-matrix/dimensions.d.ts +55 -0
  36. package/dist/testing/trace-matrix/dimensions.js +305 -0
  37. package/dist/testing/trace-matrix/pairwise.d.ts +20 -0
  38. package/dist/testing/trace-matrix/pairwise.js +75 -0
  39. package/dist/testing/trace-matrix/report.d.ts +1 -0
  40. package/dist/testing/trace-matrix/report.js +16 -0
  41. package/dist/testing/trace-matrix/transports.d.ts +19 -0
  42. package/dist/testing/trace-matrix/transports.js +27 -0
  43. package/dist/testing/trace-matrix/workflow-cases.d.ts +24 -0
  44. package/dist/testing/trace-matrix/workflow-cases.js +147 -0
  45. package/dist/trace.d.ts +66 -0
  46. package/dist/trace.js +97 -0
  47. package/dist/types/core.types.d.ts +6 -2
  48. package/dist/wirings/addon/addon-reach.d.ts +3 -0
  49. package/dist/wirings/addon/addon-reach.js +9 -0
  50. package/dist/wirings/addon/define-plugin.d.ts +16 -0
  51. package/dist/wirings/addon/wire-addon.js +5 -0
  52. package/dist/wirings/addon/wire-remote-addon.d.ts +11 -0
  53. package/dist/wirings/agent/agent-finalize.d.ts +2 -0
  54. package/dist/wirings/agent/agent-finalize.js +1 -1
  55. package/dist/wirings/agent/agent-prepare.d.ts +12 -2
  56. package/dist/wirings/agent/agent-prepare.js +38 -14
  57. package/dist/wirings/agent/agent-rpc.js +2 -1
  58. package/dist/wirings/agent/agent-runner.js +5 -6
  59. package/dist/wirings/agent/agent-stream.js +6 -6
  60. package/dist/wirings/agent/voice-input.d.ts +1 -1
  61. package/dist/wirings/agent/voice-output.d.ts +1 -1
  62. package/dist/wirings/agent-scorer/agent-scorer-live.d.ts +4 -2
  63. package/dist/wirings/agent-scorer/agent-scorer-live.js +2 -2
  64. package/dist/wirings/channel/channel-common.d.ts +4 -1
  65. package/dist/wirings/channel/channel-common.js +3 -1
  66. package/dist/wirings/channel/channel-handler.js +2 -0
  67. package/dist/wirings/channel/channel-host-rpc.d.ts +1 -1
  68. package/dist/wirings/channel/channel-host-rpc.js +2 -2
  69. package/dist/wirings/channel/channel-rpc-responder.d.ts +7 -1
  70. package/dist/wirings/channel/channel-rpc-responder.js +2 -2
  71. package/dist/wirings/channel/channel-rpc-service.d.ts +1 -1
  72. package/dist/wirings/channel/channel-rpc-service.js +2 -2
  73. package/dist/wirings/channel/channel-rpc.types.d.ts +5 -2
  74. package/dist/wirings/channel/channel-rpc.types.js +1 -1
  75. package/dist/wirings/channel/channel-store.d.ts +4 -2
  76. package/dist/wirings/channel/channel-store.js +2 -2
  77. package/dist/wirings/channel/channel.types.d.ts +3 -0
  78. package/dist/wirings/channel/local/local-channel-runner.js +16 -5
  79. package/dist/wirings/channel/pikku-abstract-channel-handler.d.ts +2 -0
  80. package/dist/wirings/channel/pikku-abstract-channel-handler.js +3 -1
  81. package/dist/wirings/channel/serverless/serverless-channel-runner.js +27 -4
  82. package/dist/wirings/gateway/gateway-runner.js +3 -1
  83. package/dist/wirings/http/http-runner.d.ts +4 -4
  84. package/dist/wirings/http/http-runner.js +14 -18
  85. package/dist/wirings/http/http.types.d.ts +3 -3
  86. package/dist/wirings/mcp/mcp-runner.js +15 -5
  87. package/dist/wirings/queue/in-memory-queue-service.d.ts +1 -1
  88. package/dist/wirings/queue/in-memory-queue-service.js +8 -2
  89. package/dist/wirings/queue/index.d.ts +1 -0
  90. package/dist/wirings/queue/index.js +1 -0
  91. package/dist/wirings/queue/queue-runner.d.ts +3 -4
  92. package/dist/wirings/queue/queue-runner.js +8 -7
  93. package/dist/wirings/queue/queue-trace.d.ts +18 -0
  94. package/dist/wirings/queue/queue-trace.js +42 -0
  95. package/dist/wirings/queue/queue.types.d.ts +7 -0
  96. package/dist/wirings/queue/remote-queue-job.d.ts +1 -1
  97. package/dist/wirings/queue/remote-queue-job.js +5 -3
  98. package/dist/wirings/rpc/rpc-runner.d.ts +7 -1
  99. package/dist/wirings/rpc/rpc-runner.js +43 -21
  100. package/dist/wirings/rpc/rpc-types.d.ts +2 -0
  101. package/dist/wirings/scheduler/remote-scheduled-job.d.ts +1 -1
  102. package/dist/wirings/scheduler/remote-scheduled-job.js +2 -2
  103. package/dist/wirings/scheduler/scheduler-runner.d.ts +4 -4
  104. package/dist/wirings/scheduler/scheduler-runner.js +7 -6
  105. package/dist/wirings/trigger/pikku-trigger-service.js +7 -4
  106. package/dist/wirings/trigger/webhook-source-runner.d.ts +7 -1
  107. package/dist/wirings/trigger/webhook-source-runner.js +11 -3
  108. package/dist/wirings/workflow/graph/graph-runner.d.ts +1 -1
  109. package/dist/wirings/workflow/graph/graph-runner.js +49 -30
  110. package/dist/wirings/workflow/index.d.ts +3 -1
  111. package/dist/wirings/workflow/index.js +1 -0
  112. package/dist/wirings/workflow/pikku-scenario-service.js +2 -0
  113. package/dist/wirings/workflow/pikku-workflow-service.d.ts +37 -40
  114. package/dist/wirings/workflow/pikku-workflow-service.js +122 -133
  115. package/dist/wirings/workflow/workflow-child-step.d.ts +2 -2
  116. package/dist/wirings/workflow/workflow-child-step.js +11 -4
  117. package/dist/wirings/workflow/workflow-compensation.d.ts +6 -0
  118. package/dist/wirings/workflow/workflow-compensation.js +15 -1
  119. package/dist/wirings/workflow/workflow-constants.d.ts +13 -0
  120. package/dist/wirings/workflow/workflow-constants.js +13 -0
  121. package/dist/wirings/workflow/workflow-dsl-pass.d.ts +2 -0
  122. package/dist/wirings/workflow/workflow-dsl-pass.js +27 -11
  123. package/dist/wirings/workflow/workflow-recovery.d.ts +131 -3
  124. package/dist/wirings/workflow/workflow-recovery.js +233 -7
  125. package/dist/wirings/workflow/workflow-run-engine.types.d.ts +1 -0
  126. package/dist/wirings/workflow/workflow-run-mirror.d.ts +4 -0
  127. package/dist/wirings/workflow/workflow-run-mirror.js +16 -0
  128. package/dist/wirings/workflow/workflow-run-status.d.ts +2 -0
  129. package/dist/wirings/workflow/workflow-run-status.js +13 -0
  130. package/dist/wirings/workflow/workflow-run-trace.d.ts +22 -0
  131. package/dist/wirings/workflow/workflow-run-trace.js +24 -0
  132. package/dist/wirings/workflow/workflow-step-claim.d.ts +18 -0
  133. package/dist/wirings/workflow/workflow-step-claim.js +47 -0
  134. package/dist/wirings/workflow/workflow-step-retry.d.ts +6 -2
  135. package/dist/wirings/workflow/workflow-step-retry.js +8 -3
  136. package/dist/wirings/workflow/workflow-wire.d.ts +12 -0
  137. package/dist/wirings/workflow/workflow-wire.js +33 -0
  138. package/dist/wirings/workflow/workflow.types.d.ts +16 -0
  139. package/package.json +2 -1
@@ -1,4 +1,5 @@
1
1
  import { runPikkuFunc } from '../../function/function-runner.js';
2
+ import { runTraceWire } from './workflow-run-trace.js';
2
3
  import { getCreateWireServices, getSingletonServices, } from '../../pikku-state.js';
3
4
  import { WorkflowAsyncException, WorkflowCancelledException, WorkflowSuspendedException, } from './workflow-errors.js';
4
5
  export const runDslWorkflowPass = async (deps, run, workflowMeta, workflow, pkgName) => {
@@ -9,6 +10,7 @@ export const runDslWorkflowPass = async (deps, run, workflowMeta, workflow, pkgN
9
10
  const workflowWire = deps.workflowWire(addonNs);
10
11
  workflowWire.pikkuUserId = run.wire?.pikkuUserId;
11
12
  const wire = {
13
+ ...runTraceWire(run.wire?.traceparent),
12
14
  workflow: workflowWire,
13
15
  pikkuUserId: run.wire?.pikkuUserId,
14
16
  };
@@ -36,6 +38,11 @@ export const runDslWorkflowPass = async (deps, run, workflowMeta, workflow, pkgN
36
38
  data: () => run.input,
37
39
  packageName: pkgName ?? undefined,
38
40
  });
41
+ // The run has been in the hands of its steps, and an inline one never went
42
+ // back through the queue, so what it was told is only known by rereading.
43
+ if (await deps.settleIfStopped()) {
44
+ throw new WorkflowCancelledException(runId);
45
+ }
39
46
  await deps.updateRunStatus('completed', result);
40
47
  await deps.onChildCompleted(result);
41
48
  }
@@ -47,24 +54,33 @@ export const runDslWorkflowPass = async (deps, run, workflowMeta, workflow, pkgN
47
54
  }
48
55
  if (error instanceof WorkflowCancelledException) {
49
56
  outcome = 'failed';
50
- await deps.updateRunStatus('cancelled', undefined, {
51
- message: error.message || 'Workflow cancelled',
52
- stack: '',
53
- code: 'WORKFLOW_CANCELLED',
54
- });
55
- await deps.onChildFailed(error);
57
+ const stopped = await deps.settleIfStopped();
58
+ if (!stopped) {
59
+ await deps.updateRunStatus('cancelled', undefined, {
60
+ message: error.message || 'Workflow cancelled',
61
+ stack: '',
62
+ code: 'WORKFLOW_CANCELLED',
63
+ });
64
+ }
65
+ // An unwind tells the parent itself, when it finishes.
66
+ if (!stopped || stopped === 'cancelled')
67
+ await deps.onChildFailed(error);
56
68
  throw error;
57
69
  }
58
70
  if (error instanceof WorkflowSuspendedException) {
59
71
  outcome = 'interrupted';
60
- await deps.updateRunStatus('suspended', undefined, {
61
- message: error.message || 'Workflow suspended',
62
- stack: '',
63
- code: 'WORKFLOW_SUSPENDED',
64
- });
72
+ if (!(await deps.settleIfStopped())) {
73
+ await deps.updateRunStatus('suspended', undefined, {
74
+ message: error.message || 'Workflow suspended',
75
+ stack: '',
76
+ code: 'WORKFLOW_SUSPENDED',
77
+ });
78
+ }
65
79
  throw error;
66
80
  }
67
81
  outcome = 'failed';
82
+ if (await deps.settleIfStopped())
83
+ throw error;
68
84
  if ((await deps.failOrUnwind(error)) === 'unwinding')
69
85
  return;
70
86
  throw error;
@@ -1,4 +1,6 @@
1
+ import type { LeaseService } from '../../services/lease-service.js';
1
2
  import type { Logger } from '../../services/logger.js';
3
+ import type { StepState, WorkflowRecoveryOptions, WorkflowStepOptions } from './workflow.types.js';
2
4
  /**
3
5
  * Per-process, advisory record of when a run may next be re-dispatched.
4
6
  *
@@ -22,10 +24,13 @@ export declare class RedispatchBackoff {
22
24
  private readonly eligibleAt;
23
25
  private readonly delays;
24
26
  isEligible(runId: string, now: number): boolean;
27
+ /** Drop a record, for a re-drive that turned out not to happen. */
28
+ forget(runId: string): void;
25
29
  note(runId: string, now: number): void;
26
30
  }
27
31
  type SweepDeps = {
28
- resume: (runId: string) => Promise<void>;
32
+ /** Resolves `false` when the run was left alone rather than re-driven. */
33
+ resume: (runId: string) => Promise<boolean | void>;
29
34
  logger?: Logger;
30
35
  };
31
36
  /**
@@ -41,8 +46,9 @@ type SweepDeps = {
41
46
  * Replay is the recovery — `resumeWorkflow` re-orchestrates from persisted step
42
47
  * state, and every settled step is memoized, so resuming a run that was not
43
48
  * actually stuck costs an orchestration pass and changes nothing. That
44
- * idempotence is what makes an idle-time heuristic safe here; a run that is
45
- * legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
49
+ * idempotence is what makes an idle-time heuristic safe here. A run parked on a
50
+ * `scheduled` step is not this sweep's: `sweepScheduledSteps` owns those, since
51
+ * replay cannot move them.
46
52
  *
47
53
  * Idempotent is not free, though: a run stays stalled until something clears
48
54
  * the reason it stalled, so an unconditional sweep re-queues the same runs on
@@ -72,6 +78,13 @@ export declare const sweepStalledRuns: (findStalledRunIds: (before: Date, limit:
72
78
  * Re-dispatches back off per run (doubling from 30s, capped at 10m) so a
73
79
  * genuine queue backlog is not amplified by a tick that keeps firing at the
74
80
  * steps the backlog is already delaying.
81
+ *
82
+ * A store opts in by overriding `findUndispatchedSteps`, and needs an atomic
83
+ * `claimStepForExecution` first: the relay makes duplicate dispatch routine, and
84
+ * the claim is what keeps a duplicate from becoming a second execution. Every
85
+ * `@pikku/kysely` dialect qualifies on its status-guarded claim, `mongodb` on
86
+ * the same claim as a single-document update, `in-memory` on being inline and
87
+ * single-process.
75
88
  */
76
89
  export declare const sweepUndispatchedSteps: (findUndispatchedSteps: (before: Date, limit: number) => Promise<Array<{
77
90
  runId: string;
@@ -82,4 +95,119 @@ export declare const sweepUndispatchedSteps: (findUndispatchedSteps: (before: Da
82
95
  } | undefined, deps: SweepDeps) => Promise<{
83
96
  redispatched: string[];
84
97
  }>;
98
+ /** A step parked on `scheduled`, waiting for a timer or a queue job. */
99
+ export type ScheduledStep = {
100
+ runId: string;
101
+ stepId: string;
102
+ stepName: string;
103
+ /** `null` is a `workflow.sleep`; anything else is a dispatched step. */
104
+ rpcName: string | null;
105
+ data: unknown;
106
+ fromStepName?: string;
107
+ retries?: number;
108
+ retryDelay?: string | number;
109
+ /** When the step went `scheduled`, on the writer's clock. */
110
+ scheduledAt: Date;
111
+ };
112
+ type ScheduledStepDeps = {
113
+ /** Set the sleep's result and re-drive its run; `false` if it was left alone. */
114
+ wake: (step: ScheduledStep) => Promise<boolean>;
115
+ /** Arm a fresh wake-up `remainingMs` from now. */
116
+ rearm: (step: ScheduledStep, remainingMs: number) => Promise<void>;
117
+ /** Send a dispatched step to the step queue again. */
118
+ redispatch: (step: ScheduledStep) => Promise<void>;
119
+ logger?: Logger;
120
+ };
121
+ /**
122
+ * Recover steps parked on `scheduled` whose timer or queue job may have died
123
+ * with the process that armed them.
124
+ *
125
+ * Replay cannot do this: it reads `scheduled` as "something is coming" and
126
+ * stops, which is right for every pass except the one after the something was
127
+ * lost. Sleeps are judged by the clock. A sleep past its wake time is woken, and
128
+ * `rearm` (set at startup, when this process has armed nothing) gives one that
129
+ * is not yet due a timer for what remains of it, so a restart neither loses a
130
+ * wake-up nor restarts the wait. Past startup a sleep is left alone until it is
131
+ * `wakeGraceMs` overdue, since only then is its timer known to be lost.
132
+ *
133
+ * Both are safe to repeat across instances. Every instance that starts arms its
134
+ * own timer for a sleep it cannot tell is someone else's, but a wake-up only
135
+ * sets a sleep's result, which is the same `null` however many times it lands,
136
+ * and resuming is idempotent. Dispatched steps are safe on the same terms as
137
+ * the relay: age is the only signal, a redundant message costs one queue job,
138
+ * and the claim is what keeps it from being a second execution.
139
+ */
140
+ export declare const sweepScheduledSteps: (findScheduledSteps: (limit: number, offset: number) => Promise<ScheduledStep[]>, backoff: RedispatchBackoff, options: {
141
+ rearm?: boolean;
142
+ wakeGraceMs?: number;
143
+ undispatchedAfterMs?: number;
144
+ }, deps: ScheduledStepDeps) => Promise<{
145
+ woken: string[];
146
+ rearmed: string[];
147
+ redispatched: string[];
148
+ }>;
149
+ export type StalledSweepOptions = {
150
+ stalledAfterMs?: number;
151
+ limit?: number;
152
+ };
153
+ export type UndispatchedSweepOptions = {
154
+ undispatchedAfterMs?: number;
155
+ limit?: number;
156
+ };
157
+ /** `startup` is a process that has armed nothing yet. */
158
+ export type WorkflowRecoveryPass = WorkflowRecoveryOptions & {
159
+ startup?: boolean;
160
+ };
161
+ /** What recovery needs from the workflow service. */
162
+ export type WorkflowRecoveryHost = {
163
+ leases: () => LeaseService | undefined;
164
+ listStepStates: (runId: string) => Promise<StepState[] | null>;
165
+ withRunLease: (runId: string, fn: () => Promise<void>) => Promise<void>;
166
+ findStalledRunIds: (before: Date, limit: number) => Promise<string[]>;
167
+ findUndispatchedSteps: (before: Date, limit: number) => Promise<Array<{
168
+ runId: string;
169
+ stepId: string;
170
+ }>>;
171
+ findScheduledSteps: (limit: number, offset: number) => Promise<ScheduledStep[]>;
172
+ resumeWorkflow: (runId: string) => Promise<void>;
173
+ wakeSleep: (runId: string, stepId: string) => Promise<void>;
174
+ rearmSleep: (runId: string, stepId: string, remainingMs: number) => Promise<unknown>;
175
+ queueStepWorker: (runId: string, stepName: string, rpcName: string, data: any, stepOptions?: WorkflowStepOptions, fromStepName?: string) => Promise<void>;
176
+ };
177
+ /** Every way a workflow service brings back work a dead process left behind. */
178
+ export declare class WorkflowRecovery {
179
+ private readonly host;
180
+ private readonly backoff;
181
+ private timer;
182
+ private sweeping;
183
+ constructor(host: WorkflowRecoveryHost);
184
+ private get logger();
185
+ /**
186
+ * Do `drive` to a run unless something else owns it, reporting whether it
187
+ * did. The run's lease is the ownership: a pass running anywhere holds it, and
188
+ * it outlives a crashed holder by its TTL, so a sweep on one instance cannot
189
+ * take a run from another and a dead instance's runs come free on their own.
190
+ */
191
+ private driveUnlessOwned;
192
+ private get sweepDeps();
193
+ recoverStalledRuns(options?: StalledSweepOptions): Promise<{
194
+ resumed: string[];
195
+ }>;
196
+ relayUndispatchedSteps(options?: UndispatchedSweepOptions): Promise<{
197
+ redispatched: string[];
198
+ }>;
199
+ /**
200
+ * Bring back everything a dead process left behind, once: steps parked on
201
+ * `scheduled`, runs with nothing in flight, and steps whose dispatch was
202
+ * lost. `startup` is a process that has armed nothing yet, so its sleeps get
203
+ * timers and nothing has to look idle first.
204
+ *
205
+ * One pass per lease, so a fleet does not sweep the same rows in step.
206
+ */
207
+ recover(options?: WorkflowRecoveryPass): Promise<void>;
208
+ /** `false` is the `workflow.recovery` config saying not to. */
209
+ start(options?: WorkflowRecoveryOptions | false): void;
210
+ stop(): void;
211
+ private sweep;
212
+ }
85
213
  export {};
@@ -1,4 +1,8 @@
1
- import { DEFAULT_STALLED_RUN_LIMIT, DEFAULT_STALLED_RUN_MS, DEFAULT_UNDISPATCHED_STEP_LIMIT, DEFAULT_UNDISPATCHED_STEP_MS, REDISPATCH_BACKOFF_MAX_ENTRIES, REDISPATCH_BACKOFF_MAX_MS, REDISPATCH_BACKOFF_MS, } from './workflow-constants.js';
1
+ import { holdLease, LeaseTakenError } from '../../services/lease-service.js';
2
+ import { getSingletonServices } from '../../pikku-state.js';
3
+ import { getDurationInMilliseconds } from '../../time-utils.js';
4
+ import { DEFAULT_RECOVERY_INTERVAL_MS, DEFAULT_RECOVERY_STALLED_MS, DEFAULT_STALLED_RUN_LIMIT, DEFAULT_STALLED_RUN_MS, DEFAULT_UNDISPATCHED_STEP_LIMIT, DEFAULT_UNDISPATCHED_STEP_MS, REDISPATCH_BACKOFF_MAX_ENTRIES, REDISPATCH_BACKOFF_MAX_MS, REDISPATCH_BACKOFF_MS, SCHEDULED_STEP_PAGE_SIZE, SLEEP_WAKE_GRACE_MS, } from './workflow-constants.js';
5
+ import { isRunLeaseError } from './workflow-run-lease.js';
2
6
  /**
3
7
  * Per-process, advisory record of when a run may next be re-dispatched.
4
8
  *
@@ -25,6 +29,11 @@ export class RedispatchBackoff {
25
29
  const at = this.eligibleAt.get(runId);
26
30
  return at === undefined || at <= now;
27
31
  }
32
+ /** Drop a record, for a re-drive that turned out not to happen. */
33
+ forget(runId) {
34
+ this.eligibleAt.delete(runId);
35
+ this.delays.delete(runId);
36
+ }
28
37
  note(runId, now) {
29
38
  const previous = this.delays.get(runId);
30
39
  const delay = Math.min(previous === undefined ? REDISPATCH_BACKOFF_MS : previous * 2, REDISPATCH_BACKOFF_MAX_MS);
@@ -41,11 +50,16 @@ export class RedispatchBackoff {
41
50
  this.eligibleAt.set(runId, now + delay);
42
51
  }
43
52
  }
44
- const resumeEach = async (runIds, { resume, logger }, failure) => {
53
+ const resumeEach = async (runIds, backoff, { resume, logger }, failure) => {
45
54
  const succeeded = [];
46
55
  for (const runId of runIds) {
47
56
  try {
48
- await resume(runId);
57
+ if ((await resume(runId)) === false) {
58
+ // Nothing was re-driven, so nothing has earned a wait: a run owned by
59
+ // a live instance now may have lost that instance by the next tick.
60
+ backoff.forget(runId);
61
+ continue;
62
+ }
49
63
  succeeded.push(runId);
50
64
  }
51
65
  catch (err) {
@@ -68,8 +82,9 @@ const resumeEach = async (runIds, { resume, logger }, failure) => {
68
82
  * Replay is the recovery — `resumeWorkflow` re-orchestrates from persisted step
69
83
  * state, and every settled step is memoized, so resuming a run that was not
70
84
  * actually stuck costs an orchestration pass and changes nothing. That
71
- * idempotence is what makes an idle-time heuristic safe here; a run that is
72
- * legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
85
+ * idempotence is what makes an idle-time heuristic safe here. A run parked on a
86
+ * `scheduled` step is not this sweep's: `sweepScheduledSteps` owns those, since
87
+ * replay cannot move them.
73
88
  *
74
89
  * Idempotent is not free, though: a run stays stalled until something clears
75
90
  * the reason it stalled, so an unconditional sweep re-queues the same runs on
@@ -94,7 +109,7 @@ export const sweepStalledRuns = async (findStalledRunIds, backoff, options, deps
94
109
  runIds.push(runId);
95
110
  }
96
111
  return {
97
- resumed: await resumeEach(runIds, deps, (runId, detail) => `Failed to resume stalled workflow run ${runId}: ${detail}`),
112
+ resumed: await resumeEach(runIds, backoff, deps, (runId, detail) => `Failed to resume stalled workflow run ${runId}: ${detail}`),
98
113
  };
99
114
  };
100
115
  /**
@@ -111,6 +126,13 @@ export const sweepStalledRuns = async (findStalledRunIds, backoff, options, deps
111
126
  * Re-dispatches back off per run (doubling from 30s, capped at 10m) so a
112
127
  * genuine queue backlog is not amplified by a tick that keeps firing at the
113
128
  * steps the backlog is already delaying.
129
+ *
130
+ * A store opts in by overriding `findUndispatchedSteps`, and needs an atomic
131
+ * `claimStepForExecution` first: the relay makes duplicate dispatch routine, and
132
+ * the claim is what keeps a duplicate from becoming a second execution. Every
133
+ * `@pikku/kysely` dialect qualifies on its status-guarded claim, `mongodb` on
134
+ * the same claim as a single-document update, `in-memory` on being inline and
135
+ * single-process.
114
136
  */
115
137
  export const sweepUndispatchedSteps = async (findUndispatchedSteps, backoff, options, deps) => {
116
138
  const before = new Date(Date.now() - (options?.undispatchedAfterMs ?? DEFAULT_UNDISPATCHED_STEP_MS));
@@ -124,6 +146,210 @@ export const sweepUndispatchedSteps = async (findUndispatchedSteps, backoff, opt
124
146
  runIds.add(runId);
125
147
  }
126
148
  return {
127
- redispatched: await resumeEach(runIds, deps, (runId, detail) => `Failed to re-dispatch workflow run ${runId}: ${detail}`),
149
+ redispatched: await resumeEach(runIds, backoff, deps, (runId, detail) => `Failed to re-dispatch workflow run ${runId}: ${detail}`),
128
150
  };
129
151
  };
152
+ /**
153
+ * Recover steps parked on `scheduled` whose timer or queue job may have died
154
+ * with the process that armed them.
155
+ *
156
+ * Replay cannot do this: it reads `scheduled` as "something is coming" and
157
+ * stops, which is right for every pass except the one after the something was
158
+ * lost. Sleeps are judged by the clock. A sleep past its wake time is woken, and
159
+ * `rearm` (set at startup, when this process has armed nothing) gives one that
160
+ * is not yet due a timer for what remains of it, so a restart neither loses a
161
+ * wake-up nor restarts the wait. Past startup a sleep is left alone until it is
162
+ * `wakeGraceMs` overdue, since only then is its timer known to be lost.
163
+ *
164
+ * Both are safe to repeat across instances. Every instance that starts arms its
165
+ * own timer for a sleep it cannot tell is someone else's, but a wake-up only
166
+ * sets a sleep's result, which is the same `null` however many times it lands,
167
+ * and resuming is idempotent. Dispatched steps are safe on the same terms as
168
+ * the relay: age is the only signal, a redundant message costs one queue job,
169
+ * and the claim is what keeps it from being a second execution.
170
+ */
171
+ export const sweepScheduledSteps = async (findScheduledSteps, backoff, options, deps) => {
172
+ const outcome = {
173
+ woken: [],
174
+ rearmed: [],
175
+ redispatched: [],
176
+ };
177
+ const grace = options.wakeGraceMs ?? SLEEP_WAKE_GRACE_MS;
178
+ const lostBefore = Date.now() - (options.undispatchedAfterMs ?? DEFAULT_UNDISPATCHED_STEP_MS);
179
+ const recover = async (step) => {
180
+ const now = Date.now();
181
+ if (step.rpcName !== null) {
182
+ if (step.scheduledAt.getTime() >= lostBefore)
183
+ return;
184
+ if (!backoff.isEligible(step.stepId, now))
185
+ return;
186
+ backoff.note(step.stepId, now);
187
+ await deps.redispatch(step);
188
+ outcome.redispatched.push(step.runId);
189
+ return;
190
+ }
191
+ const duration = step.data
192
+ ?.duration;
193
+ if (duration === undefined)
194
+ return;
195
+ const remaining = step.scheduledAt.getTime() + getDurationInMilliseconds(duration) - now;
196
+ if (options.rearm && remaining > 0) {
197
+ await deps.rearm(step, remaining);
198
+ outcome.rearmed.push(step.runId);
199
+ }
200
+ else if (remaining <= (options.rearm ? 0 : -grace)) {
201
+ if (await deps.wake(step))
202
+ outcome.woken.push(step.runId);
203
+ }
204
+ };
205
+ for (let offset = 0;; offset += SCHEDULED_STEP_PAGE_SIZE) {
206
+ const page = await findScheduledSteps(SCHEDULED_STEP_PAGE_SIZE, offset);
207
+ for (const step of page) {
208
+ try {
209
+ await recover(step);
210
+ }
211
+ catch (err) {
212
+ // One unrecoverable step must not stop the sweep from reaching the rest.
213
+ deps.logger?.error(`Failed to recover scheduled step ${step.stepName} of workflow run ${step.runId}: ${err instanceof Error ? err.message : String(err)}`);
214
+ }
215
+ }
216
+ if (page.length < SCHEDULED_STEP_PAGE_SIZE)
217
+ return outcome;
218
+ }
219
+ };
220
+ /** A store may hand a numeric duration back as the string it wrote. */
221
+ const storedDurationMs = (duration) => /^\d+$/.test(String(duration))
222
+ ? Number(duration)
223
+ : getDurationInMilliseconds(duration);
224
+ /** A retry sitting out its `retryDelay` is idle on purpose, not stalled. */
225
+ const isWaitingOutRetryDelay = (steps) => {
226
+ const now = Date.now();
227
+ return (steps ?? []).some(({ status, attemptCount, retryDelay, updatedAt }) => status === 'pending' &&
228
+ attemptCount > 1 &&
229
+ retryDelay != null &&
230
+ updatedAt.getTime() + storedDurationMs(retryDelay) > now);
231
+ };
232
+ /** Every way a workflow service brings back work a dead process left behind. */
233
+ export class WorkflowRecovery {
234
+ host;
235
+ backoff = new RedispatchBackoff();
236
+ timer;
237
+ sweeping = false;
238
+ constructor(host) {
239
+ this.host = host;
240
+ }
241
+ get logger() {
242
+ return getSingletonServices()?.logger;
243
+ }
244
+ /**
245
+ * Do `drive` to a run unless something else owns it, reporting whether it
246
+ * did. The run's lease is the ownership: a pass running anywhere holds it, and
247
+ * it outlives a crashed holder by its TTL, so a sweep on one instance cannot
248
+ * take a run from another and a dead instance's runs come free on their own.
249
+ */
250
+ async driveUnlessOwned(runId, drive) {
251
+ if (isWaitingOutRetryDelay(await this.host.listStepStates(runId))) {
252
+ return false;
253
+ }
254
+ try {
255
+ await this.host.withRunLease(runId, drive);
256
+ return true;
257
+ }
258
+ catch (error) {
259
+ if (isRunLeaseError(error))
260
+ return false;
261
+ throw error;
262
+ }
263
+ }
264
+ get sweepDeps() {
265
+ return {
266
+ resume: (runId) => this.driveUnlessOwned(runId, () => this.host.resumeWorkflow(runId)),
267
+ logger: this.logger,
268
+ };
269
+ }
270
+ recoverStalledRuns(options) {
271
+ return sweepStalledRuns(this.host.findStalledRunIds, this.backoff, options, this.sweepDeps);
272
+ }
273
+ relayUndispatchedSteps(options) {
274
+ return sweepUndispatchedSteps(this.host.findUndispatchedSteps, this.backoff, options, this.sweepDeps);
275
+ }
276
+ /**
277
+ * Bring back everything a dead process left behind, once: steps parked on
278
+ * `scheduled`, runs with nothing in flight, and steps whose dispatch was
279
+ * lost. `startup` is a process that has armed nothing yet, so its sleeps get
280
+ * timers and nothing has to look idle first.
281
+ *
282
+ * One pass per lease, so a fleet does not sweep the same rows in step.
283
+ */
284
+ async recover(options = {}) {
285
+ const { host } = this;
286
+ const { startup = false } = options;
287
+ const undispatchedAfterMs = startup
288
+ ? 0
289
+ : (options.undispatchedAfterMs ?? DEFAULT_UNDISPATCHED_STEP_MS);
290
+ const pass = async () => {
291
+ const scheduled = await sweepScheduledSteps(host.findScheduledSteps, this.backoff, { rearm: startup, undispatchedAfterMs }, {
292
+ wake: ({ runId, stepId }) => this.driveUnlessOwned(runId, () => host.wakeSleep(runId, stepId)),
293
+ rearm: async ({ runId, stepId }, remainingMs) => {
294
+ await host.rearmSleep(runId, stepId, remainingMs);
295
+ },
296
+ redispatch: (step) => host.queueStepWorker(step.runId, step.stepName, step.rpcName, step.data, { retries: step.retries, retryDelay: step.retryDelay }, step.fromStepName),
297
+ logger: this.logger,
298
+ });
299
+ const { resumed } = await this.recoverStalledRuns({
300
+ stalledAfterMs: startup
301
+ ? 0
302
+ : (options.stalledAfterMs ?? DEFAULT_RECOVERY_STALLED_MS),
303
+ });
304
+ const { redispatched } = await this.relayUndispatchedSteps({
305
+ undispatchedAfterMs,
306
+ });
307
+ const { woken, rearmed } = scheduled;
308
+ if (woken.length +
309
+ rearmed.length +
310
+ scheduled.redispatched.length +
311
+ resumed.length +
312
+ redispatched.length >
313
+ 0) {
314
+ this.logger?.info(`Workflow recovery: ${woken.length} sleeps woken, ${rearmed.length} re-armed, ${scheduled.redispatched.length} steps re-dispatched, ${resumed.length} stalled runs and ${redispatched.length} undispatched runs resumed`);
315
+ }
316
+ };
317
+ const leases = host.leases();
318
+ if (!leases)
319
+ return pass();
320
+ try {
321
+ await holdLease(leases, 'workflow-recovery', pass);
322
+ }
323
+ catch (error) {
324
+ if (!(error instanceof LeaseTakenError))
325
+ throw error;
326
+ }
327
+ }
328
+ /** `false` is the `workflow.recovery` config saying not to. */
329
+ start(options) {
330
+ if (options === false || this.timer)
331
+ return;
332
+ this.timer = setInterval(() => void this.sweep(options, false), options?.intervalMs ?? DEFAULT_RECOVERY_INTERVAL_MS);
333
+ this.timer.unref?.();
334
+ // Fire-and-forget: `sweep` logs every failure itself.
335
+ void this.sweep(options, true);
336
+ }
337
+ stop() {
338
+ clearInterval(this.timer);
339
+ this.timer = undefined;
340
+ }
341
+ async sweep(options, startup) {
342
+ if (this.sweeping)
343
+ return;
344
+ this.sweeping = true;
345
+ try {
346
+ await this.recover({ ...options, startup });
347
+ }
348
+ catch (error) {
349
+ this.logger?.error(`Workflow recovery sweep failed: ${error instanceof Error ? error.message : String(error)}`);
350
+ }
351
+ finally {
352
+ this.sweeping = false;
353
+ }
354
+ }
355
+ }
@@ -18,6 +18,7 @@ export interface WorkflowRunEngine {
18
18
  updateRunStatus(runId: string, status: WorkflowStatus, output?: any, error?: SerializedError): Promise<void>;
19
19
  onChildWorkflowFailed(run: WorkflowRun, error: unknown): Promise<void>;
20
20
  verifyStepName(stepName: unknown): void;
21
+ getRunTraceparent(runId: string): Promise<string | undefined>;
21
22
  }
22
23
  /**
23
24
  * Hooks a host may install to decorate runs it did not start. Used by the
@@ -0,0 +1,4 @@
1
+ import type { Logger } from '../../services/logger.js';
2
+ import type { WorkflowRunMirror } from './workflow.types.js';
3
+ /** Writes to the mirror, if there is one, without ever failing the run. */
4
+ export declare const writeRunMirror: (mirror: WorkflowRunMirror | undefined, getLogger: () => Logger | undefined, write: (mirror: WorkflowRunMirror) => Promise<void>) => Promise<void>;
@@ -0,0 +1,16 @@
1
+ /** Writes to the mirror, if there is one, without ever failing the run. */
2
+ export const writeRunMirror = async (mirror, getLogger, write) => {
3
+ if (!mirror)
4
+ return;
5
+ try {
6
+ await write(mirror);
7
+ }
8
+ catch (err) {
9
+ try {
10
+ getLogger()?.warn?.(`[pikku] WorkflowRunMirror write failed: ${err?.message ?? err}`);
11
+ }
12
+ catch {
13
+ // knowledge: decisions/internals/workflow-run-mirror-is-never-a-source-of-truth.md
14
+ }
15
+ }
16
+ };
@@ -2,3 +2,5 @@ import type { HistoryEntry } from './run-timeline.js';
2
2
  import type { WorkflowRun, WorkflowRunStatus } from './workflow.types.js';
3
3
  /** A run's status with each step folded to its latest attempt. */
4
4
  export declare function summarizeRunStatus(run: WorkflowRun, history: HistoryEntry[]): WorkflowRunStatus;
5
+ /** What a finished run returned, or the error for a run that did not succeed. */
6
+ export declare function runOutput(run: WorkflowRun): unknown;
@@ -1,4 +1,5 @@
1
1
  import { WORKFLOW_TERMINAL_STATES } from './workflow-constants.js';
2
+ import { WorkflowRunCancelledError, WorkflowRunFailedError, } from './workflow-errors.js';
2
3
  /** A run's status with each step folded to its latest attempt. */
3
4
  export function summarizeRunStatus(run, history) {
4
5
  const terminalStatuses = WORKFLOW_TERMINAL_STATES;
@@ -47,3 +48,15 @@ function compensationOutcome(run) {
47
48
  }
48
49
  return {};
49
50
  }
51
+ /** What a finished run returned, or the error for a run that did not succeed. */
52
+ export function runOutput(run) {
53
+ if (run.status === 'failed' ||
54
+ run.status === 'compensated' ||
55
+ run.status === 'compensation_failed') {
56
+ throw new WorkflowRunFailedError(run.error?.message);
57
+ }
58
+ if (run.status === 'cancelled') {
59
+ throw new WorkflowRunCancelledError();
60
+ }
61
+ return run.output;
62
+ }
@@ -0,0 +1,22 @@
1
+ import { type TraceContext } from '../../trace.js';
2
+ import type { Logger } from '../../services/logger.js';
3
+ import type { WorkflowRunWire } from './workflow.types.js';
4
+ /**
5
+ * The wire a run is stored with. It always holds a valid `traceparent`, so a
6
+ * run started from a wire with no trace still has one to keep through every
7
+ * pass, restart and recovery.
8
+ *
9
+ * knowledge: decisions/internals/a-workflow-run-owns-its-trace.md
10
+ */
11
+ export declare const withRunTrace: (wire: WorkflowRunWire) => WorkflowRunWire;
12
+ /**
13
+ * The wire fields for one hop of a run: the run's trace with a new span, so
14
+ * each attempt of a step is its own span in the same trace. Empty for a run
15
+ * that was stored without a trace.
16
+ */
17
+ export declare const runTraceWire: (traceparent: string | undefined) => {
18
+ traceId?: string;
19
+ trace?: TraceContext;
20
+ };
21
+ /** `logger` on the trace of the run stored with `wire`. */
22
+ export declare const runLogger: (logger: Logger, wire: WorkflowRunWire) => Logger;
@@ -0,0 +1,24 @@
1
+ import { continueTrace, createTrace, formatTraceparent, parseTraceparent, tracedLogger, } from '../../trace.js';
2
+ /**
3
+ * The wire a run is stored with. It always holds a valid `traceparent`, so a
4
+ * run started from a wire with no trace still has one to keep through every
5
+ * pass, restart and recovery.
6
+ *
7
+ * knowledge: decisions/internals/a-workflow-run-owns-its-trace.md
8
+ */
9
+ export const withRunTrace = (wire) => parseTraceparent(wire?.traceparent)
10
+ ? wire
11
+ : { ...wire, traceparent: formatTraceparent(createTrace()) };
12
+ /**
13
+ * The wire fields for one hop of a run: the run's trace with a new span, so
14
+ * each attempt of a step is its own span in the same trace. Empty for a run
15
+ * that was stored without a trace.
16
+ */
17
+ export const runTraceWire = (traceparent) => {
18
+ if (!traceparent)
19
+ return {};
20
+ const trace = continueTrace(traceparent);
21
+ return { traceId: trace.traceId, trace };
22
+ };
23
+ /** `logger` on the trace of the run stored with `wire`. */
24
+ export const runLogger = (logger, wire) => tracedLogger(logger, parseTraceparent(wire?.traceparent)?.traceId);
@@ -1,4 +1,5 @@
1
1
  import type { StepState } from './workflow.types.js';
2
+ import type { Logger } from '../../services/logger.js';
2
3
  /** What claiming a step needs from the workflow service. */
3
4
  export type StepClaimStore = {
4
5
  getStepState(runId: string, stepName: string): Promise<StepState>;
@@ -11,12 +12,29 @@ export type StepClaimStore = {
11
12
  * for lease exhaustion leaves the run `running` until a sweep notices.
12
13
  */
13
14
  resumeWorkflow?(runId: string): Promise<void>;
15
+ logStepRerun?(runId: string, stepName: string, attempt: number): Promise<void>;
16
+ logStepAttemptsExhausted?(runId: string, stepName: string, attempts: number): Promise<void>;
14
17
  };
18
+ /** Where the warnings about a lost step go, and the trace of its run. */
19
+ export type LostStepLog = {
20
+ logger(): Logger | undefined;
21
+ traceparent(runId: string): Promise<string | undefined>;
22
+ };
23
+ export declare const warnStepRerun: (log: LostStepLog, runId: string, stepName: string, attempt: number) => Promise<void>;
24
+ export declare const warnStepAttemptsExhausted: (log: LostStepLog, runId: string, stepName: string, attempts: number) => Promise<void>;
15
25
  /**
16
26
  * Whether a step whose worker vanished may be handed to another one, or has
17
27
  * run out of attempts to spend on that.
18
28
  */
19
29
  export declare const leaseAttemptsExhausted: (stepState: StepState) => boolean;
30
+ /**
31
+ * A pass holds its run's lease for as long as it works a step inline, so one
32
+ * that meets its own step already `running` is looking at a pass that died
33
+ * mid-step. Running it again is a new attempt, counted as one so a step that
34
+ * takes its process down every time runs out instead of looping forever. The
35
+ * invocation id comes from the step's name, so the attempt keeps it.
36
+ */
37
+ export declare const takeBackLostInlineStep: (store: Pick<StepClaimStore, 'setStepError' | 'createRetryAttempt' | 'logStepRerun' | 'logStepAttemptsExhausted'>, runId: string, stepName: string, stepState: StepState) => Promise<StepState>;
20
38
  /**
21
39
  * Decide whether this dispatch owns the step, by reading its state and then
22
40
  * writing it — which only excludes a concurrent dispatch when the caller holds