@boardwalk-labs/runner 0.1.2

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 (97) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +68 -0
  3. package/dist/bin.d.ts +2 -0
  4. package/dist/bin.js +214 -0
  5. package/dist/contract.d.ts +154 -0
  6. package/dist/contract.js +196 -0
  7. package/dist/daemon/daemon.d.ts +45 -0
  8. package/dist/daemon/daemon.js +129 -0
  9. package/dist/daemon/identity.d.ts +15 -0
  10. package/dist/daemon/identity.js +44 -0
  11. package/dist/daemon/index.d.ts +3 -0
  12. package/dist/daemon/index.js +4 -0
  13. package/dist/daemon/pool_client.d.ts +31 -0
  14. package/dist/daemon/pool_client.js +101 -0
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.js +8 -0
  17. package/dist/runtime/agent/budget.d.ts +123 -0
  18. package/dist/runtime/agent/budget.js +174 -0
  19. package/dist/runtime/agent/events.d.ts +46 -0
  20. package/dist/runtime/agent/events.js +8 -0
  21. package/dist/runtime/agent/model_rates.d.ts +7 -0
  22. package/dist/runtime/agent/model_rates.js +21 -0
  23. package/dist/runtime/agent/secret_redactor.d.ts +42 -0
  24. package/dist/runtime/agent/secret_redactor.js +98 -0
  25. package/dist/runtime/broker_artifact_store.d.ts +17 -0
  26. package/dist/runtime/broker_artifact_store.js +46 -0
  27. package/dist/runtime/broker_child_dispatcher.d.ts +31 -0
  28. package/dist/runtime/broker_child_dispatcher.js +88 -0
  29. package/dist/runtime/broker_event_publisher.d.ts +25 -0
  30. package/dist/runtime/broker_event_publisher.js +69 -0
  31. package/dist/runtime/broker_tool_host.d.ts +49 -0
  32. package/dist/runtime/broker_tool_host.js +240 -0
  33. package/dist/runtime/cancel_watcher.d.ts +31 -0
  34. package/dist/runtime/cancel_watcher.js +77 -0
  35. package/dist/runtime/credit_watcher.d.ts +30 -0
  36. package/dist/runtime/credit_watcher.js +78 -0
  37. package/dist/runtime/direct_inference.d.ts +29 -0
  38. package/dist/runtime/direct_inference.js +78 -0
  39. package/dist/runtime/index.d.ts +85 -0
  40. package/dist/runtime/index.js +525 -0
  41. package/dist/runtime/inference_transport.d.ts +5 -0
  42. package/dist/runtime/inference_transport.js +9 -0
  43. package/dist/runtime/leaf_executor.d.ts +104 -0
  44. package/dist/runtime/leaf_executor.js +333 -0
  45. package/dist/runtime/lease_renewer.d.ts +31 -0
  46. package/dist/runtime/lease_renewer.js +80 -0
  47. package/dist/runtime/main.d.ts +1 -0
  48. package/dist/runtime/main.js +7 -0
  49. package/dist/runtime/phase_tracker.d.ts +26 -0
  50. package/dist/runtime/phase_tracker.js +45 -0
  51. package/dist/runtime/program_log_capture.d.ts +20 -0
  52. package/dist/runtime/program_log_capture.js +79 -0
  53. package/dist/runtime/program_runner.d.ts +82 -0
  54. package/dist/runtime/program_runner.js +116 -0
  55. package/dist/runtime/program_worker.d.ts +177 -0
  56. package/dist/runtime/program_worker.js +272 -0
  57. package/dist/runtime/recording_secret_resolver.d.ts +9 -0
  58. package/dist/runtime/recording_secret_resolver.js +22 -0
  59. package/dist/runtime/run_abort.d.ts +16 -0
  60. package/dist/runtime/run_abort.js +38 -0
  61. package/dist/runtime/run_event_emitter.d.ts +24 -0
  62. package/dist/runtime/run_event_emitter.js +63 -0
  63. package/dist/runtime/runner_control_client.d.ts +184 -0
  64. package/dist/runtime/runner_control_client.js +501 -0
  65. package/dist/runtime/runtime_flusher.d.ts +41 -0
  66. package/dist/runtime/runtime_flusher.js +90 -0
  67. package/dist/runtime/sandbox_config.d.ts +21 -0
  68. package/dist/runtime/sandbox_config.js +34 -0
  69. package/dist/runtime/support/index.d.ts +53 -0
  70. package/dist/runtime/support/index.js +114 -0
  71. package/dist/runtime/suspension.d.ts +141 -0
  72. package/dist/runtime/suspension.js +120 -0
  73. package/dist/runtime/testing_artifact_build.d.ts +27 -0
  74. package/dist/runtime/testing_artifact_build.js +96 -0
  75. package/dist/runtime/tools/artifacts.d.ts +146 -0
  76. package/dist/runtime/tools/artifacts.js +125 -0
  77. package/dist/runtime/tools/types.d.ts +114 -0
  78. package/dist/runtime/tools/types.js +61 -0
  79. package/dist/runtime/tools/web_search.d.ts +66 -0
  80. package/dist/runtime/tools/web_search.js +165 -0
  81. package/dist/runtime/wire/artifact_storage.d.ts +37 -0
  82. package/dist/runtime/wire/artifact_storage.js +93 -0
  83. package/dist/runtime/wire/artifact_verify.d.ts +1 -0
  84. package/dist/runtime/wire/artifact_verify.js +8 -0
  85. package/dist/runtime/wire/human_input.d.ts +62 -0
  86. package/dist/runtime/wire/human_input.js +156 -0
  87. package/dist/runtime/wire/inference_proxy.d.ts +70 -0
  88. package/dist/runtime/wire/inference_proxy.js +157 -0
  89. package/dist/runtime/wire/manifest.d.ts +16 -0
  90. package/dist/runtime/wire/manifest.js +18 -0
  91. package/dist/runtime/wire/run.d.ts +54 -0
  92. package/dist/runtime/wire/run.js +2 -0
  93. package/dist/runtime/workflow_host.d.ts +194 -0
  94. package/dist/runtime/workflow_host.js +382 -0
  95. package/dist/runtime/workspace_store.d.ts +73 -0
  96. package/dist/runtime/workspace_store.js +118 -0
  97. package/package.json +70 -0
@@ -0,0 +1,54 @@
1
+ export type RunActor = {
2
+ type: "user";
3
+ user_id: string;
4
+ } | {
5
+ type: "workflow";
6
+ parent_run_id: string;
7
+ parent_workflow_id: string;
8
+ user_id: string;
9
+ } | {
10
+ type: "webhook";
11
+ source: string;
12
+ } | {
13
+ type: "cron";
14
+ rule: string;
15
+ } | {
16
+ type: "event";
17
+ subscription_id: string;
18
+ source_run_id: string;
19
+ event: string;
20
+ event_chain_depth: number;
21
+ user_id?: string;
22
+ };
23
+ export interface Run {
24
+ id: string;
25
+ orgId: string;
26
+ workflowId: string;
27
+ workflowVersionId: string;
28
+ environmentId: string | null;
29
+ parentRunId: string | null;
30
+ actor: RunActor;
31
+ triggerKind: string;
32
+ triggerPayload: unknown;
33
+ status: string;
34
+ concurrencyKey: string | null;
35
+ input: unknown;
36
+ config: Record<string, unknown> | null;
37
+ output: unknown;
38
+ state: unknown;
39
+ leaseUntil: number | null;
40
+ workerId: string | null;
41
+ nextWakeAt: number | null;
42
+ waitingOnRunId: string | null;
43
+ retriedFromRunId: string | null;
44
+ startedAt: number | null;
45
+ completedAt: number | null;
46
+ createdAt: number;
47
+ pendingSince: number | null;
48
+ outcomeStatus: string | null;
49
+ outcomeReasoning: string | null;
50
+ tokensIn: number;
51
+ tokensOut: number;
52
+ runtimeSeconds: number;
53
+ dispatchAttempts: number;
54
+ }
@@ -0,0 +1,2 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ export {};
@@ -0,0 +1,194 @@
1
+ import type { WorkflowHost, AgentOptions, ArtifactBody, ArtifactRef, CallOptions, HumanInputOptions, HumanInputResult, PhaseOptions, SleepArg } from "@boardwalk-labs/workflow/runtime";
2
+ import { type LeafResume } from "@boardwalk-labs/engine/core";
3
+ import { type JournalSeam, type SuspendSignal } from "./suspension.js";
4
+ /** Parse a `humanInput({ timeout })` string (`"48h"`, `"30m"`, `"90s"`, `"7d"`) to milliseconds, or
5
+ * null when absent/unparseable (the gate then waits indefinitely). */
6
+ export declare function parseTimeoutMs(timeout: string | undefined): number | null;
7
+ /** The 7-day ceiling on a single hold (matches the legacy sleep tool). */
8
+ export declare const MAX_SLEEP_MS: number;
9
+ /** Runs one ephemeral agent leaf to completion (text, or a schema-validated object). The `signal`
10
+ * carries cooperative cancellation — the leaf stops its model loop and throws when it fires. A
11
+ * `resume` (present only on a tool-level human-input resume) re-enters a parked leaf from its
12
+ * checkpoint + the answers, instead of starting fresh. A leaf that PARKS (the model called the
13
+ * `human_input` tool with no answer yet) throws {@link LeafParked}, which the host turns into a
14
+ * suspend — the executor itself never catches it. */
15
+ export interface LeafExecutor {
16
+ run(prompt: string, opts: AgentOptions | undefined, signal?: AbortSignal, resume?: LeafResume): Promise<unknown>;
17
+ }
18
+ /**
19
+ * When + how often a `workflows.schedule` fires (exactly one of cron/rate/at). MIRRORS the SDK's
20
+ * `ScheduleOptions` — defined locally so the host compiles BEFORE the @boardwalk-labs/workflow bump
21
+ * that adds `scheduleWorkflow` to `WorkflowHost`; once that lands, `scheduleWorkflow` below satisfies
22
+ * the (optional) interface member structurally.
23
+ */
24
+ export interface ScheduleOptions {
25
+ cron?: string;
26
+ rate?: string;
27
+ at?: string | Date;
28
+ timezone?: string;
29
+ idempotencyKey?: string;
30
+ }
31
+ /** A child run's terminal-relevant state, as the start/poll seams return it. */
32
+ export interface ChildResult {
33
+ childRunId: string;
34
+ status: string;
35
+ output: unknown;
36
+ }
37
+ /** Dispatches child runs: `call` holds + returns output (the no-journal path); `start`/`poll` back the
38
+ * DURABLE callWorkflow seam (start once, suspend `waiting_for_child` on a non-terminal child, poll on
39
+ * resume); `run` is fire-and-forget → run id; `schedule` provisions a durable future/recurring run →
40
+ * schedule id. The `signal` lets a hold/start abort promptly when the parent run is cancelled. */
41
+ export interface ChildDispatcher {
42
+ call(slug: string, input: unknown, opts: CallOptions | undefined, signal?: AbortSignal): Promise<unknown>;
43
+ /** Start (or idempotently re-attach to) a child run; resolves its current state. */
44
+ start(slug: string, input: unknown, opts: CallOptions | undefined, signal?: AbortSignal): Promise<ChildResult>;
45
+ /** Poll a child run's current state by id, or null when it isn't this run's child. */
46
+ poll(childRunId: string): Promise<ChildResult | null>;
47
+ run(slug: string, input: unknown, opts: CallOptions | undefined): Promise<string>;
48
+ schedule(slug: string, input: unknown, opts: ScheduleOptions): Promise<string>;
49
+ }
50
+ /** Resolves a granted secret to its plaintext value (audited, fail-closed). */
51
+ export interface SecretAccessor {
52
+ get(name: string): Promise<string>;
53
+ }
54
+ /** Holds the process for `ms` milliseconds. The seam exists so tests don't wait on real time. An
55
+ * abort fires the hold REJECT (with the signal's RunAbortedError) and clears the timer — so a
56
+ * multi-day sleep aborted early doesn't leave a live timer pinning the event loop open. */
57
+ export interface SleepController {
58
+ hold(ms: number, signal?: AbortSignal): Promise<void>;
59
+ }
60
+ /** Phase lifecycle controller injected by the worker's telemetry layer. */
61
+ export interface PhaseController {
62
+ set(name: string, opts: PhaseOptions | undefined): void;
63
+ capture(): string | null;
64
+ runInPhase<T>(phaseId: string | null, fn: () => Promise<T>): Promise<T>;
65
+ }
66
+ /** Default controller: a real timer. `setTimeout`'s ~24.8-day max comfortably covers MAX_SLEEP_MS. */
67
+ export declare class TimerSleepController implements SleepController {
68
+ hold(ms: number, signal?: AbortSignal): Promise<void>;
69
+ }
70
+ /**
71
+ * The run's identity + on-demand public-API token, surfaced to the program as
72
+ * `import { runtime } from "@boardwalk-labs/workflow"`. MIRRORS the SDK's `RuntimeContext` — defined
73
+ * locally so the host compiles BEFORE the @boardwalk-labs/workflow bump that adds the optional
74
+ * `runtime` member to `WorkflowHost`; once that lands, the host's `runtime` satisfies it
75
+ * structurally. Platform credentials are NEVER placed in `process.env` (docs/RUN_ENV_AND_CREDS.md):
76
+ * trusted program code reaches the public-API bearer ONLY through `apiToken()`, which is redacted
77
+ * from all LLM context.
78
+ */
79
+ export interface RuntimeContext {
80
+ runId: string;
81
+ workflowId: string;
82
+ orgId: string;
83
+ /** Public API base origin (e.g. `https://api.boardwalk.sh`); the program appends `/v1` or `/mcp/v1`. */
84
+ apiUrl: string;
85
+ /** A short-lived, manifest-scoped bearer for the public API / MCP / CLI. */
86
+ apiToken(): Promise<string>;
87
+ }
88
+ export interface WorkerWorkflowHostDeps {
89
+ leaf: LeafExecutor;
90
+ children: ChildDispatcher;
91
+ secrets: SecretAccessor;
92
+ /** The run's identity + on-demand public-API bearer (see {@link RuntimeContext}), exposed to the
93
+ * program via `import { runtime }`. The bearer never sits in env — it's served on demand here. */
94
+ runtime: RuntimeContext;
95
+ /** Persists a file artifact for the run (→ broker artifact store); resolves to its id + signed
96
+ * download URL. Absent ⇒ artifacts.write is unsupported and the host method rejects clearly. */
97
+ writeArtifact?: (name: string, contentType: string, body: ArtifactBody, metadata: Record<string, unknown> | undefined) => Promise<ArtifactRef>;
98
+ /** Cooperative-cancellation signal for the run (credit exhaustion today; user cancel later). Every
99
+ * hook checks it at entry and unwinds (throws RunAbortedError); the spending/blocking hooks
100
+ * (`agent`/`sleep`/`callWorkflow`) thread it down so an in-flight op stops promptly. Absent ⇒ no
101
+ * cancellation (local/pre-watcher path). */
102
+ signal?: AbortSignal;
103
+ /** Called before a real (`ms > 0`) hold begins — used to snapshot the persistent workspace so a
104
+ * crash during a long sleep can restore it. Best-effort (its own errors are swallowed). Absent ⇒
105
+ * no pre-sleep hook (workspace persistence off). */
106
+ onBeforeSleep?: () => Promise<void>;
107
+ /** Defaults to {@link TimerSleepController}. */
108
+ sleeper?: SleepController;
109
+ /** Optional run-detail phase marker support. Absent ⇒ Phase markers are a no-op in this host. */
110
+ phases?: PhaseController;
111
+ /** Injected clock for `until`-relative sleeps. Defaults to Date.now. */
112
+ now?: () => number;
113
+ /** Override the 7-day hold ceiling (tests). */
114
+ maxSleepMs?: number;
115
+ /**
116
+ * Durable-suspension journal (docs/SUSPENSION.md): the host memoizes each `agent`/`step`/`sleep`/
117
+ * `humanInput` seam here, so a resumed run replays journaled seams instantly instead of re-running
118
+ * them. Backed by the broker over the run token on hosted runs. Absent ⇒ no memoization (the
119
+ * local/test path): seams run live every time and a suspend can't be resumed.
120
+ */
121
+ journal?: JournalSeam;
122
+ /**
123
+ * Raise a durable suspension: the host calls this from a suspending seam, then returns a
124
+ * never-resolving promise; the worker races that against the program body and tears the task down
125
+ * (the program's own `try/catch` can't swallow a suspend this way). Absent ⇒ a suspending seam
126
+ * rejects with {@link SuspendError} instead (the local/test path).
127
+ */
128
+ onSuspend?: (signal: SuspendSignal) => void;
129
+ /**
130
+ * The highest journaled seq at claim (0 on a fresh run). While re-running seams up to this
131
+ * frontier on a resume, the host is REPLAYING and observability (phase markers, program logs) is
132
+ * suppressed — those lines were emitted in the prior segment.
133
+ */
134
+ replayFrontier?: number;
135
+ }
136
+ export declare class WorkerWorkflowHost implements WorkflowHost {
137
+ private readonly deps;
138
+ private readonly sleeper;
139
+ private readonly now;
140
+ private readonly maxSleepMs;
141
+ /** Synchronous durable-seam counter + silent-replay live flag (docs/SUSPENSION.md). */
142
+ private readonly seq;
143
+ /** Run context + on-demand public-API bearer the SDK `runtime` accessor reads off the host. */
144
+ readonly runtime: RuntimeContext;
145
+ constructor(deps: WorkerWorkflowHostDeps);
146
+ /** True while re-running already-journaled seams on a resume (the worker suppresses program-log +
147
+ * phase observability during this window — those lines were emitted in the prior segment). */
148
+ isReplaying(): boolean;
149
+ /** Raise a durable suspension. With `onSuspend` wired (the real worker) the host signals out-of-band
150
+ * and returns a never-resolving promise, so the program's own try/catch can't swallow it; without
151
+ * it (the local/test path) it rejects with {@link SuspendError}. */
152
+ private suspend;
153
+ /** Run `fn` only if the run isn't aborted; otherwise REJECT (never throw synchronously) with the
154
+ * signal's RunAbortedError — every Promise-returning hook funnels through this so callers always
155
+ * get a rejected promise on abort, not a sync throw. */
156
+ private guarded;
157
+ setPhase(name: string, opts: PhaseOptions | undefined): void;
158
+ agent(prompt: string, opts: AgentOptions | undefined): Promise<unknown>;
159
+ /** The `agent()` durable seam: a journal hit returns the memoized result (never re-runs the LLM); a
160
+ * `suspended` hit resumes a parked leaf from its checkpoint + answers; a miss runs the leaf and
161
+ * journals the result. A leaf that PARKS (the model called `human_input`) suspends the run. */
162
+ private agentSeam;
163
+ /** Program-level `humanInput()`: suspend the run on a gate, resume with the validated answer. The
164
+ * SDK marks this optional; the hosted host always implements it. */
165
+ humanInput(opts: HumanInputOptions): Promise<HumanInputResult>;
166
+ private humanInputSeam;
167
+ /** Absolute wake time for a `humanInput({ timeout })`, or null when there is none / it's unparseable. */
168
+ private timeoutExpiry;
169
+ /** `step.run(name, fn)`: run `fn` exactly once across restarts, memoizing its result in the journal
170
+ * (the escape hatch for nondeterministic work on a suspend/resume path). */
171
+ step(name: string, fn: () => unknown): Promise<unknown>;
172
+ private stepSeam;
173
+ callWorkflow(slug: string, input: unknown, opts: CallOptions | undefined): Promise<unknown>;
174
+ /** The `workflows.call` durable seam (docs/SUSPENSION.md): start the child once + memoize its
175
+ * output; a non-terminal child SUSPENDS the parent (`waiting_for_child`, the child's id journaled)
176
+ * — the parent releases its task and is woken when the child finalizes. On resume the seam polls
177
+ * the journaled child and returns its output (or throws on a failed child). Without a journal (the
178
+ * local/test path) it falls back to the in-process hold-and-poll. */
179
+ private callWorkflowSeam;
180
+ /** Fire-and-forget trigger of another workflow; resolves to the new run's id (no hold/poll). */
181
+ runWorkflow(slug: string, input: unknown, opts: CallOptions | undefined): Promise<string>;
182
+ /** Provision a durable schedule (one-shot/recurring) that fires the target later; resolves to the
183
+ * new schedule's id WITHOUT running it now. Satisfies the SDK's optional `scheduleWorkflow`. */
184
+ scheduleWorkflow(slug: string, input: unknown, opts: ScheduleOptions): Promise<string>;
185
+ getSecret(name: string): Promise<string>;
186
+ writeArtifact(name: string, contentType: string, body: ArtifactBody, metadata: Record<string, unknown> | undefined): Promise<ArtifactRef>;
187
+ sleep(arg: SleepArg): Promise<void>;
188
+ /** A short sleep HOLDS the task in-process (cheaper than a release + replay round-trip); a long one
189
+ * (≥ {@link SUSPEND_THRESHOLD_MS}) SUSPENDS — releases the task, and a timer re-dispatches the run
190
+ * when due. Journaled so a resumed run replays past an already-elapsed sleep instantly. */
191
+ private sleepSeam;
192
+ /** Resolve any {@link SleepArg} shape to a millisecond duration from now. */
193
+ private resolveSleepMs;
194
+ }
@@ -0,0 +1,382 @@
1
+ // WorkerWorkflowHost — the real WorkflowHost the worker installs onto @boardwalk-labs/workflow
2
+ // before running a program (docs/WORKFLOW_RUNTIME.md §3.8). The program's hooks delegate here:
3
+ //
4
+ // agent(prompt, opts) → an ephemeral Strands agent leaf (the demoted agent loop)
5
+ // sleep(arg) → an IN-PROCESS hold (hold-and-pay; no checkpoint, no exit)
6
+ // workflows.call(slug, in) → a durable child run (parent holds while it runs)
7
+ // secrets.get(name) → the run's fail-closed secret resolver
8
+ //
9
+ // The leaf executor, child dispatcher, and secret accessor are injected seams so each lights
10
+ // up independently (agent leaf, composition, secrets are separate plan items) and the host's
11
+ // own logic — chiefly the sleep-hold + argument resolution — is unit-tested in isolation.
12
+ import { AppError, ErrorCode } from "./support/index.js";
13
+ import { LeafParked } from "@boardwalk-labs/engine/core";
14
+ import { normalizeHumanInputResult } from "./wire/human_input.js";
15
+ import { SeamSequencer, SuspendError, childRunIdSchema, determinismError, leafResumeSchema, seamFingerprint, SUSPEND_THRESHOLD_MS, } from "./suspension.js";
16
+ import { throwIfAborted } from "./run_abort.js";
17
+ /** Parse a `humanInput({ timeout })` string (`"48h"`, `"30m"`, `"90s"`, `"7d"`) to milliseconds, or
18
+ * null when absent/unparseable (the gate then waits indefinitely). */
19
+ export function parseTimeoutMs(timeout) {
20
+ if (timeout === undefined)
21
+ return null;
22
+ const m = /^\s*(\d+(?:\.\d+)?)\s*(s|m|h|d)\s*$/i.exec(timeout);
23
+ const amount = m?.[1];
24
+ const unit = m?.[2]?.toLowerCase();
25
+ if (amount === undefined || unit === undefined)
26
+ return null;
27
+ const mult = unit === "s" ? 1000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000;
28
+ return Math.round(Number(amount) * mult);
29
+ }
30
+ /** The 7-day ceiling on a single hold (matches the legacy sleep tool). */
31
+ export const MAX_SLEEP_MS = 7 * 24 * 60 * 60 * 1000;
32
+ /** Default controller: a real timer. `setTimeout`'s ~24.8-day max comfortably covers MAX_SLEEP_MS. */
33
+ export class TimerSleepController {
34
+ hold(ms, signal) {
35
+ if (signal?.aborted === true)
36
+ return Promise.reject(abortError(signal));
37
+ if (ms <= 0)
38
+ return Promise.resolve();
39
+ // No signal: a plain timer.
40
+ if (signal === undefined) {
41
+ return new Promise((resolve) => {
42
+ setTimeout(resolve, ms);
43
+ });
44
+ }
45
+ // With a signal (now narrowed to AbortSignal): race the timer against the abort, clearing the
46
+ // timer on abort so a multi-day hold doesn't pin the event loop open after an early cancel.
47
+ return new Promise((resolve, reject) => {
48
+ const timer = setTimeout(() => {
49
+ signal.removeEventListener("abort", onAbort);
50
+ resolve();
51
+ }, ms);
52
+ const onAbort = () => {
53
+ clearTimeout(timer);
54
+ reject(abortError(signal));
55
+ };
56
+ signal.addEventListener("abort", onAbort, { once: true });
57
+ });
58
+ }
59
+ }
60
+ /** The RunAbortedError carried on an aborted signal (or a generic abort Error as a fallback). */
61
+ function abortError(signal) {
62
+ const r = signal.reason;
63
+ return r instanceof Error ? r : new Error("aborted");
64
+ }
65
+ export class WorkerWorkflowHost {
66
+ deps;
67
+ sleeper;
68
+ now;
69
+ maxSleepMs;
70
+ /** Synchronous durable-seam counter + silent-replay live flag (docs/SUSPENSION.md). */
71
+ seq;
72
+ /** Run context + on-demand public-API bearer the SDK `runtime` accessor reads off the host. */
73
+ runtime;
74
+ constructor(deps) {
75
+ this.deps = deps;
76
+ this.sleeper = deps.sleeper ?? new TimerSleepController();
77
+ this.now = deps.now ?? Date.now;
78
+ this.maxSleepMs = deps.maxSleepMs ?? MAX_SLEEP_MS;
79
+ this.seq = new SeamSequencer(deps.replayFrontier ?? 0);
80
+ this.runtime = deps.runtime;
81
+ }
82
+ /** True while re-running already-journaled seams on a resume (the worker suppresses program-log +
83
+ * phase observability during this window — those lines were emitted in the prior segment). */
84
+ isReplaying() {
85
+ return this.seq.isReplaying();
86
+ }
87
+ /** Raise a durable suspension. With `onSuspend` wired (the real worker) the host signals out-of-band
88
+ * and returns a never-resolving promise, so the program's own try/catch can't swallow it; without
89
+ * it (the local/test path) it rejects with {@link SuspendError}. */
90
+ suspend(signal) {
91
+ if (this.deps.onSuspend === undefined)
92
+ return Promise.reject(new SuspendError(signal));
93
+ this.deps.onSuspend(signal);
94
+ return new Promise(() => {
95
+ /* never settles: the worker races this against the body and tears the task down */
96
+ });
97
+ }
98
+ /** Run `fn` only if the run isn't aborted; otherwise REJECT (never throw synchronously) with the
99
+ * signal's RunAbortedError — every Promise-returning hook funnels through this so callers always
100
+ * get a rejected promise on abort, not a sync throw. */
101
+ guarded(fn) {
102
+ try {
103
+ throwIfAborted(this.deps.signal);
104
+ }
105
+ catch (err) {
106
+ return Promise.reject(err instanceof Error ? err : new Error(String(err)));
107
+ }
108
+ const phases = this.deps.phases;
109
+ if (phases === undefined)
110
+ return fn();
111
+ const phaseId = phases.capture();
112
+ return phases.runInPhase(phaseId, fn);
113
+ }
114
+ setPhase(name, opts) {
115
+ throwIfAborted(this.deps.signal);
116
+ // Suppressed during replay: the marker was already emitted in the prior segment.
117
+ if (this.seq.isReplaying())
118
+ return;
119
+ this.deps.phases?.set(name, opts);
120
+ }
121
+ agent(prompt, opts) {
122
+ return this.guarded(() => this.agentSeam(prompt, opts));
123
+ }
124
+ /** The `agent()` durable seam: a journal hit returns the memoized result (never re-runs the LLM); a
125
+ * `suspended` hit resumes a parked leaf from its checkpoint + answers; a miss runs the leaf and
126
+ * journals the result. A leaf that PARKS (the model called `human_input`) suspends the run. */
127
+ async agentSeam(prompt, opts) {
128
+ const seq = this.seq.next();
129
+ const fingerprint = seamFingerprint([
130
+ "agent",
131
+ opts?.provider ?? null,
132
+ opts?.model ?? null,
133
+ prompt,
134
+ opts?.schema ?? null,
135
+ ]);
136
+ let resume;
137
+ if (this.deps.journal !== undefined) {
138
+ const existing = await this.deps.journal.get(seq);
139
+ if (existing !== null) {
140
+ if (existing.fingerprint !== fingerprint)
141
+ throw determinismError(seq, "agent", existing.kind);
142
+ if (existing.state === "resolved")
143
+ return existing.result;
144
+ // A `suspended` entry is a parked leaf (tool-level human_input): re-enter it from the stored
145
+ // checkpoint + the answers the broker joined in.
146
+ resume = leafResumeSchema.parse(existing.result);
147
+ }
148
+ }
149
+ try {
150
+ const result = await this.deps.leaf.run(prompt, opts, this.deps.signal, resume);
151
+ await this.deps.journal?.put({
152
+ seq,
153
+ kind: "agent",
154
+ fingerprint,
155
+ label: prompt.slice(0, 120),
156
+ result,
157
+ });
158
+ return result;
159
+ }
160
+ catch (err) {
161
+ if (err instanceof LeafParked) {
162
+ // The model paused for a person: suspend with the leaf's checkpoint + the gate.
163
+ return this.suspend({
164
+ reason: "human_input",
165
+ seq,
166
+ fingerprint,
167
+ ...(err.checkpoint !== undefined ? { leafCheckpoint: err.checkpoint } : {}),
168
+ humanInput: {
169
+ key: err.request.toolCallId,
170
+ prompt: err.request.prompt,
171
+ inputSpec: err.request.inputSpec,
172
+ },
173
+ });
174
+ }
175
+ throw err;
176
+ }
177
+ }
178
+ /** Program-level `humanInput()`: suspend the run on a gate, resume with the validated answer. The
179
+ * SDK marks this optional; the hosted host always implements it. */
180
+ humanInput(opts) {
181
+ return this.guarded(() => this.humanInputSeam(opts));
182
+ }
183
+ async humanInputSeam(opts) {
184
+ const seq = this.seq.next();
185
+ const key = opts.key ?? `seam-${String(seq)}`;
186
+ const fingerprint = seamFingerprint(["human_input", key, opts.prompt, opts.input]);
187
+ if (this.deps.journal !== undefined) {
188
+ const existing = await this.deps.journal.get(seq);
189
+ if (existing !== null) {
190
+ if (existing.fingerprint !== fingerprint) {
191
+ throw determinismError(seq, "human_input", existing.kind);
192
+ }
193
+ // A resolved entry is the human's validated response; a still-pending entry is a spurious
194
+ // wake without an answer, so fall through and re-suspend.
195
+ if (existing.state === "resolved")
196
+ return normalizeHumanInputResult(existing.result);
197
+ }
198
+ }
199
+ const expiresAt = this.timeoutExpiry(opts.timeout);
200
+ return this.suspend({
201
+ reason: "human_input",
202
+ seq,
203
+ fingerprint,
204
+ humanInput: {
205
+ key,
206
+ prompt: opts.prompt,
207
+ inputSpec: opts.input,
208
+ ...(opts.assignees !== undefined ? { assignees: [...opts.assignees] } : {}),
209
+ // A timeout only matters with a wake to fire at; carry onTimeout alongside expiresAt.
210
+ ...(expiresAt !== null ? { expiresAt, onTimeout: opts.onTimeout ?? "fail" } : {}),
211
+ },
212
+ });
213
+ }
214
+ /** Absolute wake time for a `humanInput({ timeout })`, or null when there is none / it's unparseable. */
215
+ timeoutExpiry(timeout) {
216
+ const ms = parseTimeoutMs(timeout);
217
+ return ms === null ? null : this.now() + ms;
218
+ }
219
+ /** `step.run(name, fn)`: run `fn` exactly once across restarts, memoizing its result in the journal
220
+ * (the escape hatch for nondeterministic work on a suspend/resume path). */
221
+ step(name, fn) {
222
+ return this.guarded(() => this.stepSeam(name, fn));
223
+ }
224
+ async stepSeam(name, fn) {
225
+ const seq = this.seq.next();
226
+ const fingerprint = seamFingerprint(["step", name]);
227
+ if (this.deps.journal !== undefined) {
228
+ const existing = await this.deps.journal.get(seq);
229
+ if (existing !== null) {
230
+ if (existing.fingerprint !== fingerprint)
231
+ throw determinismError(seq, "step", existing.kind);
232
+ if (existing.state === "resolved")
233
+ return existing.result;
234
+ }
235
+ }
236
+ const result = await fn();
237
+ await this.deps.journal?.put({ seq, kind: "step", fingerprint, label: name, result });
238
+ return result;
239
+ }
240
+ callWorkflow(slug, input, opts) {
241
+ return this.guarded(() => this.callWorkflowSeam(slug, input, opts));
242
+ }
243
+ /** The `workflows.call` durable seam (docs/SUSPENSION.md): start the child once + memoize its
244
+ * output; a non-terminal child SUSPENDS the parent (`waiting_for_child`, the child's id journaled)
245
+ * — the parent releases its task and is woken when the child finalizes. On resume the seam polls
246
+ * the journaled child and returns its output (or throws on a failed child). Without a journal (the
247
+ * local/test path) it falls back to the in-process hold-and-poll. */
248
+ async callWorkflowSeam(slug, input, opts) {
249
+ if (this.deps.journal === undefined) {
250
+ return this.deps.children.call(slug, input, opts, this.deps.signal);
251
+ }
252
+ const seq = this.seq.next();
253
+ const fingerprint = seamFingerprint([
254
+ "workflow_call",
255
+ slug,
256
+ input ?? null,
257
+ opts?.idempotencyKey ?? null,
258
+ ]);
259
+ let knownChildId;
260
+ const existing = await this.deps.journal.get(seq);
261
+ if (existing !== null) {
262
+ if (existing.fingerprint !== fingerprint) {
263
+ throw determinismError(seq, "workflow_call", existing.kind);
264
+ }
265
+ if (existing.state === "resolved")
266
+ return existing.result;
267
+ // A pending entry holds the child run id we suspended waiting on (resume polls it).
268
+ knownChildId = childRunIdSchema.parse(existing.result);
269
+ }
270
+ // First execution starts (idempotently) the child; a resume polls the journaled one.
271
+ const child = knownChildId === undefined
272
+ ? await this.deps.children.start(slug, input, opts, this.deps.signal)
273
+ : await this.deps.children.poll(knownChildId);
274
+ if (child === null) {
275
+ throw new AppError(ErrorCode.INTERNAL_ERROR, `Called workflow's child run vanished`, {
276
+ slug,
277
+ childRunId: knownChildId,
278
+ });
279
+ }
280
+ if (child.status === "completed") {
281
+ await this.deps.journal.put({
282
+ seq,
283
+ kind: "workflow_call",
284
+ fingerprint,
285
+ label: slug,
286
+ result: child.output,
287
+ });
288
+ return child.output;
289
+ }
290
+ if (child.status === "failed" || child.status === "cancelled") {
291
+ throw new AppError(ErrorCode.VALIDATION_FAILED, `Called workflow "${slug}" ${child.status} (run ${child.childRunId})`, { childRunId: child.childRunId, status: child.status });
292
+ }
293
+ // Still running → suspend (release the task); the child's finalize wakes us.
294
+ return this.suspend({
295
+ reason: "workflow_call",
296
+ seq,
297
+ fingerprint,
298
+ childRunId: child.childRunId,
299
+ });
300
+ }
301
+ /** Fire-and-forget trigger of another workflow; resolves to the new run's id (no hold/poll). */
302
+ runWorkflow(slug, input, opts) {
303
+ return this.guarded(async () => {
304
+ const id = await this.deps.children.run(slug, input, opts);
305
+ return id;
306
+ });
307
+ }
308
+ /** Provision a durable schedule (one-shot/recurring) that fires the target later; resolves to the
309
+ * new schedule's id WITHOUT running it now. Satisfies the SDK's optional `scheduleWorkflow`. */
310
+ scheduleWorkflow(slug, input, opts) {
311
+ return this.guarded(async () => {
312
+ const id = await this.deps.children.schedule(slug, input, opts);
313
+ return id;
314
+ });
315
+ }
316
+ getSecret(name) {
317
+ return this.guarded(async () => {
318
+ const value = await this.deps.secrets.get(name);
319
+ return value;
320
+ });
321
+ }
322
+ writeArtifact(name, contentType, body, metadata) {
323
+ return this.guarded(async () => {
324
+ if (this.deps.writeArtifact === undefined) {
325
+ throw new AppError(ErrorCode.VALIDATION_FAILED, "artifacts.write is not available in this runtime");
326
+ }
327
+ const ref = await this.deps.writeArtifact(name, contentType, body, metadata);
328
+ return ref;
329
+ });
330
+ }
331
+ sleep(arg) {
332
+ return this.guarded(() => this.sleepSeam(arg));
333
+ }
334
+ /** A short sleep HOLDS the task in-process (cheaper than a release + replay round-trip); a long one
335
+ * (≥ {@link SUSPEND_THRESHOLD_MS}) SUSPENDS — releases the task, and a timer re-dispatches the run
336
+ * when due. Journaled so a resumed run replays past an already-elapsed sleep instantly. */
337
+ async sleepSeam(arg) {
338
+ const ms = this.resolveSleepMs(arg);
339
+ if (ms > this.maxSleepMs) {
340
+ throw new AppError(ErrorCode.VALIDATION_FAILED, `Sleep exceeds the ${String(this.maxSleepMs)}ms (7-day) maximum`, { kind: "sleep_cap" });
341
+ }
342
+ const seq = this.seq.next();
343
+ const fingerprint = seamFingerprint(["sleep"]);
344
+ if (this.deps.journal !== undefined) {
345
+ const existing = await this.deps.journal.get(seq);
346
+ if (existing !== null) {
347
+ if (existing.fingerprint !== fingerprint)
348
+ throw determinismError(seq, "sleep", existing.kind);
349
+ // A journaled sleep already elapsed in a prior segment — a resumed run only progresses past a
350
+ // sleep once it is due, so on replay this returns immediately.
351
+ return;
352
+ }
353
+ }
354
+ // A non-positive duration (incl. an `until` already in the past) is a no-op hold, not an error.
355
+ const holdMs = Math.max(0, ms);
356
+ if (holdMs === 0)
357
+ return;
358
+ // Long wait: SUSPEND (release the task). The broker records the (resolved) sleep journal entry +
359
+ // the wake time transactionally, so on wake this seam replays past it (the journal hit above).
360
+ // Only when a journal is wired — without it there is no resume, so fall back to holding.
361
+ if (holdMs >= SUSPEND_THRESHOLD_MS && this.deps.journal !== undefined) {
362
+ return this.suspend({ reason: "sleep", seq, fingerprint, durationMs: holdMs });
363
+ }
364
+ // Short wait: HOLD the process (no journal — a crash-restart simply re-holds). Snapshot the
365
+ // persistent workspace first so a crash during the hold can restore it. An abort mid-hold rejects.
366
+ if (this.deps.onBeforeSleep !== undefined)
367
+ await this.deps.onBeforeSleep();
368
+ await this.sleeper.hold(holdMs, this.deps.signal);
369
+ }
370
+ /** Resolve any {@link SleepArg} shape to a millisecond duration from now. */
371
+ resolveSleepMs(arg) {
372
+ if (typeof arg === "number")
373
+ return arg;
374
+ if ("durationMs" in arg)
375
+ return arg.durationMs;
376
+ const until = typeof arg.until === "string" ? Date.parse(arg.until) : arg.until.getTime();
377
+ if (!Number.isFinite(until)) {
378
+ throw new AppError(ErrorCode.VALIDATION_FAILED, "Could not parse sleep `until` timestamp");
379
+ }
380
+ return until - this.now();
381
+ }
382
+ }