import type { McpTokenResult } from "@boardwalk-labs/engine/core"; import type { Run } from "./wire/run.js"; import type { ByoInferenceProvider } from "../contract.js"; import type { WebSearchOutput } from "./tools/web_search.js"; import type { WorkspaceReservation, ManifestWriteResult } from "./workspace_sync.js"; import type { ArtifactCommitInput, ArtifactPresignInput, ArtifactPresignResult, ArtifactSignResult, ArtifactSummary, ArtifactWriteInput, ArtifactWriteResult } from "./tools/artifacts.js"; import { type InferenceFrame, type InferenceProxyRequest } from "./wire/inference_proxy.js"; export interface RunnerControlClientConfig { /** Base URL of the Runner Control API (BOARDWALK_CONTROL_PLANE_URL). */ baseUrl: string; /** The per-run bearer token (BOARDWALK_RUN_TOKEN). */ runToken: string; /** The run this client (and token) is bound to. */ runId: string; /** Injected fetch (defaults to global fetch). */ fetchImpl?: typeof fetch; /** Per-call ceiling for short control calls (default 30s) — bounds a poll frozen mid-flight. */ controlTimeoutMs?: number; /** Per-call ceiling for bulk artifact/workspace transfers (default 5 min). */ bulkTimeoutMs?: number; /** Backoff schedule for transient-failure retries (length = extra attempts after the first; * see {@link RETRYABLE_STATUSES}). Injectable for tests; [] disables retries. */ retryDelaysMs?: number[]; } /** The pinned program's download reference (the worker fetches + verifies + extracts it). */ export interface BrokerProgram { entry: string; digest: string; sdkVersion: string; downloadUrl: string; } export interface BrokerVersion { manifest: unknown; program: BrokerProgram; } /** The schedule spec the worker sends for `workflows.schedule` (exactly one of cron/rate/at). `at` * is a ms epoch or ISO string — a Date is serialized to ISO by the host before it reaches here. */ export interface BrokerScheduleSpec { cron?: string; rate?: string; at?: string | number; timezone?: string; idempotencyKey?: string; } /** A child run's terminal-relevant state, as returned by the `children` create/poll endpoints. */ export interface BrokerChild { childRunId: string; status: string; output: unknown; /** The callee's PINNED version's stored `output_schema` — what lets the SDK revive a typed * child's return. `null` = untyped callee; `undefined` = a non-terminal poll, a null output, * or an older backend that predates the field. */ outputSchema?: Record | null; } export declare class RunnerControlClient { private readonly cfg; private readonly base; private readonly fetchImpl; /** The live bearer. Mutable: on the snapshot substrate a wake carries a FRESH run token (the * frozen one expired while suspended) and the worker swaps it at runtime. */ private runToken; private readonly controlTimeoutMs; private readonly bulkTimeoutMs; private readonly retryDelaysMs; constructor(cfg: RunnerControlClientConfig); /** Swap the bearer for a fresh run token (the wake path). Every subsequent call uses it. */ swapRunToken(token: string): void; /** * Every SHORT control call (claim / renew / cancel / credit / inputs / …) goes through here so * it carries a hard timeout. Without one, a poll frozen mid-flight on the snapshot substrate * hangs FOREVER on restore (the socket is dead but never reset), and since a watcher serializes * its ticks, one hung tick wedges that watcher — the dead-connections gotcha for the * background pollers (lease/cancel/credit), which run on untracked timers the quiescence gate * doesn't cover. Also plain robustness: no broker call should hang on a network blip. The * streaming inference call is the ONE exception (long-lived NDJSON) and bypasses this. */ private controlFetch; /** Bulk transfers (artifact + workspace up/download over presigned S3) — a much larger ceiling * than a control call, but still bounded so a dead socket can't hang the run. */ private bulkFetch; /** GET a run-scoped control path; any status but 200 throws. The `as T` is the client's one * trust-boundary cast: the broker is the platform's own API and each caller names the * handler's documented reply shape. */ private getJson; /** POST a JSON body to a run-scoped control path; any status but `expect` throws. */ private postJson; /** POST a JSON body to a run-scoped control path expecting an empty 204 reply. */ private postVoid; /** * One attempt per entry in the backoff schedule (+1): retry thrown network failures (connection * reset/refused mid-rollover, our own per-attempt timeout on a dead socket) and the * load-balancer's {@link RETRYABLE_STATUSES}. Safe to re-send because every caller's body is a * reusable string/byte-array (the streaming inference call bypasses this entirely), and the * broker's mutating endpoints are idempotent per worker/identifier (gate seq, usage * identifier, lease per workerId). Before this, ONE blip during an api-server deploy rollover * crashed the worker hard mid-suspend/finalize and only crash-reclaim recovered the run. */ private retryingFetch; /** Claim the run's lease. Returns the run on success, or null when it isn't claimable (409 — * another worker has it, or it isn't pending), which the worker treats as "claim lost". */ claim(workerId: string, leaseSeconds: number): Promise<{ run: Run; lastEventCursor: number; /** The pinned version's SEQUENTIAL int (context.workflowVersion). `undefined` = an older * backend that predates the field (fallback 1, warned); `null` = a defensive backend * integrity anomaly. */ workflowVersion?: number | null; /** The run's selected environment (context.environment). `undefined` = an older backend; * `null` = org base (or a deleted environment) — a REAL value, no fallback needed. */ environment?: { id: string; name: string; } | null; } | null>; /** Heartbeat: extend our lease so a long run isn't reclaimed mid-flight. Returns the new * `leaseUntil`, or null when the lease was lost (409 — another worker reclaimed the run), which * the worker treats as "stop". */ renewLease(workerId: string, leaseSeconds: number): Promise; /** Mark the run terminal. `workerId` lets the broker reject a finalize from a DISPLACED worker (one * whose lease expired and whose run was reclaimed + re-dispatched to a new owner), so a * hung/partitioned worker that later recovers can't clobber the live run or revive a terminal one. */ finalize(status: "completed" | "failed", output: unknown, workerId: string): Promise; /** Fetch the run's pinned manifest + program source, or null when the version is missing (404). */ getVersion(): Promise; /** Book a runtime-seconds DELTA (the worker's RuntimeFlusher → broker). `identifier` makes a * retried/duplicate flush idempotent; distinct per-flush ids sum into the run's runtime total. */ reportUsage(runtimeSeconds: number, identifier: string): Promise; /** Report a token-usage delta for incremental in-run metering (the usage flusher → broker). The * broker gates on the run's per-connection `billed_by_boardwalk` server-side + meters usage to the * platform; `identifier` makes a retried/duplicate flush idempotent. Satisfies {@link TokenUsageReporter}. */ meterTokens(input: { inputTokens: number; outputTokens: number; model?: string; identifier: string; /** Cache-served input tokens — display-only annotation (omitted when zero/unknown). */ cachedReadTokens?: number; cachedWriteTokens?: number; }): Promise; /** Check whether the run's org is still funded (the CreditWatcher → broker). The broker reads the * live billing balance server-side; `false` means out of credit (the watcher then aborts the run). */ checkCredit(): Promise; /** Check whether the run has been asked to cancel (the CancelWatcher → broker). `true` once the user * cancelled the run (the broker flipped it to `cancelling`/`cancelled`); the watcher then aborts the * run. Brokered because the runner holds no DB/Redis — this replaces the unreachable Redis channel. */ checkCancelled(): Promise; /** Register-without-release: register a HELD HITL gate's request row * so it is answerable while the run keeps running — no suspend. Idempotent. Returns whether a new * gate was registered. */ registerInput(seq: number, gate: unknown): Promise; /** Poll the resolved answers for a held gate at `seq` (empty until a human responds). */ pollInputAnswers(seq: number): Promise>; /** Gate the scope's projected footprint before any bytes move. A refusal carries WHY: `not_eligible` * (self-hosted — nothing was lost) versus `storage_limit` (we REFUSED a snapshot the run made). A * wire that conflated the two is how persistence stopped silently once before (§8, path 6). */ workspaceReserve(totalBytes: number): Promise; /** This scope's manifest plus the generation token a conditional write compares against. */ workspaceManifestRead(): Promise<{ manifest: string | null; generation: string | null; }>; /** Compare-and-swap this scope's manifest. `expected: null` asserts it does not exist yet. */ workspaceManifestWrite(manifest: string, expected: string | null, totalBytes: number): Promise; /** Which of these packs the scope already holds — the skip-upload check for a retried persist. */ workspacePacksExist(digests: readonly string[]): Promise; /** Presign PUT or GET for a batch of packs, returned keyed by digest. */ workspacePackUrls(op: "put" | "get", digests: readonly string[]): Promise>; /** Reclaim packs nothing references any more. Called AFTER the manifest naming the survivors has * landed, so a crash between the two leaves unreferenced bytes rather than a broken manifest. */ workspacePacksDelete(digests: readonly string[]): Promise; /** Download bytes from a presigned S3 URL (workspace hydrate). `null` on 404 (no snapshot yet — * e.g. the workflow's first run); throws on any other non-2xx. Goes straight to S3, not the broker. */ downloadBytes(url: string): Promise; /** Request an OIDC run id-token for `audience` (§OIDC). The broker mints an asymmetric, * third-party-verifiable token (gated server-side on `permissions.id_token: "write"`) — used to * federate into the org's OWN cloud (AWS/GCP). DIFFERENT from this client's run token. */ requestOidcToken(audience: string): Promise<{ token: string; expiresIn: number; }>; /** Publish a batch of live agent-event frames (the SSE live-tail source) — the broker publishes * them to the run's Redis channel server-side, so the runner holds no Redis credential. */ publishTelemetry(frames: string[]): Promise; /** Push a batch of encoded desktop frames (base64 JPEG) for the live-view surface — the broker * republishes them to the run's live-view channel server-side (never durably stored; the session * recording is the durable copy). See docs/SCREEN_CAPTURE.md §5. */ publishLiveView(frames: string[]): Promise; /** Is a browser currently watching this run's live-view? The capture loop polls this so it only * captures + pushes frames while someone is attached (capture costs guest CPU + metered egress). */ liveViewWanted(): Promise; /** Store a run artifact through the broker (which holds the S3 credential + neutralizes the served * content type server-side). Returns the catalog id + a signed download URL. */ writeArtifact(input: ArtifactWriteInput): Promise; /** Phase 1 of the LARGE-artifact path (the Runner Credential Broker model): presign an S3 PUT. The * broker derives the S3 key + neutralizes/pins the served content type; it returns the upload URL + * required headers + the `s3Key` to echo back at commit. No catalog row exists yet. */ presignArtifact(input: ArtifactPresignInput): Promise; /** Upload bytes to a presigned S3 URL (the large-artifact path). The `headers` come from the * presign response and MUST be sent verbatim — the content type is pinned into the signature, so * S3 rejects a mismatch. This call goes straight to S3, not the broker. */ uploadBytes(url: string, headers: Record, body: Uint8Array): Promise; /** Phase 2 of the LARGE-artifact path: register the catalog row AFTER the bytes have landed in S3 * (called only on a successful {@link uploadBytes}, so a failed upload leaves no dangling row). The * broker re-validates the run prefix + re-neutralizes the content type, then returns the catalog id * + a signed download URL. */ commitArtifact(input: ArtifactCommitInput): Promise; /** List the artifacts this run has produced. */ listArtifacts(): Promise; /** Mint a fresh signed download URL for one of this run's artifacts. */ signArtifactUrl(artifactId: string, ttlSeconds: number): Promise; /** Re-read the org's BYO inference providers. The claim hands over a snapshot; this is how the * runtime learns about a provider created AFTER the run started (see ByoProviderRegistry). * Non-secret data — names, endpoints, auth secret NAMES; the values still come from * `secrets/resolve`. */ byoProviders(): Promise; /** Resolve an org secret the run's manifest allows (the program's `secrets.get`). The broker * enforces the allowlist + returns the value. A missing (404) or disallowed (403) secret fails * the AUTHOR's `secrets.get` call, so it maps to a clean named error + what-to-do hint — the * transport framing (op, status, envelope) stays in the log, never in the run's error text. */ resolveSecret(name: string): Promise; /** Broker a short-lived OAuth bearer for a hosted MCP server (the engine's `mcpToken` hook, called * reactively after a 401). The broker vends from the org's connection vault and re-checks egress. * A 403 (no active connection / non-allowlisted host) degrades to `{ accessToken: null, hint }` so * the engine surfaces a clean failure instead of a thrown 500 mid-run; the token is never logged. */ mcpToken(serverUrl: string, invalidateToken?: string): Promise; /** Proxy a web_search through the broker (which holds the Tavily key) — the runner sends the * query, the broker calls Tavily and returns the results. */ webSearch(input: unknown): Promise; /** Create (or idempotently re-attach to) a child run for `workflows.call`. */ startChild(slug: string, input: unknown, ordinal?: number): Promise; /** Provision a durable schedule for `workflows.schedule`; returns the new schedule's id. */ scheduleWorkflow(slug: string, input: unknown, spec: BrokerScheduleSpec): Promise; /** Poll a child run's status/output, or null when it isn't this run's child (404). */ getChild(childRunId: string): Promise<{ id: string; status: string; output: unknown; outputSchema?: Record | null; } | null>; /** * Proxy one model turn through the broker (the Runner Credential Broker model). POSTs the * neutral conversation; the broker resolves the REAL model server-side (the runner holds no model * creds), invokes the matching engine adapter, and relays the model's stream back as NDJSON * `InferenceFrame`s (delta / result / error). Yields each frame; the engine-backed leaf surfaces * deltas via `providerIo.onDelta`, takes the terminal `result` as the turn, and throws on `error`. * * Backs {@link InferenceProxyTransport} (inference_transport.ts) — the model swap is invisible to * the engine loop, which keeps the runner provider-agnostic (the broker owns model invocation). A * non-200 (a failure BEFORE the stream began) throws the broker's already-classified message. * * Resilience (added because a bare drop here surfaced as a run-fatal `PROVIDER_ERROR: terminated`): * a transient failure is retried WHILE it is still safe to re-POST — i.e. before any CONTENT frame * has been relayed. Three cases: * - the POST never returned a response (connection refused/reset during an api-server rollover), * - a load-balancer {@link INFERENCE_RETRYABLE_STATUSES} (no healthy / gateway timeout) before the * stream began — NOT 502, which the broker uses for a real upstream model error, * - the body dropped MID-stream (undici `terminated` / socket close) but only `ping` heartbeats * had been relayed so far (the observed failure: a huge-context turn streamed only pings for * ~120s during time-to-first-token, then the connection dropped). * Once a `delta`/`reasoning`/`result`/`error` frame has been yielded, the model has already * produced output (or the turn finished), so a re-POST would duplicate it: the drop surfaces as * before. Re-POST is billing-safe — the broker aborts the abandoned turn on our disconnect and * returns before it meters (no `result` frame ⇒ no usage). The body is a reusable string. */ streamInference(req: InferenceProxyRequest): AsyncGenerator; /** Log + wait one backoff step before re-POSTing a transient inference failure (see streamInference). */ private backoffInferenceRetry; private url; private headers; }