@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,196 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The Boardwalk runner contract — the canonical payload types between a self-hosted runner
3
+ // machine and the Boardwalk control plane: registration, the assignment offer, claim/lease,
4
+ // and heartbeat. Published from this repo (CONTRACT.md is the prose half); the control plane
5
+ // implements the same schemas.
6
+ //
7
+ // DRAFT until the first tagged release: breaking changes are allowed while the platform
8
+ // implementation lands. After that, this file versions with the package, semver-strictly.
9
+ //
10
+ // Shape of the protocol (revised 2026-07 — credentials moved to CLAIM):
11
+ // - The POLLED OFFER is credential-free: identity + selector only. A queued assignment
12
+ // never carries a token that could age out or leak before any runner commits.
13
+ // - The CLAIM RESPONSE is the only place per-run credentials exist: the run token, the run
14
+ // API token, the resolved non-secret env, and the org's BYO inference provider registry.
15
+ // - Everything else about the run (manifest, program artifact + digest, workspace, artifact
16
+ // prefixes, event stream) is fetched AFTER claim through the run-token'd Runner Control
17
+ // API — the same broker surface a Boardwalk-hosted worker uses. One contract, hosted and
18
+ // self-hosted.
19
+ //
20
+ // Security invariants encoded here (see CONTRACT.md §security):
21
+ // - The registration token registers, nothing else; the standing runner token can only
22
+ // poll/claim/heartbeat/deregister; org reach exists only in a claim's run token.
23
+ // - Secrets are NOT in any payload here. They resolve per run through the control plane
24
+ // with the run token, fail-closed. A BYO provider entry names its auth secret; it never
25
+ // carries the value.
26
+ //
27
+ // Schema rules (same discipline as @boardwalk-labs/workflow): strict objects — unknown fields are
28
+ // validation errors; union members most-specific-first; types derive from schemas, never
29
+ // hand-written.
30
+ import { z } from "zod";
31
+ // ============================================================================
32
+ // Shared scalars
33
+ // ============================================================================
34
+ const id = z.string().min(1).max(128);
35
+ /** Epoch milliseconds. */
36
+ const epochMs = z.number().int().nonnegative();
37
+ export const runnerOsSchema = z.enum(["linux", "macos", "windows"]);
38
+ export const runnerArchSchema = z.enum(["x64", "arm64"]);
39
+ // ============================================================================
40
+ // Registration — a machine joins a pool
41
+ // ============================================================================
42
+ /**
43
+ * POST /runner/v1/register, authenticated by the short-lived registration token in the body —
44
+ * the token IS the credential (single-purpose: it can register, nothing else) and it is BOUND
45
+ * to a pool at mint, so the request names no pool.
46
+ */
47
+ export const runnerRegistrationRequestSchema = z.strictObject({
48
+ registration_token: z.string().min(1),
49
+ /** Human-readable machine name (shown in the dashboard). */
50
+ name: z.string().min(1).max(120),
51
+ /** Extra labels advertised for `runs_on.labels` matching. */
52
+ labels: z.array(z.string().min(1).max(120)).max(32).default([]),
53
+ os: runnerOsSchema.optional(),
54
+ arch: runnerArchSchema.optional(),
55
+ /** The runner client's own version (for deprecation + compatibility messaging). */
56
+ runner_version: z.string().min(1).max(64).optional(),
57
+ });
58
+ export const runnerRegistrationResponseSchema = z.strictObject({
59
+ runner_id: id,
60
+ /** The pool the (pool-bound) registration token enrolled this runner into. */
61
+ pool: z.string().min(1).max(120),
62
+ /**
63
+ * The runner's standing identity credential: it can poll for assignments, claim, heartbeat,
64
+ * and deregister — and nothing else. Per-run capability comes only from a claim's run token.
65
+ */
66
+ runner_token: z.string().min(1),
67
+ poll: z.strictObject({
68
+ url: z.string().url(),
69
+ /** Suggested poll cadence when no long-poll is held. */
70
+ interval_seconds: z.number().int().positive().max(300),
71
+ }),
72
+ });
73
+ // ============================================================================
74
+ // Assignment offer — one run, offered to the pool
75
+ // ============================================================================
76
+ /** The matched `runs_on` selector, echoed so the runner can re-verify it should run this. */
77
+ export const assignmentRunsOnSchema = z.union([
78
+ z.strictObject({
79
+ kind: z.literal("self-hosted"),
80
+ pool: z.string().min(1).max(120),
81
+ labels: z.array(z.string().min(1).max(120)).optional(),
82
+ }),
83
+ // Hosted labels (the hosted worker speaks the same contract).
84
+ z.string().min(1).max(120),
85
+ ]);
86
+ /**
87
+ * A polled offer is CREDENTIAL-FREE by design: identity + selector only. Committing to it
88
+ * (claim) is what mints credentials; the run's full context (manifest, program, workspace)
89
+ * is fetched after claim through the run-token'd Runner Control API.
90
+ */
91
+ export const assignmentOfferSchema = z.strictObject({
92
+ assignment_id: id,
93
+ run_id: id,
94
+ org_id: id,
95
+ runs_on: assignmentRunsOnSchema,
96
+ queued_at: epochMs,
97
+ });
98
+ /**
99
+ * Long-poll response: at most one offer. `action: "drain"` tells an idle runner to stop
100
+ * claiming (deregistration/update flow) — control signals are always brokered polls, never
101
+ * inbound connections to the runner.
102
+ */
103
+ export const assignmentPollResponseSchema = z.strictObject({
104
+ assignment: assignmentOfferSchema.nullable(),
105
+ action: z.literal("drain").optional(),
106
+ });
107
+ // ============================================================================
108
+ // Claim — lease before work; credentials arrive HERE
109
+ // ============================================================================
110
+ /**
111
+ * One BYO inference provider, as data: the runtime calls the endpoint directly (managed-lane
112
+ * inference stays brokered; managed providers are never listed). The auth secret resolves by
113
+ * NAME through the control plane with the run token — the value never rides this payload.
114
+ */
115
+ export const byoInferenceProviderSchema = z.strictObject({
116
+ name: z.string().min(1).max(120),
117
+ /** Adapter kind, e.g. `openai_compatible`, `anthropic`, `bedrock`, `google`. */
118
+ source: z.string().min(1).max(64),
119
+ base_url: z.string().nullable(),
120
+ auth_secret_name: z.string().nullable(),
121
+ });
122
+ /**
123
+ * POST .../assignments/{assignment_id}/claim (runner-token authed; the bearer identifies the
124
+ * runner, the URL the assignment — no body). First claim wins; a loser gets a conflict and
125
+ * polls again. The response is the ONLY payload carrying per-run credentials.
126
+ */
127
+ export const claimResponseSchema = z.strictObject({
128
+ lease_id: id,
129
+ run_id: id,
130
+ /** Heartbeat before this or the lease expires and the run is recovered elsewhere. */
131
+ lease_expires_at: epochMs,
132
+ /**
133
+ * The runner's ONLY org-reaching credentials, minted at claim and bound to this run:
134
+ * `run_token` authenticates every Runner Control API call; `api_token` is the run's
135
+ * auto-injected BOARDWALK_API_KEY (public-API machine principal, manifest-derived scopes).
136
+ */
137
+ control_plane: z.strictObject({
138
+ base_url: z.string().url(),
139
+ run_token: z.string().min(1),
140
+ api_token: z.string().min(1),
141
+ }),
142
+ /** The run's resolved NON-secret env (manifest literals overlaid by org/environment
143
+ * variables). Secrets never ride here — they resolve through the control plane. */
144
+ env: z.record(z.string(), z.string()),
145
+ /** The org's BYO inference providers, for runner-direct model calls. */
146
+ byo_providers: z.array(byoInferenceProviderSchema),
147
+ });
148
+ // ============================================================================
149
+ // Heartbeat — keep the lease, receive control signals
150
+ // ============================================================================
151
+ export const heartbeatPhaseSchema = z.enum(["preparing", "running", "finalizing"]);
152
+ /** The bearer identifies the runner; the body names the held lease. */
153
+ export const heartbeatRequestSchema = z.strictObject({
154
+ lease_id: id,
155
+ run_id: id,
156
+ phase: heartbeatPhaseSchema,
157
+ });
158
+ /**
159
+ * The heartbeat response is the control channel: cancellation and drain arrive HERE (a
160
+ * brokered poll), never as an inbound connection to the runner.
161
+ */
162
+ export const heartbeatResponseSchema = z.strictObject({
163
+ lease_expires_at: epochMs,
164
+ /**
165
+ * `continue` — keep going. `cancel` — stop the run now (the broker records the terminal
166
+ * state). `drain` — finish the current run, then claim nothing further.
167
+ */
168
+ action: z.enum(["continue", "cancel", "drain"]),
169
+ });
170
+ // ============================================================================
171
+ // Terminal status
172
+ // ============================================================================
173
+ // There is deliberately NO pool-level status report: the runner's last word on a run is the
174
+ // run-token'd `finalize` call on the Runner Control API — the same call a hosted worker makes.
175
+ // The control plane closes out the assignment (terminal status, runner back to idle) there.
176
+ // ============================================================================
177
+ // Validation helper
178
+ // ============================================================================
179
+ /** Thrown when a contract payload fails validation. */
180
+ export class ContractValidationError extends Error {
181
+ constructor(message) {
182
+ super(message);
183
+ this.name = "ContractValidationError";
184
+ }
185
+ }
186
+ /** Parse with a readable multi-issue error (the schemas are strict — unknown fields fail). */
187
+ export function parseContract(schema, payload, what) {
188
+ const result = schema.safeParse(payload);
189
+ if (!result.success) {
190
+ const issues = result.error.issues
191
+ .map((i) => ` ${i.path.length > 0 ? i.path.join(".") : "(root)"}: ${i.message}`)
192
+ .join("\n");
193
+ throw new ContractValidationError(`Invalid ${what}:\n${issues}`);
194
+ }
195
+ return result.data;
196
+ }
@@ -0,0 +1,45 @@
1
+ import type { ClaimResponse } from "../contract.js";
2
+ import { type Logger } from "../runtime/support/index.js";
3
+ import type { PoolClient } from "./pool_client.js";
4
+ export interface RunProcessHandle {
5
+ /** Resolves with the exit code when the run process ends. */
6
+ wait: () => Promise<number>;
7
+ /** Ask the process to stop (SIGTERM — the runtime exits and the lease recovers the run). */
8
+ kill: () => void;
9
+ }
10
+ export interface RunSpawner {
11
+ (opts: {
12
+ entry: string;
13
+ env: Record<string, string>;
14
+ cwd: string;
15
+ }): RunProcessHandle;
16
+ }
17
+ export interface DaemonDeps {
18
+ client: Pick<PoolClient, "poll" | "claim" | "heartbeat">;
19
+ /** Absolute path to the runtime process entry (dist/runtime/main.js). */
20
+ runtimeEntry: string;
21
+ /** Root under which per-run workspaces are created (and always removed). */
22
+ workDir: string;
23
+ /** Stable runner identity (stamped as WORKER_ID on run processes). */
24
+ runnerId: string;
25
+ spawn: RunSpawner;
26
+ log?: Logger;
27
+ sleep?: (ms: number) => Promise<void>;
28
+ now?: () => number;
29
+ /** Execute at most one run, then return (CI-friendly). */
30
+ once?: boolean;
31
+ /** Test hook: called at the top of each loop iteration. */
32
+ onIdle?: () => void;
33
+ }
34
+ export interface DaemonController {
35
+ /** Resolves when the daemon has fully stopped (drained + last run finished). */
36
+ done: Promise<void>;
37
+ /** Begin draining: finish the current run (if any), then stop claiming. */
38
+ drain: () => void;
39
+ }
40
+ /** Env for one run process: the platform contract + the claim's resolved non-secret vars. */
41
+ export declare function runProcessEnv(claim: ClaimResponse, opts: {
42
+ runnerId: string;
43
+ workspaceRoot: string;
44
+ }): Record<string, string>;
45
+ export declare function startDaemon(deps: DaemonDeps): DaemonController;
@@ -0,0 +1,129 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The self-hosted runner daemon (docs/SELF_HOSTED_RUNNERS.md R4): poll → claim → spawn ONE run
3
+ // process → heartbeat while it runs → clean → poll. One run at a time per daemon (concurrency =
4
+ // more daemons/machines); drain (Ctrl-C, admin drain, org toggle off) finishes the current run
5
+ // then stops claiming. The per-run process is the SAME runtime a Boardwalk-hosted worker boots
6
+ // (./runtime/main.js) — the daemon only hands it the claim's credentials + env and a fresh
7
+ // per-run workspace, then tears the workspace down (contract: cleanup "always").
8
+ import { mkdir, rm } from "node:fs/promises";
9
+ import * as path from "node:path";
10
+ import * as os from "node:os";
11
+ import { createLogger } from "../runtime/support/index.js";
12
+ /** Assignment-lease heartbeat cadence — comfortably beats the 300s lease. */
13
+ const HEARTBEAT_MS = 60_000;
14
+ /** Back off this long after a transient poll/claim error (network blip, control-plane deploy). */
15
+ const ERROR_BACKOFF_MS = 10_000;
16
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
17
+ /** Env for one run process: the platform contract + the claim's resolved non-secret vars. */
18
+ export function runProcessEnv(claim, opts) {
19
+ return {
20
+ // The claim's resolved non-secret env FIRST — the platform contract keys always win.
21
+ ...claim.env,
22
+ RUN_ID: claim.run_id,
23
+ BOARDWALK_CONTROL_PLANE_URL: claim.control_plane.base_url,
24
+ BOARDWALK_RUN_TOKEN: claim.control_plane.run_token,
25
+ BOARDWALK_API_KEY: claim.control_plane.api_token,
26
+ BOARDWALK_TASK_CPU_UNITS: String(os.cpus().length * 1024),
27
+ // The org's BYO inference providers, as data (plan D7) — consumed by the runtime's direct
28
+ // model path. Names + endpoints + secret NAMES only; never a credential value.
29
+ BOARDWALK_BYO_PROVIDERS: JSON.stringify(claim.byo_providers),
30
+ WORKER_ID: opts.runnerId,
31
+ WORKSPACE_ROOT: opts.workspaceRoot,
32
+ // The machine's own PATH/HOME etc. deliberately do NOT flow here: the run gets exactly the
33
+ // resolved env + platform contract, mirroring the hosted container. The spawner overlays the
34
+ // minimal process necessities (PATH, HOME=workspace).
35
+ };
36
+ }
37
+ export function startDaemon(deps) {
38
+ const log = deps.log ?? createLogger("RunnerDaemon");
39
+ const sleep = deps.sleep ?? defaultSleep;
40
+ let draining = false;
41
+ async function executeClaim(offer, claim) {
42
+ const runDir = path.join(deps.workDir, "runs", claim.run_id);
43
+ const workspaceRoot = path.join(runDir, "workspace");
44
+ await mkdir(workspaceRoot, { recursive: true });
45
+ const child = deps.spawn({
46
+ entry: deps.runtimeEntry,
47
+ env: runProcessEnv(claim, { runnerId: deps.runnerId, workspaceRoot }),
48
+ cwd: workspaceRoot,
49
+ });
50
+ log.info("run_started", { runId: claim.run_id, assignmentId: offer.assignment_id });
51
+ // Heartbeat while the run process lives. Lease lost ⇒ the control plane recovered the
52
+ // assignment (we were presumed dead) — stop the process; the run restarts elsewhere.
53
+ // `cancel` is ALSO delivered to the run process itself (its own CancelWatcher via the run
54
+ // token), so the daemon doesn't kill on cancel — it lets the runtime finalize cleanly.
55
+ let exited = false;
56
+ const heartbeatLoop = (async () => {
57
+ for (;;) {
58
+ await sleep(HEARTBEAT_MS);
59
+ if (exited)
60
+ return;
61
+ try {
62
+ const beat = await deps.client.heartbeat(claim.lease_id, claim.run_id, "running");
63
+ log.debug("heartbeat", { runId: claim.run_id, action: beat?.action ?? "lease_lost" });
64
+ if (exited)
65
+ return;
66
+ if (beat === null) {
67
+ log.warn("assignment_lease_lost", { runId: claim.run_id });
68
+ child.kill();
69
+ return;
70
+ }
71
+ if (beat.action === "drain") {
72
+ log.info("drain_requested", { runId: claim.run_id });
73
+ draining = true;
74
+ }
75
+ }
76
+ catch (err) {
77
+ // Transient heartbeat failure: keep the process running; the next beat retries. The
78
+ // lease is 300s and the cadence 60s, so several failures are survivable.
79
+ log.warn("heartbeat_failed", {
80
+ runId: claim.run_id,
81
+ error: err instanceof Error ? err.message : String(err),
82
+ });
83
+ }
84
+ }
85
+ })();
86
+ const exitCode = await child.wait();
87
+ exited = true;
88
+ await heartbeatLoop.catch(() => undefined);
89
+ log.info("run_finished", { runId: claim.run_id, exitCode });
90
+ // Contract: cleanup "always". Persistence happened via the workspace store, not local disk.
91
+ await rm(runDir, { recursive: true, force: true }).catch(() => undefined);
92
+ }
93
+ const done = (async () => {
94
+ log.info("daemon_started", { runnerId: deps.runnerId, workDir: deps.workDir });
95
+ while (!draining) {
96
+ deps.onIdle?.();
97
+ try {
98
+ log.debug("polling", {});
99
+ const polled = await deps.client.poll();
100
+ if (polled.action === "drain") {
101
+ log.info("drain_requested", {});
102
+ break;
103
+ }
104
+ const offer = polled.assignment;
105
+ if (offer === null)
106
+ continue; // long-poll came back empty — poll again immediately
107
+ const claim = await deps.client.claim(offer.assignment_id);
108
+ if (claim === null)
109
+ continue; // another runner won — back to polling
110
+ await executeClaim(offer, claim);
111
+ if (deps.once === true)
112
+ break;
113
+ }
114
+ catch (err) {
115
+ log.warn("daemon_iteration_failed", {
116
+ error: err instanceof Error ? err.message : String(err),
117
+ });
118
+ await sleep(ERROR_BACKOFF_MS);
119
+ }
120
+ }
121
+ log.info("daemon_stopped", { runnerId: deps.runnerId });
122
+ })();
123
+ return {
124
+ done,
125
+ drain: () => {
126
+ draining = true;
127
+ },
128
+ };
129
+ }
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+ export declare const runnerIdentitySchema: z.ZodObject<{
3
+ runner_id: z.ZodString;
4
+ runner_token: z.ZodString;
5
+ control_plane_url: z.ZodString;
6
+ pool: z.ZodString;
7
+ org: z.ZodOptional<z.ZodString>;
8
+ name: z.ZodString;
9
+ created_at: z.ZodNumber;
10
+ }, z.core.$strict>;
11
+ export type RunnerIdentity = z.infer<typeof runnerIdentitySchema>;
12
+ export declare function defaultIdentityDir(): string;
13
+ export declare function saveIdentity(dir: string, identity: RunnerIdentity): Promise<string>;
14
+ export declare function loadIdentity(dir: string, controlPlaneUrl: string, pool: string): Promise<RunnerIdentity | null>;
15
+ export declare function removeIdentity(dir: string, controlPlaneUrl: string, pool: string): Promise<void>;
@@ -0,0 +1,44 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Persisted runner identity — the standing credential + coordinates a machine keeps between
3
+ // restarts (`runner start` after a reboot skips registration). One JSON file per
4
+ // (control plane, pool), mode 0600: the runner token is a credential.
5
+ import { mkdir, readFile, writeFile, unlink } from "node:fs/promises";
6
+ import * as path from "node:path";
7
+ import * as os from "node:os";
8
+ import { z } from "zod";
9
+ export const runnerIdentitySchema = z.strictObject({
10
+ runner_id: z.string().min(1),
11
+ runner_token: z.string().min(1),
12
+ control_plane_url: z.string().url(),
13
+ pool: z.string().min(1),
14
+ /** Org slug, when known (the one-step CLI flow records it for display). */
15
+ org: z.string().optional(),
16
+ name: z.string().min(1),
17
+ created_at: z.number().int().nonnegative(),
18
+ });
19
+ export function defaultIdentityDir() {
20
+ return path.join(os.homedir(), ".boardwalk", "runner");
21
+ }
22
+ function identityFile(dir, controlPlaneUrl, pool) {
23
+ const host = new URL(controlPlaneUrl).host.replace(/[^A-Za-z0-9.-]/g, "_");
24
+ return path.join(dir, `${host}--${pool}.json`);
25
+ }
26
+ export async function saveIdentity(dir, identity) {
27
+ await mkdir(dir, { recursive: true, mode: 0o700 });
28
+ const file = identityFile(dir, identity.control_plane_url, identity.pool);
29
+ await writeFile(file, `${JSON.stringify(identity, null, 2)}\n`, { mode: 0o600 });
30
+ return file;
31
+ }
32
+ export async function loadIdentity(dir, controlPlaneUrl, pool) {
33
+ try {
34
+ const raw = await readFile(identityFile(dir, controlPlaneUrl, pool), "utf8");
35
+ const parsed = runnerIdentitySchema.safeParse(JSON.parse(raw));
36
+ return parsed.success ? parsed.data : null;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ export async function removeIdentity(dir, controlPlaneUrl, pool) {
43
+ await unlink(identityFile(dir, controlPlaneUrl, pool)).catch(() => undefined);
44
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./pool_client.js";
2
+ export * from "./identity.js";
3
+ export * from "./daemon.js";
@@ -0,0 +1,4 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ export * from "./pool_client.js";
3
+ export * from "./identity.js";
4
+ export * from "./daemon.js";
@@ -0,0 +1,31 @@
1
+ import { type AssignmentPollResponse, type ClaimResponse, type HeartbeatResponse, type RunnerRegistrationRequest, type RunnerRegistrationResponse } from "../contract.js";
2
+ export interface PoolClientConfig {
3
+ /** Control-plane origin, e.g. https://api.boardwalk.sh */
4
+ baseUrl: string;
5
+ /** The standing `bwkr_…` credential (absent only for `register`). */
6
+ runnerToken?: string;
7
+ fetchImpl?: typeof fetch;
8
+ }
9
+ export declare class PoolClientError extends Error {
10
+ readonly status: number;
11
+ readonly operation: string;
12
+ constructor(status: number, operation: string, message: string);
13
+ }
14
+ export declare class PoolClient {
15
+ private readonly cfg;
16
+ private readonly fetchImpl;
17
+ constructor(cfg: PoolClientConfig);
18
+ private url;
19
+ private headers;
20
+ /** Redeem a registration token (unauthenticated: the token in the body IS the credential). */
21
+ register(request: RunnerRegistrationRequest): Promise<RunnerRegistrationResponse>;
22
+ /** Long-poll for ONE credential-free offer (the server holds ~22s). */
23
+ poll(): Promise<AssignmentPollResponse>;
24
+ /** Race-safe claim. Null on 409 — another runner won; go back to polling. */
25
+ claim(assignmentId: string): Promise<ClaimResponse | null>;
26
+ /** Extend the assignment lease + receive the control signal. Null on 409 — lease lost:
27
+ * the control plane recovered the assignment; discard local state. */
28
+ heartbeat(leaseId: string, runId: string, phase: "preparing" | "running" | "finalizing"): Promise<HeartbeatResponse | null>;
29
+ /** Permanent removal (decommissioning this machine). */
30
+ deregister(): Promise<void>;
31
+ }
@@ -0,0 +1,101 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // HTTP client for the control plane's pool-lease endpoints (runner/CONTRACT.md; the platform
3
+ // serves them at /runner/v1/register + /runner/v1/pool/*). Every response is validated against
4
+ // the published contract schemas — the daemon never trusts wire shapes.
5
+ //
6
+ // Corporate proxies: Node's fetch honors HTTPS_PROXY/https_proxy when the process starts with
7
+ // NODE_USE_ENV_PROXY=1 (the CLI/bin wrappers set it; see the README).
8
+ import { assignmentPollResponseSchema, claimResponseSchema, heartbeatResponseSchema, parseContract, runnerRegistrationResponseSchema, } from "../contract.js";
9
+ export class PoolClientError extends Error {
10
+ status;
11
+ operation;
12
+ constructor(status, operation, message) {
13
+ super(`${operation} failed (${String(status)}): ${message}`);
14
+ this.status = status;
15
+ this.operation = operation;
16
+ this.name = "PoolClientError";
17
+ }
18
+ }
19
+ async function errorBody(res) {
20
+ try {
21
+ return (await res.text()).slice(0, 500);
22
+ }
23
+ catch {
24
+ return "<unreadable body>";
25
+ }
26
+ }
27
+ export class PoolClient {
28
+ cfg;
29
+ fetchImpl;
30
+ constructor(cfg) {
31
+ this.cfg = cfg;
32
+ this.fetchImpl = cfg.fetchImpl ?? fetch;
33
+ }
34
+ url(path) {
35
+ return `${this.cfg.baseUrl.replace(/\/$/, "")}${path}`;
36
+ }
37
+ headers() {
38
+ const token = this.cfg.runnerToken;
39
+ if (token === undefined)
40
+ throw new Error("PoolClient has no runner token");
41
+ return { authorization: `Bearer ${token}`, "content-type": "application/json" };
42
+ }
43
+ /** Redeem a registration token (unauthenticated: the token in the body IS the credential). */
44
+ async register(request) {
45
+ const res = await this.fetchImpl(this.url("/runner/v1/register"), {
46
+ method: "POST",
47
+ headers: { "content-type": "application/json" },
48
+ body: JSON.stringify(request),
49
+ });
50
+ if (res.status !== 201) {
51
+ throw new PoolClientError(res.status, "register", await errorBody(res));
52
+ }
53
+ return parseContract(runnerRegistrationResponseSchema, await res.json(), "registration response");
54
+ }
55
+ /** Long-poll for ONE credential-free offer (the server holds ~22s). */
56
+ async poll() {
57
+ const res = await this.fetchImpl(this.url("/runner/v1/pool/poll"), {
58
+ method: "POST",
59
+ headers: this.headers(),
60
+ body: "{}",
61
+ });
62
+ if (res.status !== 200)
63
+ throw new PoolClientError(res.status, "poll", await errorBody(res));
64
+ return parseContract(assignmentPollResponseSchema, await res.json(), "poll response");
65
+ }
66
+ /** Race-safe claim. Null on 409 — another runner won; go back to polling. */
67
+ async claim(assignmentId) {
68
+ const res = await this.fetchImpl(this.url(`/runner/v1/pool/assignments/${encodeURIComponent(assignmentId)}/claim`), { method: "POST", headers: this.headers(), body: "{}" });
69
+ if (res.status === 409)
70
+ return null;
71
+ if (res.status !== 200)
72
+ throw new PoolClientError(res.status, "claim", await errorBody(res));
73
+ return parseContract(claimResponseSchema, await res.json(), "claim response");
74
+ }
75
+ /** Extend the assignment lease + receive the control signal. Null on 409 — lease lost:
76
+ * the control plane recovered the assignment; discard local state. */
77
+ async heartbeat(leaseId, runId, phase) {
78
+ const res = await this.fetchImpl(this.url("/runner/v1/pool/heartbeat"), {
79
+ method: "POST",
80
+ headers: this.headers(),
81
+ body: JSON.stringify({ lease_id: leaseId, run_id: runId, phase }),
82
+ });
83
+ if (res.status === 409)
84
+ return null;
85
+ if (res.status !== 200) {
86
+ throw new PoolClientError(res.status, "heartbeat", await errorBody(res));
87
+ }
88
+ return parseContract(heartbeatResponseSchema, await res.json(), "heartbeat response");
89
+ }
90
+ /** Permanent removal (decommissioning this machine). */
91
+ async deregister() {
92
+ const res = await this.fetchImpl(this.url("/runner/v1/pool/deregister"), {
93
+ method: "POST",
94
+ headers: this.headers(),
95
+ body: "{}",
96
+ });
97
+ if (res.status !== 204 && res.status !== 200) {
98
+ throw new PoolClientError(res.status, "deregister", await errorBody(res));
99
+ }
100
+ }
101
+ }
@@ -0,0 +1 @@
1
+ export * from "./contract.js";
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // @boardwalk-labs/runner — the self-hosted runner client (Phase 3).
3
+ //
4
+ // What exists today is the CONTRACT: the canonical registration / assignment / claim /
5
+ // heartbeat / status payload schemas the runner and the Boardwalk control plane agree on.
6
+ // The client implementation (register, poll, execute, stream) lands when Boardwalk self-hosted
7
+ // runners ship; it will consume exactly these types.
8
+ export * from "./contract.js";