@bratsos/workflow-engine-host-serverless 0.3.2 → 0.4.0-alpha.1

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/README.md CHANGED
@@ -42,10 +42,16 @@ Creates a new serverless host instance.
42
42
  | `kernel` | `Kernel` | required | Kernel instance to dispatch commands to |
43
43
  | `jobTransport` | `JobTransport` | required | Job transport for complete/suspend/fail lifecycle |
44
44
  | `workerId` | `string` | required | Unique worker identifier (e.g. function name) |
45
- | `staleLeaseThresholdMs` | `number` | `60_000` | Time before a job lease is considered stale |
45
+ | `staleLeaseThresholdMs` | `number` | `300_000` | Heartbeat tier: a job whose lease is older is re-queued (`LEASE_HEARTBEAT_LOST`) |
46
+ | `jobAbsoluteTimeoutMs` | `number` | `3_600_000` | Absolute tier: a job claimed longer ago than this is failed terminally (`LEASE_ABSOLUTE_CAP`); `0` disables |
47
+ | `jobHeartbeatIntervalMs` | `number` | `60_000` | How often `handleJob` dispatches `job.heartbeat` while the stage runs; it renews the lease and aborts `ctx.abortSignal` on cancel or lease loss |
46
48
  | `maxClaimsPerTick` | `number` | `10` | Max pending runs to claim per maintenance tick |
49
+ | `serves` | `ServedDefinition[] \| "all"` | derived from registry | Which definition versions this host claims, dequeues and polls; `"all"` restores the pre-1.0 predicate |
47
50
  | `maxSuspendedChecksPerTick` | `number` | `10` | Max suspended stages to poll per tick |
48
51
  | `maxOutboxFlushPerTick` | `number` | `100` | Max outbox events to flush per tick |
52
+ | `retention` | `RetentionOptions` | off | `{ olderThanMs, statuses?, limit? }`: delete terminal runs through `run.purge` at the end of every tick |
53
+ | `flushOutboxAfterJob` | `boolean` | `true` | Publish the job's outbox events right after `handleJob` settles it |
54
+ | `outboxFlushTimeoutMs` | `number` | `5_000` | Bound on that post-job flush; errors are logged, not thrown |
49
55
 
50
56
  ### `ServerlessHost`
51
57
 
@@ -66,19 +72,33 @@ interface JobMessage {
66
72
  workflowId: string;
67
73
  stageId: string;
68
74
  attempt: number;
75
+ maxAttempts?: number; // retry budget; defaults to 3
69
76
  payload: Record<string, unknown>;
70
77
  }
71
78
  ```
72
79
 
80
+ A message delivered through your own push queue may carry a spilled payload if you wrapped the transport with `createSpillingJobTransport`; resolve it first with `createPayloadSpill({ blobStore }).unpack(msg.payload)` (both from `@bratsos/workflow-engine/kernel`).
81
+
73
82
  ### `JobResult`
74
83
 
75
84
  ```typescript
76
85
  interface JobResult {
77
86
  outcome: "completed" | "suspended" | "failed";
78
87
  error?: string;
88
+ /** The message was an orphan or malformed; it was failed and acknowledged. */
89
+ dead?: boolean;
90
+ /** The stage was left PENDING and the job must run again. */
91
+ willRetry?: boolean;
92
+ /** The job attempt that ran (1 on the first execution). */
93
+ attempt?: number;
94
+ maxAttempts?: number;
95
+ /** Backoff before the retry (2^attempt seconds), when `willRetry`. */
96
+ retryDelayMs?: number;
79
97
  }
80
98
  ```
81
99
 
100
+ The consumer's ack/retry decision reads `willRetry`, not `outcome`: a failed attempt with attempts left is `outcome: "failed", willRetry: true`, and the stage row is `PENDING` waiting for the next delivery. A transport whose `fail()` re-enqueues (the built-in Prisma and in-memory queues) has already re-queued the job — acknowledge the message. A push transport whose `fail()` cannot re-enqueue (a Cloudflare Queue consumer has to call `msg.retry()` itself) retries the message after `retryDelayMs`; when `willRetry` is false (settled, or `dead`) acknowledge it. Acknowledging a `willRetry: true` result without retrying leaves the run `RUNNING` until `run.reapStuck` heals it. A message without `payload` or `workflowId` is rejected as a dead job (`dead: true`, failed and acknowledged) instead of throwing.
101
+
82
102
  ### `ProcessJobsResult`
83
103
 
84
104
  ```typescript
@@ -93,13 +113,48 @@ interface ProcessJobsResult {
93
113
 
94
114
  ```typescript
95
115
  interface MaintenanceTickResult {
96
- claimed: number; // Pending runs claimed
97
- suspendedChecked: number; // Suspended stages polled
98
- staleReleased: number; // Stale leases released
99
- eventsFlushed: number; // Outbox events published
116
+ claimed: number; // Pending runs claimed
117
+ suspendedChecked: number; // Suspended stages polled
118
+ staleReleased: number; // Stale leases re-queued (heartbeat tier)
119
+ eventsFlushed: number; // Outbox events published
120
+ stuckReaped: number; // RUNNING runs failed for inactivity
121
+ purged: number; // Terminal runs deleted; 0 unless `retention` is set
122
+ eventsFailed: number; // Events the next flush will retry
123
+ eventsDeadLettered: number; // Events that exhausted their retries (disjoint from eventsFailed)
124
+ eventSinkStatus: "healthy" | "degraded";
125
+ eventSinkError?: string; // First publish failure of this tick, when degraded
100
126
  }
101
127
  ```
102
128
 
129
+ There is no process to carry event-sink state across invocations, so a serverless caller reads `eventSinkStatus` per tick and alerts on a run of `"degraded"` results; runs keep progressing either way.
130
+
131
+ ### `runToCompletion(options)`
132
+
133
+ A supported synchronous drain loop for callers who need the run's result inside the request that created it:
134
+
135
+ ```typescript
136
+ import { runToCompletion } from "@bratsos/workflow-engine-host-serverless";
137
+
138
+ const result = await runToCompletion({
139
+ kernel,
140
+ jobTransport,
141
+ persistence, // { getRun } — the WorkflowPersistence you gave the kernel works
142
+ command: {
143
+ type: "run.create",
144
+ idempotencyKey: crypto.randomUUID(),
145
+ workflowId: "document-processor",
146
+ input: { url: "https://example.com" },
147
+ },
148
+ maxJobs: 50, // default
149
+ maxClaimRounds: 5, // default
150
+ });
151
+
152
+ // { workflowRunId, status, outcome, output?, reason?, jobsProcessed, foreignJobsProcessed, suspendedStageId? }
153
+ if (result.outcome === "completed") return Response.json(result.output);
154
+ ```
155
+
156
+ Its three limits: it shares the queue, so it may execute another caller's job (`foreignJobsProcessed` says how many; give it its own `jobTransport` when that is unacceptable); it cannot finish a workflow that suspends, and returns `outcome: "suspended"` naming the stage instead of spinning; and it is bounded by `maxJobs` and `maxClaimRounds`, returning `outcome: "incomplete"` with a `reason` rather than looping or throwing. It does not poll suspended stages or reap leases — that is `runMaintenanceTick()`.
157
+
103
158
  ## Platform Integration Examples
104
159
 
105
160
  ### Cloudflare Workers (Queue + Cron)
@@ -115,10 +170,11 @@ export default {
115
170
  for (const msg of batch.messages) {
116
171
  const result = await host.handleJob(msg.body);
117
172
 
118
- if (result.outcome === "failed") {
119
- msg.retry();
173
+ if (result.willRetry) {
174
+ // The CF transport's fail() cannot re-enqueue: redeliver after the backoff.
175
+ msg.retry({ delaySeconds: Math.ceil((result.retryDelayMs ?? 0) / 1000) });
120
176
  } else {
121
- msg.ack();
177
+ msg.ack(); // completed, suspended, terminally failed, or dead
122
178
  }
123
179
  }
124
180
  },
@@ -177,11 +233,13 @@ export async function GET() {
177
233
 
178
234
  Unlike the Node host, the serverless host has **no loops, timers, or signal handlers**. Every method is a single stateless invocation:
179
235
 
180
- - **`handleJob(msg)`** -- Dispatches `job.execute` to the kernel, then marks the job complete/suspended/failed via `jobTransport`. On completion, also dispatches `run.transition` to advance the workflow.
236
+ - **`handleJob(msg)`** -- Dispatches `job.execute` to the kernel, then marks the job complete/suspended/failed via `jobTransport`. On completion, also dispatches `run.transition` to advance the workflow, and (unless `flushOutboxAfterJob: false`) runs one bounded `outbox.flush` so the run's events are published by this invocation instead of by the next maintenance tick — there is no process lifecycle to hook a final flush on. A failure with attempts left (`msg.attempt < msg.maxAttempts`, default 3) leaves the stage `PENDING` with the error, emits `stage:retrying`, calls `jobTransport.fail(jobId, error, true)` and returns `willRetry: true` with `retryDelayMs`: either your transport re-enqueues the message with backoff from `fail()`, or your consumer retries it from the result (acknowledging it without a retry strands the run as `RUNNING` until `run.reapStuck`). When no attempts remain the stage is `FAILED`, `stage:failed` is emitted, and `run.transition` fails the run immediately with the stage error. A malformed message is failed and acknowledged as a dead job (`dead: true`).
181
237
 
182
238
  - **`processAvailableJobs(opts?)`** -- Dequeues up to `maxJobs` (default: 1) from the job transport and processes each via `handleJob`. Safe for edge runtimes with CPU limits.
183
239
 
184
- - **`runMaintenanceTick()`** -- Runs one bounded pass of all orchestration duties: `run.claimPending`, `stage.pollSuspended`, `lease.reapStale`, `outbox.flush`.
240
+ - **`runMaintenanceTick()`** -- Runs one bounded pass of all orchestration duties: `run.claimPending` (filtered by `serves`), `stage.pollSuspended`, `lease.reapStale` (both tiers), `outbox.flush`, `run.reapStuck`, and `run.purge` when `retention` is set. A run pinned to a definition version this build does not serve is left untouched for a host that does.
241
+
242
+ - **Cancellation** -- `handleJob` dispatches `job.heartbeat` every `jobHeartbeatIntervalMs` while the stage runs; when the run is cancelled or the job lease is lost, the stage's `ctx.abortSignal` aborts with a `StageAbortedError` (`reason: "cancelled" | "lease-lost"`). Acknowledgements are fenced on `{ startedAt, attempt }`, so an invocation that outlived its lease writes nothing and reports `superseded`.
185
243
 
186
244
  Consumers wire platform-specific glue (queue ack/retry, `waitUntil`, cron triggers) around these methods.
187
245
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Kernel, JobTransport } from '@bratsos/workflow-engine/kernel';
1
+ import { EventSinkStatus, Kernel, JobTransport, ServedDefinition, RetentionOptions, RunCreateCommand } from '@bratsos/workflow-engine/kernel';
2
2
 
3
3
  /**
4
4
  * Serverless Host for Workflow Engine Command Kernel
@@ -21,12 +21,43 @@ interface ServerlessHostConfig {
21
21
  workerId: string;
22
22
  /** Stale lease threshold in milliseconds (default: 300_000). */
23
23
  staleLeaseThresholdMs?: number;
24
+ /** Absolute cap (ms) on one job claim (default: HOST_DEFAULTS.jobAbsoluteTimeoutMs). */
25
+ jobAbsoluteTimeoutMs?: number;
24
26
  /** Max pending runs to claim per maintenance tick (default: 10). */
25
27
  maxClaimsPerTick?: number;
28
+ /**
29
+ * Which definition versions this host may adopt and poll. Forwarded to
30
+ * `run.claimPending` and `stage.pollSuspended`.
31
+ *
32
+ * Left unset — the right default — the kernel derives it from the
33
+ * registry: a registry built with `createWorkflowRegistry` claims only
34
+ * runs this build can execute, which is what makes a rolling deploy safe.
35
+ * Pass `"all"` to claim and poll regardless of version (the pre-1.0
36
+ * behaviour), or an explicit list to run maintenance on behalf of
37
+ * another build.
38
+ */
39
+ serves?: readonly ServedDefinition[] | "all";
26
40
  /** Max suspended stages to check per tick (default: 10). */
27
41
  maxSuspendedChecksPerTick?: number;
28
42
  /** Max outbox events to flush per tick (default: 100). */
29
43
  maxOutboxFlushPerTick?: number;
44
+ /**
45
+ * Delete finished runs older than `retention.olderThanMs` (with their
46
+ * stages, logs, artifacts, annotations, step ledger, job rows and blobs)
47
+ * through `run.purge` at the end of every maintenance tick. Off by
48
+ * default: nothing is deleted unless this is set.
49
+ */
50
+ retention?: RetentionOptions;
51
+ /**
52
+ * Publish this job's outbox events right after `handleJob` settles it
53
+ * (default: true). There is no process lifecycle to hook a final flush
54
+ * on, so without this a run completed by this invocation is announced by
55
+ * whichever invocation runs the next maintenance tick. Bounded by
56
+ * `outboxFlushTimeoutMs`; errors are logged, never thrown.
57
+ */
58
+ flushOutboxAfterJob?: boolean;
59
+ /** Upper bound (ms) on the post-job outbox flush (default: 5_000). */
60
+ outboxFlushTimeoutMs?: number;
30
61
  /** Job lease heartbeat interval in milliseconds (default: 60_000). */
31
62
  jobHeartbeatIntervalMs?: number;
32
63
  }
@@ -40,9 +71,26 @@ interface JobMessage {
40
71
  maxAttempts?: number;
41
72
  payload: Record<string, unknown>;
42
73
  }
74
+ /**
75
+ * Outcome of `handleJob`. The consumer's ack/retry decision reads
76
+ * `willRetry`: when true the stage was left `PENDING` and the job must run
77
+ * again — a transport whose `fail()` re-enqueues (the built-in queues) has
78
+ * already done so and the message can be acknowledged; a push transport
79
+ * whose `fail()` cannot (a queue consumer that must `retry()` the message
80
+ * itself) retries the message after `retryDelayMs`. When false the job is
81
+ * settled: acknowledge it.
82
+ */
43
83
  interface JobResult {
44
84
  outcome: "completed" | "suspended" | "failed";
45
85
  error?: string;
86
+ /** The message was an orphan or malformed; it was failed and acknowledged. */
87
+ dead?: boolean;
88
+ willRetry?: boolean;
89
+ /** The job attempt that ran (1 on the first execution). */
90
+ attempt?: number;
91
+ maxAttempts?: number;
92
+ /** Backoff before the retry (`2^attempt` seconds), when `willRetry`. */
93
+ retryDelayMs?: number;
46
94
  }
47
95
  interface ProcessJobsResult {
48
96
  processed: number;
@@ -55,6 +103,21 @@ interface MaintenanceTickResult {
55
103
  staleReleased: number;
56
104
  eventsFlushed: number;
57
105
  stuckReaped: number;
106
+ /** Terminal runs deleted by `run.purge`; 0 unless `retention` is configured. */
107
+ purged: number;
108
+ /** Events claimed but not published this tick; the next flush retries them. */
109
+ eventsFailed: number;
110
+ /** Events that exhausted their retry budget and moved to the DLQ. */
111
+ eventsDeadLettered: number;
112
+ /**
113
+ * `"degraded"` when the event sink refused at least one event this tick.
114
+ * There is no process to carry the state across invocations, so a
115
+ * serverless caller reports this per tick (and alerts on a run of them);
116
+ * runs keep progressing either way.
117
+ */
118
+ eventSinkStatus: EventSinkStatus;
119
+ /** First publish failure of this tick, when degraded. */
120
+ eventSinkError?: string;
58
121
  }
59
122
  interface ServerlessHost {
60
123
  /** Execute a single pre-dequeued job. Returns outcome so consumer can ack/retry. */
@@ -72,4 +135,140 @@ interface ServerlessHost {
72
135
  }
73
136
  declare function createServerlessHost(config: ServerlessHostConfig): ServerlessHost;
74
137
 
75
- export { type JobMessage, type JobResult, type MaintenanceTickResult, type ProcessJobsResult, type ServerlessHost, type ServerlessHostConfig, createServerlessHost };
138
+ /** The one read `runToCompletion` needs: how did the run end. */
139
+ interface RunToCompletionPersistence {
140
+ getRun(id: string): Promise<{
141
+ status: string;
142
+ output: unknown | null;
143
+ } | null>;
144
+ }
145
+ /**
146
+ * Why `runToCompletion` stopped.
147
+ *
148
+ * The first three are the run's own terminal states. The last two are not
149
+ * failures — they say the run is still alive and something else has to carry
150
+ * it, which is the honest answer for a helper that must return inside a
151
+ * request.
152
+ */
153
+ type RunToCompletionOutcome = "completed" | "failed" | "cancelled"
154
+ /** A stage suspended (a durable sleep, wait or signal). See the caveats. */
155
+ | "suspended"
156
+ /** The budget ran out, or nothing was runnable yet. See `reason`. */
157
+ | "incomplete";
158
+ interface RunToCompletionResult {
159
+ workflowRunId: string;
160
+ /** The run's persisted status when the helper returned. */
161
+ status: string;
162
+ outcome: RunToCompletionOutcome;
163
+ /** The run's output, when it completed. */
164
+ output?: unknown;
165
+ /** Plain-language explanation, always present when `outcome` is not terminal. */
166
+ reason?: string;
167
+ /** Jobs executed by this call, this run's and other callers' alike. */
168
+ jobsProcessed: number;
169
+ /**
170
+ * How many of `jobsProcessed` belonged to a different run. Non-zero means
171
+ * this call did work on someone else's behalf — see the shared-queue
172
+ * caveat.
173
+ */
174
+ foreignJobsProcessed: number;
175
+ /** The stage that suspended, when `outcome` is `"suspended"`. */
176
+ suspendedStageId?: string;
177
+ }
178
+ interface RunToCompletionOptions {
179
+ kernel: Kernel;
180
+ /** The same transport the kernel enqueues onto. */
181
+ jobTransport: JobTransport;
182
+ /** Read port for the run's final status. */
183
+ persistence: RunToCompletionPersistence;
184
+ /** The run to create, exactly as `kernel.dispatch` takes it. */
185
+ command: RunCreateCommand;
186
+ /** Worker id used for the claim and the job leases. Defaults to `"run-to-completion"`. */
187
+ workerId?: string;
188
+ /**
189
+ * Hard cap on jobs this call executes. Defaults to 50. Reaching it returns
190
+ * `outcome: "incomplete"`; it never throws and never loops past the cap.
191
+ */
192
+ maxJobs?: number;
193
+ /**
194
+ * How many `run.claimPending` rounds to spend looking for this run's first
195
+ * job. Defaults to 5. Each round claims up to `claimsPerRound` pending
196
+ * runs — including other callers' (see the caveats).
197
+ */
198
+ maxClaimRounds?: number;
199
+ /** Pending runs claimed per round. Defaults to 10. */
200
+ claimsPerRound?: number;
201
+ /** Job lease heartbeat interval. Defaults to `HOST_DEFAULTS.jobHeartbeatIntervalMs`. */
202
+ jobHeartbeatIntervalMs?: number;
203
+ /**
204
+ * Publish the run's outbox events before returning (default true), so a
205
+ * caller that returns the result straight to an HTTP client has already
206
+ * announced the run.
207
+ */
208
+ flushOutbox?: boolean;
209
+ /** Max events published by that final flush. Defaults to 100. */
210
+ maxOutboxFlush?: number;
211
+ /** Log prefix for the job loop. Defaults to `"[runToCompletion]"`. */
212
+ logPrefix?: string;
213
+ }
214
+ /**
215
+ * Create a workflow run and drive it to a terminal state inside the calling
216
+ * request — the drain loop every caller writes by hand, exactly once.
217
+ *
218
+ * It creates the run, claims it, then dequeues and executes jobs through the
219
+ * same `executeJobWithHeartbeat` the hosts use, until the run is terminal,
220
+ * a stage suspends, nothing more is runnable, or a bound trips.
221
+ *
222
+ * ## Three things to know before you use it
223
+ *
224
+ * **1. It shares the queue, so it can execute another caller's job.** The
225
+ * kernel's queue is global: `run.claimPending` claims whichever runs are
226
+ * pending, and `dequeue()` returns whichever job is next — neither can be
227
+ * narrowed to one run. So this call may start other callers' runs and
228
+ * execute their jobs against its own budget, and their work lands inside
229
+ * your request's latency and error budget. `foreignJobsProcessed` reports
230
+ * how much of that happened. If it matters, give this call a kernel wired to
231
+ * its own `jobTransport` (an in-request queue) so the only jobs it can see
232
+ * are the ones it created.
233
+ *
234
+ * **2. It cannot complete a workflow that suspends.** A durable sleep, wait
235
+ * or signal parks the stage until a later poll, which by definition is not
236
+ * this request. Rather than spin — polling a clock that has not moved, or
237
+ * sleeping inside a request handler — it returns immediately with
238
+ * `outcome: "suspended"` and the stage that parked. The run is healthy and
239
+ * unfinished: a background host or a scheduled maintenance tick resumes it.
240
+ * Workflows you intend to run this way should have no durable waits.
241
+ *
242
+ * **3. It is bounded, never an unbounded loop.** `maxJobs` caps the jobs one
243
+ * call executes and `maxClaimRounds` caps the search for this run's first
244
+ * job. Hitting either returns `outcome: "incomplete"` with a `reason`; it
245
+ * does not throw, and it does not keep going. The same is true when a stage
246
+ * fails and is re-enqueued with a retry backoff: the job is not due yet, the
247
+ * queue is empty, and the call returns `"incomplete"` rather than waiting.
248
+ *
249
+ * It also deliberately does not poll suspended stages or reap stale leases.
250
+ * Those are maintenance, they are what would make it spin, and
251
+ * `host.runMaintenanceTick()` already owns them.
252
+ *
253
+ * @example
254
+ * ```typescript
255
+ * const result = await runToCompletion({
256
+ * kernel,
257
+ * jobTransport,
258
+ * persistence,
259
+ * command: {
260
+ * type: "run.create",
261
+ * idempotencyKey: `checkout:${orderId}`,
262
+ * workflowId: "checkout",
263
+ * input: { orderId },
264
+ * },
265
+ * });
266
+ *
267
+ * if (result.outcome === "completed") return Response.json(result.output);
268
+ * if (result.outcome === "suspended") return new Response(null, { status: 202 });
269
+ * return new Response(result.reason ?? "run failed", { status: 500 });
270
+ * ```
271
+ */
272
+ declare function runToCompletion(options: RunToCompletionOptions): Promise<RunToCompletionResult>;
273
+
274
+ export { type JobMessage, type JobResult, type MaintenanceTickResult, type ProcessJobsResult, type RunToCompletionOptions, type RunToCompletionOutcome, type RunToCompletionPersistence, type RunToCompletionResult, type ServerlessHost, type ServerlessHostConfig, createServerlessHost, runToCompletion };
package/dist/index.js CHANGED
@@ -6,27 +6,76 @@ var ServerlessHostImpl = class {
6
6
  jobTransport;
7
7
  workerId;
8
8
  staleLeaseThresholdMs;
9
+ jobAbsoluteTimeoutMs;
9
10
  maxClaimsPerTick;
11
+ /** See `HostConfig.serves`; `undefined` means "derive from the registry". */
12
+ serves;
10
13
  maxSuspendedChecksPerTick;
11
14
  maxOutboxFlushPerTick;
15
+ retention;
12
16
  jobHeartbeatIntervalMs;
17
+ flushOutboxAfterJob;
18
+ outboxFlushTimeoutMs;
13
19
  constructor(config) {
14
20
  this.kernel = config.kernel;
15
21
  this.jobTransport = config.jobTransport;
16
22
  this.workerId = config.workerId;
17
23
  this.staleLeaseThresholdMs = config.staleLeaseThresholdMs ?? HOST_DEFAULTS.staleLeaseThresholdMs;
24
+ this.jobAbsoluteTimeoutMs = config.jobAbsoluteTimeoutMs ?? HOST_DEFAULTS.jobAbsoluteTimeoutMs;
18
25
  this.maxClaimsPerTick = config.maxClaimsPerTick ?? HOST_DEFAULTS.maxClaimsPerTick;
26
+ this.serves = config.serves;
19
27
  this.maxSuspendedChecksPerTick = config.maxSuspendedChecksPerTick ?? HOST_DEFAULTS.maxSuspendedChecksPerTick;
20
28
  this.maxOutboxFlushPerTick = config.maxOutboxFlushPerTick ?? HOST_DEFAULTS.maxOutboxFlushPerTick;
29
+ this.retention = config.retention;
21
30
  this.jobHeartbeatIntervalMs = config.jobHeartbeatIntervalMs ?? HOST_DEFAULTS.jobHeartbeatIntervalMs;
31
+ this.flushOutboxAfterJob = config.flushOutboxAfterJob ?? true;
32
+ this.outboxFlushTimeoutMs = config.outboxFlushTimeoutMs ?? 5e3;
22
33
  }
23
34
  async handleJob(msg) {
24
- return executeJobWithHeartbeat(this.kernel, {
35
+ const result = await executeJobWithHeartbeat(this.kernel, {
25
36
  jobTransport: this.jobTransport,
26
37
  job: msg,
27
38
  jobHeartbeatIntervalMs: this.jobHeartbeatIntervalMs,
28
39
  logPrefix: "[ServerlessHost]"
29
40
  });
41
+ if (this.flushOutboxAfterJob) {
42
+ await this.flushOutbox();
43
+ }
44
+ return result;
45
+ }
46
+ /** Publish pending outbox events, bounded by `outboxFlushTimeoutMs`. */
47
+ async flushOutbox() {
48
+ let timer;
49
+ const timeout = new Promise((resolve) => {
50
+ timer = setTimeout(() => resolve("timeout"), this.outboxFlushTimeoutMs);
51
+ });
52
+ try {
53
+ const outcome = await Promise.race([
54
+ this.kernel.dispatch({
55
+ type: "outbox.flush",
56
+ maxEvents: this.maxOutboxFlushPerTick
57
+ }),
58
+ timeout
59
+ ]);
60
+ if (outcome === "timeout") {
61
+ console.error(
62
+ "[ServerlessHost] outbox.flush after job: timed out; the next maintenance tick publishes the rest"
63
+ );
64
+ } else if (outcome.eventSinkStatus === "degraded") {
65
+ console.error(
66
+ `[ServerlessHost] event sink DEGRADED: ${outcome.failed} event(s) stayed in the outbox for a later flush; runs keep progressing. Last error: ${outcome.eventSinkError ?? "unknown"}`
67
+ );
68
+ }
69
+ if (outcome !== "timeout" && outcome.deadLettered > 0) {
70
+ console.error(
71
+ `[ServerlessHost] event sink DEAD-LETTERED ${outcome.deadLettered} event(s): they will not be delivered until replayed with the plugin.replayDLQ command`
72
+ );
73
+ }
74
+ } catch (error) {
75
+ console.error("[ServerlessHost] outbox.flush after job error:", error);
76
+ } finally {
77
+ if (timer) clearTimeout(timer);
78
+ }
30
79
  }
31
80
  async processAvailableJobs(opts) {
32
81
  const maxJobs = opts?.maxJobs ?? 1;
@@ -34,7 +83,10 @@ var ServerlessHostImpl = class {
34
83
  let succeeded = 0;
35
84
  let failed = 0;
36
85
  while (processed < maxJobs) {
37
- const job = await this.jobTransport.dequeue();
86
+ const serves = this.dequeueServes();
87
+ const job = await this.jobTransport.dequeue(
88
+ serves !== void 0 ? { serves } : void 0
89
+ );
38
90
  if (!job) break;
39
91
  const result = await this.handleJob({
40
92
  jobId: job.jobId,
@@ -54,21 +106,152 @@ var ServerlessHostImpl = class {
54
106
  }
55
107
  return { processed, succeeded, failed };
56
108
  }
109
+ /**
110
+ * What to pass to `jobTransport.dequeue`. `"all"` means claim regardless
111
+ * of version, which is expressed by passing nothing; otherwise the
112
+ * kernel's registry answers, so the dequeue narrows on exactly what
113
+ * `run.claimPending` narrows on.
114
+ */
115
+ dequeueServes() {
116
+ if (this.serves === "all") return void 0;
117
+ return this.serves ?? this.kernel.servedDefinitions?.();
118
+ }
57
119
  async runMaintenanceTick() {
58
- return runMaintenanceTick(this.kernel, {
120
+ const counts = await runMaintenanceTick(this.kernel, {
59
121
  workerId: this.workerId,
60
122
  maxClaimsPerTick: this.maxClaimsPerTick,
123
+ ...this.serves !== void 0 ? { serves: this.serves } : {},
61
124
  maxSuspendedChecksPerTick: this.maxSuspendedChecksPerTick,
62
125
  maxOutboxFlushPerTick: this.maxOutboxFlushPerTick,
63
126
  staleLeaseThresholdMs: this.staleLeaseThresholdMs,
127
+ jobAbsoluteTimeoutMs: this.jobAbsoluteTimeoutMs,
128
+ ...this.retention !== void 0 ? { retention: this.retention } : {},
64
129
  logPrefix: "[ServerlessHost]"
65
130
  });
131
+ if (counts.eventSinkStatus === "degraded") {
132
+ console.error(
133
+ `[ServerlessHost] event sink DEGRADED: ${counts.eventsFailed} event(s) stayed in the outbox for a later flush; runs keep progressing. Last error: ${counts.eventSinkError ?? "unknown"}`
134
+ );
135
+ }
136
+ if (counts.eventsDeadLettered > 0) {
137
+ console.error(
138
+ `[ServerlessHost] event sink DEAD-LETTERED ${counts.eventsDeadLettered} event(s): they will not be delivered until replayed with the plugin.replayDLQ command`
139
+ );
140
+ }
141
+ return counts;
66
142
  }
67
143
  };
68
144
  function createServerlessHost(config) {
69
145
  return new ServerlessHostImpl(config);
70
146
  }
147
+ var TERMINAL = {
148
+ COMPLETED: "completed",
149
+ FAILED: "failed",
150
+ CANCELLED: "cancelled"
151
+ };
152
+ async function runToCompletion(options) {
153
+ const {
154
+ kernel,
155
+ jobTransport,
156
+ persistence,
157
+ command,
158
+ workerId = "run-to-completion",
159
+ maxJobs = 50,
160
+ maxClaimRounds = 5,
161
+ claimsPerRound = 10,
162
+ jobHeartbeatIntervalMs = HOST_DEFAULTS.jobHeartbeatIntervalMs,
163
+ flushOutbox = true,
164
+ maxOutboxFlush = HOST_DEFAULTS.maxOutboxFlushPerTick,
165
+ logPrefix = "[runToCompletion]"
166
+ } = options;
167
+ const created = await kernel.dispatch(command);
168
+ const workflowRunId = created.workflowRunId;
169
+ let jobsProcessed = 0;
170
+ let foreignJobsProcessed = 0;
171
+ let suspendedStageId;
172
+ let reason;
173
+ let claimed = false;
174
+ for (let round = 0; round < maxClaimRounds && !claimed; round++) {
175
+ const result = await kernel.dispatch({
176
+ type: "run.claimPending",
177
+ workerId,
178
+ maxClaims: claimsPerRound
179
+ });
180
+ claimed = result.claimed.some(
181
+ (entry) => entry.workflowRunId === workflowRunId
182
+ );
183
+ if (!claimed && result.claimed.length === 0) break;
184
+ }
185
+ if (!claimed) {
186
+ reason = `run ${workflowRunId} was not claimed within ${maxClaimRounds} claim round(s): another worker claimed it, and it will finish there`;
187
+ }
188
+ while (claimed && jobsProcessed < maxJobs) {
189
+ const job = await jobTransport.dequeue();
190
+ if (!job) {
191
+ reason = `no runnable job remained (${jobsProcessed} executed)`;
192
+ break;
193
+ }
194
+ const outcome = await executeJobWithHeartbeat(kernel, {
195
+ jobTransport,
196
+ job: {
197
+ jobId: job.jobId,
198
+ workflowRunId: job.workflowRunId,
199
+ workflowId: job.workflowId,
200
+ stageId: job.stageId,
201
+ attempt: job.attempt,
202
+ maxAttempts: job.maxAttempts,
203
+ payload: job.payload,
204
+ startedAt: job.startedAt
205
+ },
206
+ jobHeartbeatIntervalMs,
207
+ logPrefix
208
+ });
209
+ jobsProcessed++;
210
+ const mine = job.workflowRunId === workflowRunId;
211
+ if (!mine) foreignJobsProcessed++;
212
+ if (mine && outcome.outcome === "suspended") {
213
+ suspendedStageId = job.stageId;
214
+ reason = `stage "${job.stageId}" suspended on a durable wait; the run resumes on a later poll, not in this request`;
215
+ break;
216
+ }
217
+ }
218
+ if (claimed && jobsProcessed >= maxJobs && !suspendedStageId) {
219
+ reason = `job budget of ${maxJobs} exhausted before the run finished`;
220
+ }
221
+ if (flushOutbox) {
222
+ try {
223
+ await kernel.dispatch({
224
+ type: "outbox.flush",
225
+ maxEvents: maxOutboxFlush
226
+ });
227
+ } catch (error) {
228
+ console.error(`${logPrefix} outbox.flush failed:`, error);
229
+ }
230
+ }
231
+ const record = await persistence.getRun(workflowRunId);
232
+ const status = record?.status ?? "UNKNOWN";
233
+ const terminal = TERMINAL[status];
234
+ if (terminal) {
235
+ return {
236
+ workflowRunId,
237
+ status,
238
+ outcome: terminal,
239
+ ...record?.output != null ? { output: record.output } : {},
240
+ jobsProcessed,
241
+ foreignJobsProcessed
242
+ };
243
+ }
244
+ return {
245
+ workflowRunId,
246
+ status,
247
+ outcome: suspendedStageId ? "suspended" : "incomplete",
248
+ reason: reason ?? `the run is still ${status}`,
249
+ jobsProcessed,
250
+ foreignJobsProcessed,
251
+ ...suspendedStageId ? { suspendedStageId } : {}
252
+ };
253
+ }
71
254
 
72
- export { createServerlessHost };
255
+ export { createServerlessHost, runToCompletion };
73
256
  //# sourceMappingURL=index.js.map
74
257
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/host.ts"],"names":["runMaintenanceTickCommands"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Serverless Host for Workflow Engine Command Kernel\n *\n * Platform-agnostic host for serverless environments (Cloudflare Workers,\n * AWS Lambda, Vercel Edge, Deno Deploy, etc.). Unlike the Node host, there\n * are no timers, loops, or signal handlers — every method is a single\n * stateless invocation.\n *\n * Consumers wire platform-specific glue (queue ack/retry, waitUntil,\n * cron triggers) around these methods.\n */\n\nimport {\n executeJobWithHeartbeat,\n HOST_DEFAULTS,\n type JobTransport,\n type Kernel,\n runMaintenanceTick as runMaintenanceTickCommands,\n} from \"@bratsos/workflow-engine/kernel\";\n\n// ============================================================================\n// Public interfaces\n// ============================================================================\n\nexport interface ServerlessHostConfig {\n /** Kernel instance to dispatch commands to. */\n kernel: Kernel;\n\n /** Job transport for complete/suspend/fail lifecycle. */\n jobTransport: JobTransport;\n\n /** Unique worker identifier (e.g. function name, worker name). */\n workerId: string;\n\n /** Stale lease threshold in milliseconds (default: 300_000). */\n staleLeaseThresholdMs?: number;\n\n /** Max pending runs to claim per maintenance tick (default: 10). */\n maxClaimsPerTick?: number;\n\n /** Max suspended stages to check per tick (default: 10). */\n maxSuspendedChecksPerTick?: number;\n\n /** Max outbox events to flush per tick (default: 100). */\n maxOutboxFlushPerTick?: number;\n\n /** Job lease heartbeat interval in milliseconds (default: 60_000). */\n jobHeartbeatIntervalMs?: number;\n}\n\n/** Message shape representing a job to execute. Matches DequeueResult fields. */\nexport interface JobMessage {\n jobId: string;\n workflowRunId: string;\n workflowId: string;\n stageId: string;\n attempt: number;\n maxAttempts?: number;\n payload: Record<string, unknown>;\n}\n\nexport interface JobResult {\n outcome: \"completed\" | \"suspended\" | \"failed\";\n error?: string;\n}\n\nexport interface ProcessJobsResult {\n processed: number;\n succeeded: number;\n failed: number;\n}\n\nexport interface MaintenanceTickResult {\n claimed: number;\n suspendedChecked: number;\n staleReleased: number;\n eventsFlushed: number;\n stuckReaped: number;\n}\n\nexport interface ServerlessHost {\n /** Execute a single pre-dequeued job. Returns outcome so consumer can ack/retry. */\n handleJob(msg: JobMessage): Promise<JobResult>;\n\n /**\n * Dequeue and process jobs from the jobTransport.\n * Defaults to 1 job per call (safe for edge runtimes with CPU limits).\n * Pass maxJobs for longer-running environments like Lambda.\n */\n processAvailableJobs(opts?: { maxJobs?: number }): Promise<ProcessJobsResult>;\n\n /** Run one bounded maintenance tick (claim, poll, reap, flush). */\n runMaintenanceTick(): Promise<MaintenanceTickResult>;\n}\n\n// ============================================================================\n// Implementation\n// ============================================================================\n\nclass ServerlessHostImpl implements ServerlessHost {\n private readonly kernel: Kernel;\n private readonly jobTransport: JobTransport;\n private readonly workerId: string;\n private readonly staleLeaseThresholdMs: number;\n private readonly maxClaimsPerTick: number;\n private readonly maxSuspendedChecksPerTick: number;\n private readonly maxOutboxFlushPerTick: number;\n private readonly jobHeartbeatIntervalMs: number;\n\n constructor(config: ServerlessHostConfig) {\n this.kernel = config.kernel;\n this.jobTransport = config.jobTransport;\n this.workerId = config.workerId;\n this.staleLeaseThresholdMs =\n config.staleLeaseThresholdMs ?? HOST_DEFAULTS.staleLeaseThresholdMs;\n this.maxClaimsPerTick =\n config.maxClaimsPerTick ?? HOST_DEFAULTS.maxClaimsPerTick;\n this.maxSuspendedChecksPerTick =\n config.maxSuspendedChecksPerTick ??\n HOST_DEFAULTS.maxSuspendedChecksPerTick;\n this.maxOutboxFlushPerTick =\n config.maxOutboxFlushPerTick ?? HOST_DEFAULTS.maxOutboxFlushPerTick;\n this.jobHeartbeatIntervalMs =\n config.jobHeartbeatIntervalMs ?? HOST_DEFAULTS.jobHeartbeatIntervalMs;\n }\n\n async handleJob(msg: JobMessage): Promise<JobResult> {\n // Dispatch job.execute under a lease heartbeat and route the outcome\n // (complete/suspend/fail + terminal run.transition) — see\n // executeJobWithHeartbeat in @bratsos/workflow-engine/kernel for the\n // shared command sequence.\n return executeJobWithHeartbeat(this.kernel, {\n jobTransport: this.jobTransport,\n job: msg,\n jobHeartbeatIntervalMs: this.jobHeartbeatIntervalMs,\n logPrefix: \"[ServerlessHost]\",\n });\n }\n\n async processAvailableJobs(opts?: {\n maxJobs?: number;\n }): Promise<ProcessJobsResult> {\n const maxJobs = opts?.maxJobs ?? 1;\n let processed = 0;\n let succeeded = 0;\n let failed = 0;\n\n while (processed < maxJobs) {\n const job = await this.jobTransport.dequeue();\n if (!job) break;\n\n const result = await this.handleJob({\n jobId: job.jobId,\n workflowRunId: job.workflowRunId,\n workflowId: job.workflowId,\n stageId: job.stageId,\n attempt: job.attempt,\n maxAttempts: job.maxAttempts,\n payload: job.payload,\n });\n\n processed++;\n if (result.outcome === \"failed\") {\n failed++;\n } else {\n succeeded++;\n }\n }\n\n return { processed, succeeded, failed };\n }\n\n async runMaintenanceTick(): Promise<MaintenanceTickResult> {\n // Claim pending runs, poll suspended stages, reap stale leases, flush\n // the outbox, and reap stuck runs — see runMaintenanceTick in\n // @bratsos/workflow-engine/kernel for the shared command sequence. The\n // serverless host returns the per-command counts to its caller (unlike\n // the Node host, which fires this on a timer and ignores them).\n return runMaintenanceTickCommands(this.kernel, {\n workerId: this.workerId,\n maxClaimsPerTick: this.maxClaimsPerTick,\n maxSuspendedChecksPerTick: this.maxSuspendedChecksPerTick,\n maxOutboxFlushPerTick: this.maxOutboxFlushPerTick,\n staleLeaseThresholdMs: this.staleLeaseThresholdMs,\n logPrefix: \"[ServerlessHost]\",\n });\n }\n}\n\n// ============================================================================\n// Factory\n// ============================================================================\n\nexport function createServerlessHost(\n config: ServerlessHostConfig,\n): ServerlessHost {\n return new ServerlessHostImpl(config);\n}\n"]}
1
+ {"version":3,"sources":["../src/host.ts","../src/run-to-completion.ts"],"names":["runMaintenanceTickCommands","HOST_DEFAULTS","executeJobWithHeartbeat"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Serverless Host for Workflow Engine Command Kernel\n *\n * Platform-agnostic host for serverless environments (Cloudflare Workers,\n * AWS Lambda, Vercel Edge, Deno Deploy, etc.). Unlike the Node host, there\n * are no timers, loops, or signal handlers — every method is a single\n * stateless invocation.\n *\n * Consumers wire platform-specific glue (queue ack/retry, waitUntil,\n * cron triggers) around these methods.\n */\n\nimport {\n type EventSinkStatus,\n executeJobWithHeartbeat,\n HOST_DEFAULTS,\n type JobTransport,\n type Kernel,\n type RetentionOptions,\n runMaintenanceTick as runMaintenanceTickCommands,\n type ServedDefinition,\n} from \"@bratsos/workflow-engine/kernel\";\n\n// ============================================================================\n// Public interfaces\n// ============================================================================\n\nexport interface ServerlessHostConfig {\n /** Kernel instance to dispatch commands to. */\n kernel: Kernel;\n\n /** Job transport for complete/suspend/fail lifecycle. */\n jobTransport: JobTransport;\n\n /** Unique worker identifier (e.g. function name, worker name). */\n workerId: string;\n\n /** Stale lease threshold in milliseconds (default: 300_000). */\n staleLeaseThresholdMs?: number;\n\n /** Absolute cap (ms) on one job claim (default: HOST_DEFAULTS.jobAbsoluteTimeoutMs). */\n jobAbsoluteTimeoutMs?: number;\n\n /** Max pending runs to claim per maintenance tick (default: 10). */\n maxClaimsPerTick?: number;\n\n /**\n * Which definition versions this host may adopt and poll. Forwarded to\n * `run.claimPending` and `stage.pollSuspended`.\n *\n * Left unset — the right default — the kernel derives it from the\n * registry: a registry built with `createWorkflowRegistry` claims only\n * runs this build can execute, which is what makes a rolling deploy safe.\n * Pass `\"all\"` to claim and poll regardless of version (the pre-1.0\n * behaviour), or an explicit list to run maintenance on behalf of\n * another build.\n */\n serves?: readonly ServedDefinition[] | \"all\";\n\n /** Max suspended stages to check per tick (default: 10). */\n maxSuspendedChecksPerTick?: number;\n\n /** Max outbox events to flush per tick (default: 100). */\n maxOutboxFlushPerTick?: number;\n\n /**\n * Delete finished runs older than `retention.olderThanMs` (with their\n * stages, logs, artifacts, annotations, step ledger, job rows and blobs)\n * through `run.purge` at the end of every maintenance tick. Off by\n * default: nothing is deleted unless this is set.\n */\n retention?: RetentionOptions;\n\n /**\n * Publish this job's outbox events right after `handleJob` settles it\n * (default: true). There is no process lifecycle to hook a final flush\n * on, so without this a run completed by this invocation is announced by\n * whichever invocation runs the next maintenance tick. Bounded by\n * `outboxFlushTimeoutMs`; errors are logged, never thrown.\n */\n flushOutboxAfterJob?: boolean;\n\n /** Upper bound (ms) on the post-job outbox flush (default: 5_000). */\n outboxFlushTimeoutMs?: number;\n\n /** Job lease heartbeat interval in milliseconds (default: 60_000). */\n jobHeartbeatIntervalMs?: number;\n}\n\n/** Message shape representing a job to execute. Matches DequeueResult fields. */\nexport interface JobMessage {\n jobId: string;\n workflowRunId: string;\n workflowId: string;\n stageId: string;\n attempt: number;\n maxAttempts?: number;\n payload: Record<string, unknown>;\n}\n\n/**\n * Outcome of `handleJob`. The consumer's ack/retry decision reads\n * `willRetry`: when true the stage was left `PENDING` and the job must run\n * again — a transport whose `fail()` re-enqueues (the built-in queues) has\n * already done so and the message can be acknowledged; a push transport\n * whose `fail()` cannot (a queue consumer that must `retry()` the message\n * itself) retries the message after `retryDelayMs`. When false the job is\n * settled: acknowledge it.\n */\nexport interface JobResult {\n outcome: \"completed\" | \"suspended\" | \"failed\";\n error?: string;\n /** The message was an orphan or malformed; it was failed and acknowledged. */\n dead?: boolean;\n willRetry?: boolean;\n /** The job attempt that ran (1 on the first execution). */\n attempt?: number;\n maxAttempts?: number;\n /** Backoff before the retry (`2^attempt` seconds), when `willRetry`. */\n retryDelayMs?: number;\n}\n\nexport interface ProcessJobsResult {\n processed: number;\n succeeded: number;\n failed: number;\n}\n\nexport interface MaintenanceTickResult {\n claimed: number;\n suspendedChecked: number;\n staleReleased: number;\n eventsFlushed: number;\n stuckReaped: number;\n /** Terminal runs deleted by `run.purge`; 0 unless `retention` is configured. */\n purged: number;\n /** Events claimed but not published this tick; the next flush retries them. */\n eventsFailed: number;\n /** Events that exhausted their retry budget and moved to the DLQ. */\n eventsDeadLettered: number;\n /**\n * `\"degraded\"` when the event sink refused at least one event this tick.\n * There is no process to carry the state across invocations, so a\n * serverless caller reports this per tick (and alerts on a run of them);\n * runs keep progressing either way.\n */\n eventSinkStatus: EventSinkStatus;\n /** First publish failure of this tick, when degraded. */\n eventSinkError?: string;\n}\n\nexport interface ServerlessHost {\n /** Execute a single pre-dequeued job. Returns outcome so consumer can ack/retry. */\n handleJob(msg: JobMessage): Promise<JobResult>;\n\n /**\n * Dequeue and process jobs from the jobTransport.\n * Defaults to 1 job per call (safe for edge runtimes with CPU limits).\n * Pass maxJobs for longer-running environments like Lambda.\n */\n processAvailableJobs(opts?: { maxJobs?: number }): Promise<ProcessJobsResult>;\n\n /** Run one bounded maintenance tick (claim, poll, reap, flush). */\n runMaintenanceTick(): Promise<MaintenanceTickResult>;\n}\n\n// ============================================================================\n// Implementation\n// ============================================================================\n\nclass ServerlessHostImpl implements ServerlessHost {\n private readonly kernel: Kernel;\n private readonly jobTransport: JobTransport;\n private readonly workerId: string;\n private readonly staleLeaseThresholdMs: number;\n private readonly jobAbsoluteTimeoutMs: number;\n private readonly maxClaimsPerTick: number;\n /** See `HostConfig.serves`; `undefined` means \"derive from the registry\". */\n private readonly serves: readonly ServedDefinition[] | \"all\" | undefined;\n private readonly maxSuspendedChecksPerTick: number;\n private readonly maxOutboxFlushPerTick: number;\n private readonly retention: RetentionOptions | undefined;\n private readonly jobHeartbeatIntervalMs: number;\n private readonly flushOutboxAfterJob: boolean;\n private readonly outboxFlushTimeoutMs: number;\n\n constructor(config: ServerlessHostConfig) {\n this.kernel = config.kernel;\n this.jobTransport = config.jobTransport;\n this.workerId = config.workerId;\n this.staleLeaseThresholdMs =\n config.staleLeaseThresholdMs ?? HOST_DEFAULTS.staleLeaseThresholdMs;\n this.jobAbsoluteTimeoutMs =\n config.jobAbsoluteTimeoutMs ?? HOST_DEFAULTS.jobAbsoluteTimeoutMs;\n this.maxClaimsPerTick =\n config.maxClaimsPerTick ?? HOST_DEFAULTS.maxClaimsPerTick;\n this.serves = config.serves;\n this.maxSuspendedChecksPerTick =\n config.maxSuspendedChecksPerTick ??\n HOST_DEFAULTS.maxSuspendedChecksPerTick;\n this.maxOutboxFlushPerTick =\n config.maxOutboxFlushPerTick ?? HOST_DEFAULTS.maxOutboxFlushPerTick;\n this.retention = config.retention;\n this.jobHeartbeatIntervalMs =\n config.jobHeartbeatIntervalMs ?? HOST_DEFAULTS.jobHeartbeatIntervalMs;\n this.flushOutboxAfterJob = config.flushOutboxAfterJob ?? true;\n this.outboxFlushTimeoutMs = config.outboxFlushTimeoutMs ?? 5_000;\n }\n\n async handleJob(msg: JobMessage): Promise<JobResult> {\n // Dispatch job.execute under a lease heartbeat and route the outcome\n // (complete/suspend/fail + terminal run.transition) — see\n // executeJobWithHeartbeat in @bratsos/workflow-engine/kernel for the\n // shared command sequence.\n const result = await executeJobWithHeartbeat(this.kernel, {\n jobTransport: this.jobTransport,\n job: msg,\n jobHeartbeatIntervalMs: this.jobHeartbeatIntervalMs,\n logPrefix: \"[ServerlessHost]\",\n });\n if (this.flushOutboxAfterJob) {\n await this.flushOutbox();\n }\n return result;\n }\n\n /** Publish pending outbox events, bounded by `outboxFlushTimeoutMs`. */\n private async flushOutbox(): Promise<void> {\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<\"timeout\">((resolve) => {\n timer = setTimeout(() => resolve(\"timeout\"), this.outboxFlushTimeoutMs);\n });\n try {\n const outcome = await Promise.race([\n this.kernel.dispatch({\n type: \"outbox.flush\",\n maxEvents: this.maxOutboxFlushPerTick,\n }),\n timeout,\n ]);\n if (outcome === \"timeout\") {\n console.error(\n \"[ServerlessHost] outbox.flush after job: timed out; the next maintenance tick publishes the rest\",\n );\n } else if (outcome.eventSinkStatus === \"degraded\") {\n // Stateless: no transition to detect, so say it every time.\n console.error(\n `[ServerlessHost] event sink DEGRADED: ${outcome.failed} event(s) stayed in the outbox for a later flush; runs keep progressing. Last error: ${\n outcome.eventSinkError ?? \"unknown\"\n }`,\n );\n }\n if (outcome !== \"timeout\" && outcome.deadLettered > 0) {\n console.error(\n `[ServerlessHost] event sink DEAD-LETTERED ${outcome.deadLettered} event(s): they will not be delivered until replayed with the plugin.replayDLQ command`,\n );\n }\n } catch (error) {\n console.error(\"[ServerlessHost] outbox.flush after job error:\", error);\n } finally {\n if (timer) clearTimeout(timer);\n }\n }\n\n async processAvailableJobs(opts?: {\n maxJobs?: number;\n }): Promise<ProcessJobsResult> {\n const maxJobs = opts?.maxJobs ?? 1;\n let processed = 0;\n let succeeded = 0;\n let failed = 0;\n\n while (processed < maxJobs) {\n const serves = this.dequeueServes();\n const job = await this.jobTransport.dequeue(\n serves !== undefined ? { serves } : undefined,\n );\n if (!job) break;\n\n const result = await this.handleJob({\n jobId: job.jobId,\n workflowRunId: job.workflowRunId,\n workflowId: job.workflowId,\n stageId: job.stageId,\n attempt: job.attempt,\n maxAttempts: job.maxAttempts,\n payload: job.payload,\n });\n\n processed++;\n if (result.outcome === \"failed\") {\n failed++;\n } else {\n succeeded++;\n }\n }\n\n return { processed, succeeded, failed };\n }\n\n /**\n * What to pass to `jobTransport.dequeue`. `\"all\"` means claim regardless\n * of version, which is expressed by passing nothing; otherwise the\n * kernel's registry answers, so the dequeue narrows on exactly what\n * `run.claimPending` narrows on.\n */\n private dequeueServes(): readonly ServedDefinition[] | undefined {\n if (this.serves === \"all\") return undefined;\n return this.serves ?? this.kernel.servedDefinitions?.();\n }\n\n async runMaintenanceTick(): Promise<MaintenanceTickResult> {\n // Claim pending runs, poll suspended stages, reap stale leases, flush\n // the outbox, and reap stuck runs — see runMaintenanceTick in\n // @bratsos/workflow-engine/kernel for the shared command sequence. The\n // serverless host returns the per-command counts to its caller (unlike\n // the Node host, which fires this on a timer and ignores them).\n const counts = await runMaintenanceTickCommands(this.kernel, {\n workerId: this.workerId,\n maxClaimsPerTick: this.maxClaimsPerTick,\n ...(this.serves !== undefined ? { serves: this.serves } : {}),\n maxSuspendedChecksPerTick: this.maxSuspendedChecksPerTick,\n maxOutboxFlushPerTick: this.maxOutboxFlushPerTick,\n staleLeaseThresholdMs: this.staleLeaseThresholdMs,\n jobAbsoluteTimeoutMs: this.jobAbsoluteTimeoutMs,\n ...(this.retention !== undefined ? { retention: this.retention } : {}),\n logPrefix: \"[ServerlessHost]\",\n });\n if (counts.eventSinkStatus === \"degraded\") {\n console.error(\n `[ServerlessHost] event sink DEGRADED: ${counts.eventsFailed} event(s) stayed in the outbox for a later flush; runs keep progressing. Last error: ${\n counts.eventSinkError ?? \"unknown\"\n }`,\n );\n }\n if (counts.eventsDeadLettered > 0) {\n console.error(\n `[ServerlessHost] event sink DEAD-LETTERED ${counts.eventsDeadLettered} event(s): they will not be delivered until replayed with the plugin.replayDLQ command`,\n );\n }\n return counts;\n }\n}\n\n// ============================================================================\n// Factory\n// ============================================================================\n\nexport function createServerlessHost(\n config: ServerlessHostConfig,\n): ServerlessHost {\n return new ServerlessHostImpl(config);\n}\n","// packages/workflow-engine-host-serverless/src/run-to-completion.ts\nimport {\n executeJobWithHeartbeat,\n HOST_DEFAULTS,\n type JobTransport,\n type Kernel,\n type RunCreateCommand,\n} from \"@bratsos/workflow-engine/kernel\";\n\n/** The one read `runToCompletion` needs: how did the run end. */\nexport interface RunToCompletionPersistence {\n getRun(id: string): Promise<{\n status: string;\n output: unknown | null;\n } | null>;\n}\n\n/**\n * Why `runToCompletion` stopped.\n *\n * The first three are the run's own terminal states. The last two are not\n * failures — they say the run is still alive and something else has to carry\n * it, which is the honest answer for a helper that must return inside a\n * request.\n */\nexport type RunToCompletionOutcome =\n | \"completed\"\n | \"failed\"\n | \"cancelled\"\n /** A stage suspended (a durable sleep, wait or signal). See the caveats. */\n | \"suspended\"\n /** The budget ran out, or nothing was runnable yet. See `reason`. */\n | \"incomplete\";\n\nexport interface RunToCompletionResult {\n workflowRunId: string;\n /** The run's persisted status when the helper returned. */\n status: string;\n outcome: RunToCompletionOutcome;\n /** The run's output, when it completed. */\n output?: unknown;\n /** Plain-language explanation, always present when `outcome` is not terminal. */\n reason?: string;\n /** Jobs executed by this call, this run's and other callers' alike. */\n jobsProcessed: number;\n /**\n * How many of `jobsProcessed` belonged to a different run. Non-zero means\n * this call did work on someone else's behalf — see the shared-queue\n * caveat.\n */\n foreignJobsProcessed: number;\n /** The stage that suspended, when `outcome` is `\"suspended\"`. */\n suspendedStageId?: string;\n}\n\nexport interface RunToCompletionOptions {\n kernel: Kernel;\n /** The same transport the kernel enqueues onto. */\n jobTransport: JobTransport;\n /** Read port for the run's final status. */\n persistence: RunToCompletionPersistence;\n /** The run to create, exactly as `kernel.dispatch` takes it. */\n command: RunCreateCommand;\n /** Worker id used for the claim and the job leases. Defaults to `\"run-to-completion\"`. */\n workerId?: string;\n /**\n * Hard cap on jobs this call executes. Defaults to 50. Reaching it returns\n * `outcome: \"incomplete\"`; it never throws and never loops past the cap.\n */\n maxJobs?: number;\n /**\n * How many `run.claimPending` rounds to spend looking for this run's first\n * job. Defaults to 5. Each round claims up to `claimsPerRound` pending\n * runs — including other callers' (see the caveats).\n */\n maxClaimRounds?: number;\n /** Pending runs claimed per round. Defaults to 10. */\n claimsPerRound?: number;\n /** Job lease heartbeat interval. Defaults to `HOST_DEFAULTS.jobHeartbeatIntervalMs`. */\n jobHeartbeatIntervalMs?: number;\n /**\n * Publish the run's outbox events before returning (default true), so a\n * caller that returns the result straight to an HTTP client has already\n * announced the run.\n */\n flushOutbox?: boolean;\n /** Max events published by that final flush. Defaults to 100. */\n maxOutboxFlush?: number;\n /** Log prefix for the job loop. Defaults to `\"[runToCompletion]\"`. */\n logPrefix?: string;\n}\n\nconst TERMINAL: Record<string, RunToCompletionOutcome> = {\n COMPLETED: \"completed\",\n FAILED: \"failed\",\n CANCELLED: \"cancelled\",\n};\n\n/**\n * Create a workflow run and drive it to a terminal state inside the calling\n * request — the drain loop every caller writes by hand, exactly once.\n *\n * It creates the run, claims it, then dequeues and executes jobs through the\n * same `executeJobWithHeartbeat` the hosts use, until the run is terminal,\n * a stage suspends, nothing more is runnable, or a bound trips.\n *\n * ## Three things to know before you use it\n *\n * **1. It shares the queue, so it can execute another caller's job.** The\n * kernel's queue is global: `run.claimPending` claims whichever runs are\n * pending, and `dequeue()` returns whichever job is next — neither can be\n * narrowed to one run. So this call may start other callers' runs and\n * execute their jobs against its own budget, and their work lands inside\n * your request's latency and error budget. `foreignJobsProcessed` reports\n * how much of that happened. If it matters, give this call a kernel wired to\n * its own `jobTransport` (an in-request queue) so the only jobs it can see\n * are the ones it created.\n *\n * **2. It cannot complete a workflow that suspends.** A durable sleep, wait\n * or signal parks the stage until a later poll, which by definition is not\n * this request. Rather than spin — polling a clock that has not moved, or\n * sleeping inside a request handler — it returns immediately with\n * `outcome: \"suspended\"` and the stage that parked. The run is healthy and\n * unfinished: a background host or a scheduled maintenance tick resumes it.\n * Workflows you intend to run this way should have no durable waits.\n *\n * **3. It is bounded, never an unbounded loop.** `maxJobs` caps the jobs one\n * call executes and `maxClaimRounds` caps the search for this run's first\n * job. Hitting either returns `outcome: \"incomplete\"` with a `reason`; it\n * does not throw, and it does not keep going. The same is true when a stage\n * fails and is re-enqueued with a retry backoff: the job is not due yet, the\n * queue is empty, and the call returns `\"incomplete\"` rather than waiting.\n *\n * It also deliberately does not poll suspended stages or reap stale leases.\n * Those are maintenance, they are what would make it spin, and\n * `host.runMaintenanceTick()` already owns them.\n *\n * @example\n * ```typescript\n * const result = await runToCompletion({\n * kernel,\n * jobTransport,\n * persistence,\n * command: {\n * type: \"run.create\",\n * idempotencyKey: `checkout:${orderId}`,\n * workflowId: \"checkout\",\n * input: { orderId },\n * },\n * });\n *\n * if (result.outcome === \"completed\") return Response.json(result.output);\n * if (result.outcome === \"suspended\") return new Response(null, { status: 202 });\n * return new Response(result.reason ?? \"run failed\", { status: 500 });\n * ```\n */\nexport async function runToCompletion(\n options: RunToCompletionOptions,\n): Promise<RunToCompletionResult> {\n const {\n kernel,\n jobTransport,\n persistence,\n command,\n workerId = \"run-to-completion\",\n maxJobs = 50,\n maxClaimRounds = 5,\n claimsPerRound = 10,\n jobHeartbeatIntervalMs = HOST_DEFAULTS.jobHeartbeatIntervalMs,\n flushOutbox = true,\n maxOutboxFlush = HOST_DEFAULTS.maxOutboxFlushPerTick,\n logPrefix = \"[runToCompletion]\",\n } = options;\n\n const created = await kernel.dispatch(command);\n const workflowRunId = created.workflowRunId;\n\n let jobsProcessed = 0;\n let foreignJobsProcessed = 0;\n let suspendedStageId: string | undefined;\n let reason: string | undefined;\n\n // Claim until this run is claimed. Each round may also claim other\n // callers' pending runs; that is the queue being shared, not a bug, and it\n // is why the round count is bounded.\n let claimed = false;\n for (let round = 0; round < maxClaimRounds && !claimed; round++) {\n const result = await kernel.dispatch({\n type: \"run.claimPending\",\n workerId,\n maxClaims: claimsPerRound,\n });\n claimed = result.claimed.some(\n (entry) => entry.workflowRunId === workflowRunId,\n );\n // Nothing left to claim and it still was not ours: another worker took\n // it, and its jobs are that worker's to run.\n if (!claimed && result.claimed.length === 0) break;\n }\n if (!claimed) {\n reason =\n `run ${workflowRunId} was not claimed within ${maxClaimRounds} claim round(s): ` +\n `another worker claimed it, and it will finish there`;\n }\n\n while (claimed && jobsProcessed < maxJobs) {\n const job = await jobTransport.dequeue();\n if (!job) {\n reason = `no runnable job remained (${jobsProcessed} executed)`;\n break;\n }\n\n const outcome = await executeJobWithHeartbeat(kernel, {\n jobTransport,\n job: {\n jobId: job.jobId,\n workflowRunId: job.workflowRunId,\n workflowId: job.workflowId,\n stageId: job.stageId,\n attempt: job.attempt,\n maxAttempts: job.maxAttempts,\n payload: job.payload,\n startedAt: job.startedAt,\n },\n jobHeartbeatIntervalMs,\n logPrefix,\n });\n\n jobsProcessed++;\n const mine = job.workflowRunId === workflowRunId;\n if (!mine) foreignJobsProcessed++;\n\n if (mine && outcome.outcome === \"suspended\") {\n suspendedStageId = job.stageId;\n reason =\n `stage \"${job.stageId}\" suspended on a durable wait; the run resumes on a ` +\n `later poll, not in this request`;\n break;\n }\n }\n if (claimed && jobsProcessed >= maxJobs && !suspendedStageId) {\n reason = `job budget of ${maxJobs} exhausted before the run finished`;\n }\n\n if (flushOutbox) {\n // Best effort: the events are already durable in the outbox, and a\n // publish failure must not turn a finished run into a thrown request.\n try {\n await kernel.dispatch({\n type: \"outbox.flush\",\n maxEvents: maxOutboxFlush,\n });\n } catch (error) {\n console.error(`${logPrefix} outbox.flush failed:`, error);\n }\n }\n\n const record = await persistence.getRun(workflowRunId);\n const status = record?.status ?? \"UNKNOWN\";\n const terminal = TERMINAL[status];\n\n if (terminal) {\n return {\n workflowRunId,\n status,\n outcome: terminal,\n ...(record?.output != null ? { output: record.output } : {}),\n jobsProcessed,\n foreignJobsProcessed,\n };\n }\n\n return {\n workflowRunId,\n status,\n outcome: suspendedStageId ? \"suspended\" : \"incomplete\",\n reason: reason ?? `the run is still ${status}`,\n jobsProcessed,\n foreignJobsProcessed,\n ...(suspendedStageId ? { suspendedStageId } : {}),\n };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bratsos/workflow-engine-host-serverless",
3
- "version": "0.3.2",
3
+ "version": "0.4.0-alpha.1",
4
4
  "description": "Platform-agnostic serverless host for @bratsos/workflow-engine command kernel",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,7 +28,7 @@
28
28
  "dist"
29
29
  ],
30
30
  "dependencies": {
31
- "@bratsos/workflow-engine": "0.13.0"
31
+ "@bratsos/workflow-engine": "1.0.0-alpha.14"
32
32
  },
33
33
  "devDependencies": {
34
34
  "tsup": "^8.5.1",