@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,34 @@
1
+ // sandbox_config — derive the sandbox tools' per-run configuration from a workflow manifest.
2
+ //
3
+ // The worker builds the `shell`/`git`/`filesystem` tools (build_registry's `sandbox` deps) once
4
+ // the run's manifest is known. One manifest-driven knob feeds in here:
5
+ // * env split — `manifest.env` values are either non-secret literals (passed straight to the
6
+ // subprocess env) or `${{ secrets.X }}` references (turned into a `envVar → secretName` map the
7
+ // tools resolve per-run via `ctx.secrets`; the secret VALUE never sits in the manifest).
8
+ // The shell command allowlist is the built-in default — there is no manifest-level tool grant or
9
+ // `extra_commands` config (tools are selected per-`agent()` call, not granted in the manifest).
10
+ //
11
+ // Pure + manifest-only so the split/extraction is exhaustively unit-testable without a worker.
12
+ import { parseSecretEnvRef } from "./wire/manifest.js";
13
+ /**
14
+ * Split `manifest.env` into non-secret literals vs. `${{ secrets.X }}` references. Returns only the
15
+ * keys that have content, so the caller can spread it into the sandbox deps without injecting
16
+ * empty objects.
17
+ */
18
+ export function sandboxConfigFromManifest(manifest) {
19
+ const env = {};
20
+ const secretEnv = {};
21
+ for (const [key, value] of Object.entries(manifest.env ?? {})) {
22
+ const secret = parseSecretEnvRef(value);
23
+ if (secret !== null)
24
+ secretEnv[key] = secret;
25
+ else
26
+ env[key] = value;
27
+ }
28
+ const config = {};
29
+ if (Object.keys(env).length > 0)
30
+ config.env = env;
31
+ if (Object.keys(secretEnv).length > 0)
32
+ config.secretEnv = secretEnv;
33
+ return config;
34
+ }
@@ -0,0 +1,53 @@
1
+ export declare enum ErrorCode {
2
+ VALIDATION_FAILED = "VALIDATION_FAILED",
3
+ IDEMPOTENCY_KEY_REUSED = "IDEMPOTENCY_KEY_REUSED",
4
+ MISSING_IDEMPOTENCY_KEY = "MISSING_IDEMPOTENCY_KEY",
5
+ UNAUTHORIZED = "UNAUTHORIZED",
6
+ FORBIDDEN = "FORBIDDEN",
7
+ TWO_FACTOR_REQUIRED = "TWO_FACTOR_REQUIRED",
8
+ BUDGET_EXCEEDED = "BUDGET_EXCEEDED",
9
+ INSUFFICIENT_CREDITS = "INSUFFICIENT_CREDITS",
10
+ BILLING_GATED = "BILLING_GATED",
11
+ UPGRADE_REQUIRED = "UPGRADE_REQUIRED",
12
+ NOT_FOUND = "NOT_FOUND",
13
+ CONFLICT = "CONFLICT",
14
+ CONCURRENCY_LIMIT = "CONCURRENCY_LIMIT",
15
+ WORKFLOW_DISABLED = "WORKFLOW_DISABLED",
16
+ UNSUPPORTED_TRIGGER = "UNSUPPORTED_TRIGGER",
17
+ RATE_LIMIT = "RATE_LIMIT",
18
+ TOOL_ERROR = "TOOL_ERROR",
19
+ INTERNAL_ERROR = "INTERNAL_ERROR"
20
+ }
21
+ export declare class AppError extends Error {
22
+ readonly code: ErrorCode;
23
+ readonly httpStatus: number;
24
+ readonly detail?: unknown;
25
+ constructor(code: ErrorCode, message: string, detail?: unknown);
26
+ }
27
+ export declare function isAppError(err: unknown): err is AppError;
28
+ /** Run-lease heartbeat period (ported from the platform's checkpoint module — the broker's
29
+ * renew endpoint mirrors it). */
30
+ export declare const DEFAULT_LEASE_MS: number;
31
+ /** Opaque unique id (metering sessions, worker ids). Not a ULID — nothing here sorts by it. */
32
+ export declare function newId(): string;
33
+ type LogFields = Record<string, unknown>;
34
+ export interface Logger {
35
+ debug(message: string, fields?: LogFields): void;
36
+ info(message: string, fields?: LogFields): void;
37
+ warn(message: string, fields?: LogFields): void;
38
+ error(message: string, fields?: LogFields): void;
39
+ }
40
+ export declare function createLogger(module: string): Logger;
41
+ export type OrgRole = "owner" | "admin" | "member" | "viewer";
42
+ export type AuthSource = "session_jwt" | "oauth_jwt" | "api_key" | "workflow";
43
+ export interface AuthContext {
44
+ userId: string;
45
+ source: AuthSource;
46
+ orgId: string;
47
+ role: OrgRole;
48
+ apiKeyId?: string;
49
+ scopes?: readonly string[];
50
+ boundOrgId?: string;
51
+ boundRole?: OrgRole;
52
+ }
53
+ export {};
@@ -0,0 +1,114 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Runtime support shims — the small slice of the platform's `@boardwalk/common` +
3
+ // `domain/authz` the worker runtime depends on, ported verbatim where it matters
4
+ // (AppError taxonomy) and minimally where it doesn't (the logger: structured JSON to
5
+ // stdout, same call surface as the platform's Powertools child logger).
6
+ import { randomUUID } from "node:crypto";
7
+ // ---- errors (ported from boardwalk-backend src/common/errors.ts; keep in sync until the
8
+ // backend consumes this package and the copy there is deleted) ----
9
+ export var ErrorCode;
10
+ (function (ErrorCode) {
11
+ ErrorCode["VALIDATION_FAILED"] = "VALIDATION_FAILED";
12
+ ErrorCode["IDEMPOTENCY_KEY_REUSED"] = "IDEMPOTENCY_KEY_REUSED";
13
+ ErrorCode["MISSING_IDEMPOTENCY_KEY"] = "MISSING_IDEMPOTENCY_KEY";
14
+ ErrorCode["UNAUTHORIZED"] = "UNAUTHORIZED";
15
+ ErrorCode["FORBIDDEN"] = "FORBIDDEN";
16
+ ErrorCode["TWO_FACTOR_REQUIRED"] = "TWO_FACTOR_REQUIRED";
17
+ ErrorCode["BUDGET_EXCEEDED"] = "BUDGET_EXCEEDED";
18
+ ErrorCode["INSUFFICIENT_CREDITS"] = "INSUFFICIENT_CREDITS";
19
+ ErrorCode["BILLING_GATED"] = "BILLING_GATED";
20
+ ErrorCode["UPGRADE_REQUIRED"] = "UPGRADE_REQUIRED";
21
+ ErrorCode["NOT_FOUND"] = "NOT_FOUND";
22
+ ErrorCode["CONFLICT"] = "CONFLICT";
23
+ ErrorCode["CONCURRENCY_LIMIT"] = "CONCURRENCY_LIMIT";
24
+ ErrorCode["WORKFLOW_DISABLED"] = "WORKFLOW_DISABLED";
25
+ ErrorCode["UNSUPPORTED_TRIGGER"] = "UNSUPPORTED_TRIGGER";
26
+ ErrorCode["RATE_LIMIT"] = "RATE_LIMIT";
27
+ ErrorCode["TOOL_ERROR"] = "TOOL_ERROR";
28
+ ErrorCode["INTERNAL_ERROR"] = "INTERNAL_ERROR";
29
+ })(ErrorCode || (ErrorCode = {}));
30
+ const ERROR_HTTP_STATUS = {
31
+ [ErrorCode.VALIDATION_FAILED]: 400,
32
+ [ErrorCode.IDEMPOTENCY_KEY_REUSED]: 409,
33
+ [ErrorCode.MISSING_IDEMPOTENCY_KEY]: 400,
34
+ [ErrorCode.UNAUTHORIZED]: 401,
35
+ [ErrorCode.FORBIDDEN]: 403,
36
+ [ErrorCode.TWO_FACTOR_REQUIRED]: 403,
37
+ [ErrorCode.BUDGET_EXCEEDED]: 402,
38
+ [ErrorCode.INSUFFICIENT_CREDITS]: 402,
39
+ [ErrorCode.BILLING_GATED]: 402,
40
+ [ErrorCode.UPGRADE_REQUIRED]: 402,
41
+ [ErrorCode.NOT_FOUND]: 404,
42
+ [ErrorCode.CONFLICT]: 409,
43
+ [ErrorCode.CONCURRENCY_LIMIT]: 409,
44
+ [ErrorCode.WORKFLOW_DISABLED]: 409,
45
+ [ErrorCode.UNSUPPORTED_TRIGGER]: 422,
46
+ [ErrorCode.RATE_LIMIT]: 429,
47
+ [ErrorCode.TOOL_ERROR]: 500,
48
+ [ErrorCode.INTERNAL_ERROR]: 500,
49
+ };
50
+ export class AppError extends Error {
51
+ code;
52
+ httpStatus;
53
+ detail;
54
+ constructor(code, message, detail) {
55
+ super(message);
56
+ this.name = "AppError";
57
+ this.code = code;
58
+ this.httpStatus = ERROR_HTTP_STATUS[code];
59
+ this.detail = detail;
60
+ }
61
+ }
62
+ export function isAppError(err) {
63
+ return err instanceof AppError;
64
+ }
65
+ // ---- leases ----
66
+ /** Run-lease heartbeat period (ported from the platform's checkpoint module — the broker's
67
+ * renew endpoint mirrors it). */
68
+ export const DEFAULT_LEASE_MS = 5 * 60 * 1000;
69
+ // ---- ids ----
70
+ /** Opaque unique id (metering sessions, worker ids). Not a ULID — nothing here sorts by it. */
71
+ export function newId() {
72
+ return randomUUID();
73
+ }
74
+ const LEVEL_ORDER = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
75
+ /** Active log level: BOARDWALK_RUNNER_LOG_LEVEL (debug|info|warn|error), default info.
76
+ * BOARDWALK_RUNNER_DEBUG=1 is a legacy alias for debug. Read per call so a CLI flag that
77
+ * sets the env (e.g. `--verbose` / `--debug`) applies without logger re-creation. */
78
+ function activeLevel() {
79
+ if (process.env.BOARDWALK_RUNNER_DEBUG === "1")
80
+ return LEVEL_ORDER.DEBUG ?? 10;
81
+ const name = process.env.BOARDWALK_RUNNER_LOG_LEVEL?.toUpperCase() ?? "INFO";
82
+ return LEVEL_ORDER[name] ?? 20;
83
+ }
84
+ function emit(level, module, message, fields) {
85
+ if ((LEVEL_ORDER[level] ?? 20) < activeLevel())
86
+ return;
87
+ const line = JSON.stringify({
88
+ level,
89
+ message,
90
+ timestamp: new Date().toISOString(),
91
+ module,
92
+ ...fields,
93
+ });
94
+ if (level === "ERROR" || level === "WARN")
95
+ process.stderr.write(`${line}\n`);
96
+ else
97
+ process.stdout.write(`${line}\n`);
98
+ }
99
+ export function createLogger(module) {
100
+ return {
101
+ debug: (m, f) => {
102
+ emit("DEBUG", module, m, f);
103
+ },
104
+ info: (m, f) => {
105
+ emit("INFO", module, m, f);
106
+ },
107
+ warn: (m, f) => {
108
+ emit("WARN", module, m, f);
109
+ },
110
+ error: (m, f) => {
111
+ emit("ERROR", module, m, f);
112
+ },
113
+ };
114
+ }
@@ -0,0 +1,141 @@
1
+ import { z } from "zod";
2
+ import type { LeafCheckpoint, LeafResume } from "@boardwalk-labs/engine/core";
3
+ import { AppError } from "./support/index.js";
4
+ /**
5
+ * Sleeps at/above this hold-vs-release boundary SUSPEND (release the task; a timer re-dispatches the
6
+ * run when due); shorter ones HOLD the process in-memory, where a release + replay round-trip costs
7
+ * more than it saves. Mirrors the engine's `SUSPEND_THRESHOLD_MS` so the two runtimes draw the line
8
+ * in the same place.
9
+ */
10
+ export declare const SUSPEND_THRESHOLD_MS = 30000;
11
+ /** The durable-seam kinds the journal memoizes (mirrors `run_journal.kind` + the engine's IPC). */
12
+ export type JournalKind = "agent" | "step" | "human_input" | "sleep" | "workflow_call";
13
+ /** Why a seam suspended the run. */
14
+ export type SuspendReason = "human_input" | "sleep" | "workflow_call";
15
+ /** A human-in-the-loop gate carried out of a suspending seam (program-level or the in-leaf tool). */
16
+ export interface HumanInputGate {
17
+ /** The stable key the responder answers by (an author/seq key, or the model's tool-call id). */
18
+ key: string;
19
+ prompt: string;
20
+ /** The response form ({@link import("@boardwalk-labs/workflow").HumanInputSpec}); validated on submit. */
21
+ inputSpec: unknown;
22
+ /** RBAC scopes (`role:…`, `user:…`) allowed to respond; absent ⇒ any member with `run:respond`. */
23
+ assignees?: string[];
24
+ /** When the gate auto-expires (ms since epoch), for a `timeout`; absent ⇒ waits indefinitely. */
25
+ expiresAt?: number;
26
+ /** What to do on expiry (`"fail"` | `{ value }`) — the SDK's `onTimeout`; jsonb passthrough. */
27
+ onTimeout?: unknown;
28
+ }
29
+ /** Everything the broker needs to persist a suspension + the wake condition. */
30
+ export interface SuspendSignal {
31
+ reason: SuspendReason;
32
+ /** The journal seq of the suspending seam. */
33
+ seq: number;
34
+ /** The seam's determinism fingerprint (recorded on the pending journal entry). */
35
+ fingerprint: string;
36
+ /** Present for `reason: "human_input"` — the gate to register a request row for. */
37
+ humanInput?: HumanInputGate;
38
+ /** A tool-level gate's leaf transcript checkpoint, stored so the leaf resumes where it paused. */
39
+ leafCheckpoint?: LeafCheckpoint;
40
+ /** Relative wait (ms) for `reason: "sleep"`; the broker computes the absolute wake time. */
41
+ durationMs?: number;
42
+ /** The child run id for `reason: "workflow_call"` — the parent suspends `waiting_for_child` and is
43
+ * woken when this child finalizes (the sweep wakes a parent whose child is terminal). */
44
+ childRunId?: string;
45
+ }
46
+ /**
47
+ * The control signal a host seam raises to suspend the run. Unlike the engine (a child process the
48
+ * supervisor kills out-of-band), the backend worker runs the program IN-PROCESS, so a suspend is
49
+ * surfaced by the host calling `onSuspend(signal)` and returning a NEVER-resolving promise; the
50
+ * worker races that against the program body and tears down. This class exists so a seam that has no
51
+ * `onSuspend` wired (the local/test path) can still raise a typed, catchable signal.
52
+ */
53
+ export declare class SuspendError extends Error {
54
+ readonly signal: SuspendSignal;
55
+ constructor(signal: SuspendSignal);
56
+ }
57
+ /** Journal-entry states (mirrors `run_journal.state` + the engine's IPC). */
58
+ export declare const JOURNAL_STATES: readonly ["pending", "suspended", "resolved"];
59
+ export type JournalEntryState = (typeof JOURNAL_STATES)[number];
60
+ /** A memoized journal entry, as the host reads it back on replay (mirrors the engine's IPC shape). */
61
+ export interface JournalLookup {
62
+ seq: number;
63
+ kind: JournalKind;
64
+ fingerprint: string;
65
+ /** `resolved` ⇒ `result` is the memoized value; `suspended` ⇒ a parked agent leaf (result is the
66
+ * {@link LeafResume} the host re-enters with); `pending` ⇒ awaiting an external event (re-suspend). */
67
+ state: JournalEntryState;
68
+ result: unknown;
69
+ }
70
+ /** Validate the broker's journal_get response (the worker's run token doesn't exempt the channel from
71
+ * validation). The result is genuinely heterogeneous JSON (an agent return / a LeafResume / a
72
+ * HumanInputResult), parsed per-kind downstream — so it stays `unknown` here, the validation seam. */
73
+ export declare const journalLookupSchema: z.ZodObject<{
74
+ seq: z.ZodNumber;
75
+ kind: z.ZodEnum<{
76
+ sleep: "sleep";
77
+ agent: "agent";
78
+ step: "step";
79
+ human_input: "human_input";
80
+ workflow_call: "workflow_call";
81
+ }>;
82
+ fingerprint: z.ZodString;
83
+ state: z.ZodEnum<{
84
+ pending: "pending";
85
+ suspended: "suspended";
86
+ resolved: "resolved";
87
+ }>;
88
+ result: z.ZodUnknown;
89
+ }, z.core.$strip>;
90
+ /** The journal the host reads (replay lookup) + writes (resolved seam results). Backed by the broker
91
+ * over the run token on hosted runs; absent on the local/test path (no durable suspension). */
92
+ export interface JournalSeam {
93
+ /** The memoized entry for a seam, or null on a replay miss. */
94
+ get(seq: number): Promise<JournalLookup | null>;
95
+ /** Record a RESOLVED seam result (idempotent on the run + seq; a resolved entry is immutable). */
96
+ put(entry: {
97
+ seq: number;
98
+ kind: JournalKind;
99
+ fingerprint: string;
100
+ label: string;
101
+ result: unknown;
102
+ }): Promise<void>;
103
+ }
104
+ /**
105
+ * The synchronous, monotonic durable-seam counter. Incremented at each journaled seam's ENTRY:
106
+ * because a program's synchronous call order is deterministic (even under `Promise.all`, whose
107
+ * `.map(...)` runs left-to-right synchronously), the same logical call gets the same `seq` on every
108
+ * execution — the journal key that lets a resumed run return a memoized result.
109
+ *
110
+ * It also drives SILENT REPLAY: a resume starts suppressed (observability — console output, phase
111
+ * markers — was already emitted last segment) and goes `live` the moment it reaches the suspending
112
+ * seam (the frontier = the highest journaled seq). A fresh run (frontier 0) is live immediately.
113
+ */
114
+ export declare class SeamSequencer {
115
+ private readonly replayFrontier;
116
+ private count;
117
+ private liveFlag;
118
+ constructor(replayFrontier?: number);
119
+ next(): number;
120
+ /** True once execution has crossed the replay frontier — output after this point is NEW. */
121
+ get isLive(): boolean;
122
+ /** True while re-running already-journaled seams on a resume (observability suppressed). */
123
+ isReplaying(): boolean;
124
+ }
125
+ /** The child run id a `workflow_call` seam journals while it waits (parsed back on resume). */
126
+ export declare const childRunIdSchema: z.ZodString;
127
+ /** A stable content hash of a seam's salient args — the determinism check on replay. Identical
128
+ * construction to the engine's `seamFingerprint` so a journal written by one runtime validates in
129
+ * the other (the conformance promise). */
130
+ export declare function seamFingerprint(parts: readonly unknown[]): string;
131
+ /** A seam reached on replay didn't match what the journal recorded at that seq — the workflow's code
132
+ * on the path to a suspend changed (a different prompt/model/step name, or a different seam kind).
133
+ * Fails the run loudly rather than returning a stale memoized result for the wrong call. */
134
+ export declare function determinismError(seq: number, got: JournalKind, recorded: JournalKind): AppError;
135
+ /**
136
+ * The shape of a SUSPENDED agent leaf's journal result on resume: the transcript checkpoint plus the
137
+ * answers the broker joined from the resolved request rows, keyed by tool-call id. `messages` is the
138
+ * engine's own serialized transcript round-tripping through JSON — handed straight back to the leaf,
139
+ * not re-validated field-by-field. Structurally a {@link LeafResume}.
140
+ */
141
+ export declare const leafResumeSchema: z.ZodType<LeafResume>;
@@ -0,0 +1,120 @@
1
+ // Durable-suspension primitives for the JS-body worker (docs/SUSPENSION.md).
2
+ //
3
+ // A run SUSPENDS at a durable seam — a long `sleep`, a `humanInput()` gate, or the in-leaf
4
+ // `human_input` tool — by releasing its Fargate task and re-acquiring one on wake. The mechanism is
5
+ // a JOURNAL: every memoizable seam (`agent` / `step` / `humanInput` / `sleep`) is keyed by a
6
+ // synchronous monotonic `seq` and records its whole result, so a resumed run RESTARTS from the top
7
+ // and replays through the journal (a hit returns the memoized value instantly; we never replay an
8
+ // LLM turn — we memoize whole results, the `findChildCall` pattern generalized). This is NOT
9
+ // Temporal-style turn replay, so it does not violate the no-replay-engine rule (CLAUDE.md).
10
+ //
11
+ // This file holds the SHARED pieces the host + worker + broker client agree on: the suspend signal a
12
+ // seam raises, the journal seam the host reads/writes, the seq sequencer driving silent replay, and
13
+ // the determinism fingerprint. The behavior is conformance-pinned to the OSS engine's
14
+ // `src/run/child_host.ts` — the two runtimes must suspend + resume identically.
15
+ import { createHash } from "node:crypto";
16
+ import { z } from "zod";
17
+ import { AppError, ErrorCode } from "./support/index.js";
18
+ /**
19
+ * Sleeps at/above this hold-vs-release boundary SUSPEND (release the task; a timer re-dispatches the
20
+ * run when due); shorter ones HOLD the process in-memory, where a release + replay round-trip costs
21
+ * more than it saves. Mirrors the engine's `SUSPEND_THRESHOLD_MS` so the two runtimes draw the line
22
+ * in the same place.
23
+ */
24
+ export const SUSPEND_THRESHOLD_MS = 30_000;
25
+ /**
26
+ * The control signal a host seam raises to suspend the run. Unlike the engine (a child process the
27
+ * supervisor kills out-of-band), the backend worker runs the program IN-PROCESS, so a suspend is
28
+ * surfaced by the host calling `onSuspend(signal)` and returning a NEVER-resolving promise; the
29
+ * worker races that against the program body and tears down. This class exists so a seam that has no
30
+ * `onSuspend` wired (the local/test path) can still raise a typed, catchable signal.
31
+ */
32
+ export class SuspendError extends Error {
33
+ signal;
34
+ constructor(signal) {
35
+ super(`run suspended (${signal.reason}) at seam ${String(signal.seq)}`);
36
+ this.name = "SuspendError";
37
+ this.signal = signal;
38
+ }
39
+ }
40
+ /** Journal-entry states (mirrors `run_journal.state` + the engine's IPC). */
41
+ export const JOURNAL_STATES = ["pending", "suspended", "resolved"];
42
+ /** Validate the broker's journal_get response (the worker's run token doesn't exempt the channel from
43
+ * validation). The result is genuinely heterogeneous JSON (an agent return / a LeafResume / a
44
+ * HumanInputResult), parsed per-kind downstream — so it stays `unknown` here, the validation seam. */
45
+ export const journalLookupSchema = z.object({
46
+ seq: z.number().int().positive(),
47
+ kind: z.enum(["agent", "step", "human_input", "sleep", "workflow_call"]),
48
+ fingerprint: z.string(),
49
+ state: z.enum(JOURNAL_STATES),
50
+ result: z.unknown(),
51
+ });
52
+ /**
53
+ * The synchronous, monotonic durable-seam counter. Incremented at each journaled seam's ENTRY:
54
+ * because a program's synchronous call order is deterministic (even under `Promise.all`, whose
55
+ * `.map(...)` runs left-to-right synchronously), the same logical call gets the same `seq` on every
56
+ * execution — the journal key that lets a resumed run return a memoized result.
57
+ *
58
+ * It also drives SILENT REPLAY: a resume starts suppressed (observability — console output, phase
59
+ * markers — was already emitted last segment) and goes `live` the moment it reaches the suspending
60
+ * seam (the frontier = the highest journaled seq). A fresh run (frontier 0) is live immediately.
61
+ */
62
+ export class SeamSequencer {
63
+ replayFrontier;
64
+ count = 0;
65
+ liveFlag;
66
+ constructor(replayFrontier = 0) {
67
+ this.replayFrontier = replayFrontier;
68
+ this.liveFlag = replayFrontier === 0;
69
+ }
70
+ next() {
71
+ const seq = ++this.count;
72
+ if (seq >= this.replayFrontier)
73
+ this.liveFlag = true;
74
+ return seq;
75
+ }
76
+ /** True once execution has crossed the replay frontier — output after this point is NEW. */
77
+ get isLive() {
78
+ return this.liveFlag;
79
+ }
80
+ /** True while re-running already-journaled seams on a resume (observability suppressed). */
81
+ isReplaying() {
82
+ return !this.liveFlag;
83
+ }
84
+ }
85
+ /** The child run id a `workflow_call` seam journals while it waits (parsed back on resume). */
86
+ export const childRunIdSchema = z.string().min(1);
87
+ /** A stable content hash of a seam's salient args — the determinism check on replay. Identical
88
+ * construction to the engine's `seamFingerprint` so a journal written by one runtime validates in
89
+ * the other (the conformance promise). */
90
+ export function seamFingerprint(parts) {
91
+ return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
92
+ }
93
+ /** A seam reached on replay didn't match what the journal recorded at that seq — the workflow's code
94
+ * on the path to a suspend changed (a different prompt/model/step name, or a different seam kind).
95
+ * Fails the run loudly rather than returning a stale memoized result for the wrong call. */
96
+ export function determinismError(seq, got, recorded) {
97
+ const detail = got === recorded
98
+ ? `the same "${got}" seam but with different arguments (a changed prompt, model, or step name)`
99
+ : `a "${recorded}" call, but this execution reached a "${got}" call`;
100
+ return new AppError(ErrorCode.VALIDATION_FAILED, `Nondeterministic replay at seam ${String(seq)}: the journal recorded ${detail}. A workflow's ` +
101
+ `code on the path to a suspend/resume must be deterministic — route nondeterministic I/O ` +
102
+ `through agent(), step.run(), or workflows.call so it is journaled.`, { kind: "nondeterministic_replay", seq });
103
+ }
104
+ /**
105
+ * The shape of a SUSPENDED agent leaf's journal result on resume: the transcript checkpoint plus the
106
+ * answers the broker joined from the resolved request rows, keyed by tool-call id. `messages` is the
107
+ * engine's own serialized transcript round-tripping through JSON — handed straight back to the leaf,
108
+ * not re-validated field-by-field. Structurally a {@link LeafResume}.
109
+ */
110
+ export const leafResumeSchema = z.object({
111
+ checkpoint: z.object({
112
+ messages: z.array(z.custom()),
113
+ iteration: z.number().int(),
114
+ totals: z.object({
115
+ inputTokens: z.number().int(),
116
+ outputTokens: z.number().int(),
117
+ }),
118
+ }),
119
+ answers: z.record(z.string(), z.unknown()),
120
+ });
@@ -0,0 +1,27 @@
1
+ /** Entry module name inside the artifact (matches the CLI builder). */
2
+ export declare const SINGLE_FILE_ENTRY = "index.mjs";
3
+ /** A built program artifact + the metadata the version row records. */
4
+ export interface BuiltServerArtifact {
5
+ tarball: Uint8Array;
6
+ digest: string;
7
+ size: number;
8
+ entry: string;
9
+ sdkVersion: string;
10
+ lockfileDigest: null;
11
+ }
12
+ /**
13
+ * Build a single-file program artifact from submitted source. Type-strips TS→ESM (no type-check;
14
+ * imports preserved so `@boardwalk-labs/workflow` stays external), packs `index.mjs` into a deterministic
15
+ * tar.gz, and content-addresses it. Pure (string in → bytes out) — no fs, no network.
16
+ */
17
+ export declare function buildSingleFileArtifact(source: string, carryAssets?: readonly {
18
+ name: string;
19
+ data: Buffer;
20
+ }[]): BuiltServerArtifact;
21
+ /** Build a standards-compliant ustar archive holding the given regular files in order (deterministic:
22
+ * mtime/uid/gid zeroed). Readable by node-tar / system tar (the worker extractor) and our own reader.
23
+ * Exported so the multi-file package builder reuses the exact same packing. */
24
+ export declare function tarFiles(files: {
25
+ name: string;
26
+ data: Buffer;
27
+ }[]): Buffer;
@@ -0,0 +1,96 @@
1
+ // Server-side single-file artifact build (web / MCP deploy surface, docs/WORKFLOW_RUNTIME.md §3.9).
2
+ //
3
+ // The CLI bundles packages locally; web + MCP submit a single TS/JS file with no client bundler, so
4
+ // the api-server builds the IDENTICAL artifact shape: type-strip the source → `index.mjs`, pack a
5
+ // one-file deterministic tar.gz, content-address by sha256. A single file's only import is the host
6
+ // `@boardwalk-labs/workflow` SDK (left external at runtime), so there is nothing to bundle. The result
7
+ // flows through the SAME finalize path as a CLI upload (verify digest → read entry → derive manifest),
8
+ // so a server-built artifact gets the same integrity guarantees.
9
+ import { createHash } from "node:crypto";
10
+ import { gzipSync } from "node:zlib";
11
+ import ts from "typescript";
12
+ // Inlined from the platform's domain/workflow/artifact_storage.ts (test-only helper).
13
+ const WORKFLOW_ARTIFACT_SOURCE_DIR = ".bw-src";
14
+ const WORKFLOW_ARTIFACT_SINGLE_SOURCE = `${WORKFLOW_ARTIFACT_SOURCE_DIR}/index.ts`;
15
+ /** Entry module name inside the artifact (matches the CLI builder). */
16
+ export const SINGLE_FILE_ENTRY = "index.mjs";
17
+ /** Single-file programs pin no SDK version — defer to whatever the worker image ships. */
18
+ const UNPINNED_SDK = "*";
19
+ /**
20
+ * Build a single-file program artifact from submitted source. Type-strips TS→ESM (no type-check;
21
+ * imports preserved so `@boardwalk-labs/workflow` stays external), packs `index.mjs` into a deterministic
22
+ * tar.gz, and content-addresses it. Pure (string in → bytes out) — no fs, no network.
23
+ */
24
+ export function buildSingleFileArtifact(source, carryAssets = []) {
25
+ const js = ts.transpileModule(source, {
26
+ fileName: "index.ts",
27
+ compilerOptions: {
28
+ module: ts.ModuleKind.ESNext,
29
+ target: ts.ScriptTarget.ES2022,
30
+ verbatimModuleSyntax: false,
31
+ },
32
+ }).outputText;
33
+ // Pack the built (runnable) entry + the author's ORIGINAL source (verbatim, for display/quick-edit)
34
+ // under the unified `.bw-src/` tree — so the detail read surfaces single- and multi-file the same way.
35
+ // `carryAssets` are the prior version's bundled non-source files (skills/configs/templates), re-packed
36
+ // verbatim so a web quick-edit (which rebuilds from source only) never drops them. They arrive already
37
+ // sorted, so appending keeps a no-asset build byte-identical to before.
38
+ const tar = tarFiles([
39
+ { name: SINGLE_FILE_ENTRY, data: Buffer.from(js, "utf8") },
40
+ { name: WORKFLOW_ARTIFACT_SINGLE_SOURCE, data: Buffer.from(source, "utf8") },
41
+ ...carryAssets,
42
+ ]);
43
+ const tarball = new Uint8Array(gzipSync(tar, { level: 9 }));
44
+ return {
45
+ tarball,
46
+ digest: createHash("sha256").update(tarball).digest("hex"),
47
+ size: tarball.length,
48
+ entry: SINGLE_FILE_ENTRY,
49
+ sdkVersion: UNPINNED_SDK,
50
+ lockfileDigest: null,
51
+ };
52
+ }
53
+ const TAR_BLOCK = 512;
54
+ /** Build a standards-compliant ustar archive holding the given regular files in order (deterministic:
55
+ * mtime/uid/gid zeroed). Readable by node-tar / system tar (the worker extractor) and our own reader.
56
+ * Exported so the multi-file package builder reuses the exact same packing. */
57
+ export function tarFiles(files) {
58
+ const parts = files.map((f) => tarEntry(f.name, f.data));
59
+ parts.push(Buffer.alloc(TAR_BLOCK * 2, 0)); // two zero blocks = end-of-archive marker
60
+ return Buffer.concat(parts);
61
+ }
62
+ /** One ustar entry: a 512-byte header + the data padded to a block boundary (no end-of-archive). */
63
+ function tarEntry(name, data) {
64
+ if (Buffer.byteLength(name) > 100) {
65
+ // Names are short (`index.mjs`, `.bw-src/lib/util.ts`); guard a long name (no PAX support).
66
+ throw new Error(`tar entry name too long: ${name}`);
67
+ }
68
+ const header = Buffer.alloc(TAR_BLOCK, 0);
69
+ header.write(name, 0, 100, "utf8"); // name
70
+ writeOctal(header, 0o644, 100, 8); // mode
71
+ writeOctal(header, 0, 108, 8); // uid
72
+ writeOctal(header, 0, 116, 8); // gid
73
+ writeOctal(header, data.length, 124, 12); // size
74
+ writeOctal(header, 0, 136, 12); // mtime (deterministic)
75
+ header.write("0", 156, 1, "ascii"); // typeflag = regular file
76
+ header.write("ustar\0", 257, 6, "binary"); // magic
77
+ header.write("00", 263, 2, "binary"); // version
78
+ // Checksum: sum every header byte with the checksum field initialized to 8 spaces, then write it as
79
+ // 6 octal digits + NUL + space (the conventional ustar form).
80
+ header.write(" ", 148, 8, "ascii");
81
+ let sum = 0;
82
+ for (const b of header)
83
+ sum += b;
84
+ const chk = sum.toString(8).padStart(6, "0").slice(-6);
85
+ header.write(`${chk}\0 `, 148, 8, "ascii");
86
+ const padLen = (TAR_BLOCK - (data.length % TAR_BLOCK)) % TAR_BLOCK;
87
+ return Buffer.concat([header, data, Buffer.alloc(padLen, 0)]);
88
+ }
89
+ /** Write a tar numeric field: (length-1) zero-padded octal digits + NUL. */
90
+ function writeOctal(buf, value, offset, length) {
91
+ const digits = value
92
+ .toString(8)
93
+ .padStart(length - 1, "0")
94
+ .slice(-(length - 1));
95
+ buf.write(`${digits}\0`, offset, length, "ascii");
96
+ }