@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,123 @@
1
+ import type { Budget } from "../wire/manifest.js";
2
+ /** Per-million-token rates. */
3
+ export interface ModelRate {
4
+ /** USD per million input tokens. */
5
+ inputPerMillion: number;
6
+ /** USD per million output tokens. */
7
+ outputPerMillion: number;
8
+ /** USD per million cache-read tokens (Anthropic). Defaults to inputPerMillion / 10. */
9
+ cacheReadPerMillion?: number;
10
+ /** USD per million cache-write tokens (Anthropic). Defaults to inputPerMillion * 1.25. */
11
+ cacheWritePerMillion?: number;
12
+ }
13
+ export interface UsageDelta {
14
+ inputTokens?: number;
15
+ outputTokens?: number;
16
+ cacheReadTokens?: number;
17
+ cacheWriteTokens?: number;
18
+ }
19
+ /**
20
+ * Cumulative usage carried forward from PRIOR worker sessions of the same run. Seeded from the
21
+ * checkpoint on resume so budget caps bound the WHOLE run, not just the current session — a run
22
+ * that sleeps / waits on a child / recovers from a crash must not get a fresh budget each time
23
+ * (review #2). Zero for a fresh run.
24
+ */
25
+ export interface PriorUsage {
26
+ inputTokens: number;
27
+ outputTokens: number;
28
+ cacheReadTokens: number;
29
+ cacheWriteTokens: number;
30
+ totalUsd: number;
31
+ /**
32
+ * Active execution time from prior sessions (ms). Only on-CPU turn time counts toward the
33
+ * duration cap — sleep/wait pauses don't burn it (a run intentionally parked for a day must
34
+ * not blow its duration budget on resume).
35
+ */
36
+ activeMs: number;
37
+ }
38
+ export interface BudgetMeterOptions {
39
+ budget?: Budget;
40
+ /** Rate for the model the agent is configured with. */
41
+ rate: ModelRate;
42
+ /** This SESSION's start time (ms). Per-session active duration is measured from here. */
43
+ startedAt: number;
44
+ /** The RUN's original start time (ms) — the basis for `deadline_seconds`, a WALL-CLOCK cap that
45
+ * INCLUDES suspended idle (unlike max_duration_seconds, which is active compute). Omit to leave
46
+ * the deadline cap unenforced (e.g. a run that hasn't started yet). */
47
+ deadlineStartedAt?: number;
48
+ /** Cumulative usage from prior sessions of this run (resume). Defaults to zero. */
49
+ priorUsage?: PriorUsage;
50
+ /** Injected clock for tests. */
51
+ now?: () => number;
52
+ }
53
+ export interface BudgetSnapshot {
54
+ inputTokens: number;
55
+ outputTokens: number;
56
+ cacheReadTokens: number;
57
+ cacheWriteTokens: number;
58
+ totalTokens: number;
59
+ totalUsd: number;
60
+ elapsedMs: number;
61
+ }
62
+ export declare class BudgetMeter {
63
+ private readonly budget;
64
+ private readonly rate;
65
+ private readonly startedAt;
66
+ private readonly deadlineStartedAt;
67
+ private readonly prior;
68
+ private readonly now;
69
+ private inputTokens;
70
+ private outputTokens;
71
+ private cacheReadTokens;
72
+ private cacheWriteTokens;
73
+ private totalUsd;
74
+ constructor(opts: BudgetMeterOptions);
75
+ /**
76
+ * Add a usage delta to the accumulator + recompute USD. `realCostUsd`, when provided, is the EXACT
77
+ * upstream cost the broker observed for this turn (OpenRouter's per-request `usage.cost`) — used
78
+ * verbatim so the `max_usd` cap tracks ACTUAL spend (already cache-discounted, model-correct).
79
+ * Omitted for a BYO turn (no upstream price) or a managed turn whose cost the broker couldn't read,
80
+ * which fall back to the representative-rate {@link costFor} estimate. Token counts accumulate on
81
+ * both paths (they drive the `max_tokens` cap + the snapshot); only the USD basis differs.
82
+ */
83
+ addUsage(delta: UsageDelta, realCostUsd?: number): void;
84
+ /**
85
+ * THIS SESSION's accumulator snapshot (excludes prior-session usage). Read by per-session token
86
+ * metering — which reports token deltas to Stripe (deferred; not yet wired into the brokered
87
+ * loop) — and by audit/post-run cost rollups. Cap enforcement uses {@link cumulative} instead.
88
+ */
89
+ snapshot(): BudgetSnapshot;
90
+ /**
91
+ * RUN-CUMULATIVE snapshot: this session's usage PLUS the prior sessions' usage seeded at
92
+ * construction. Cap enforcement ({@link assertWithinCaps}) and checkpoint persistence use
93
+ * this so a run that resumes after a sleep/wait/crash is bounded by its declared caps across
94
+ * the whole run — not once per session (review #2). `snapshot()` stays session-local because
95
+ * per-session token metering reports token deltas; seeding it would double-report to Stripe.
96
+ */
97
+ cumulative(): BudgetSnapshot;
98
+ /**
99
+ * Throws `AppError(BUDGET_EXCEEDED)` when any cap is breached. Call BETWEEN
100
+ * turns (after `addUsage` reflects the latest delta) — that way the meter
101
+ * tears the loop down before another LLM call is dispatched.
102
+ */
103
+ assertWithinCaps(): void;
104
+ /**
105
+ * Predicate variant for callers that prefer to handle the cap-hit path
106
+ * inline (e.g., the sleep tool rejects a sleep that would breach the
107
+ * duration cap). Returns the first breach reason or null.
108
+ */
109
+ capBreachReason(): "tokens" | "usd" | "duration" | "deadline" | null;
110
+ /**
111
+ * The declared `max_duration_seconds` cap, or null when unset. Lets callers that
112
+ * reason about wall-clock ahead of time (e.g. the sleep tool rejecting a sleep that
113
+ * would overrun the run) read the cap without reaching into the meter's internals.
114
+ */
115
+ durationCapSeconds(): number | null;
116
+ /**
117
+ * USD cost for a single delta — the representative-rate ESTIMATE used only when the broker reports
118
+ * no real upstream cost for the turn (a BYO-provider turn, or a managed turn whose cost tap missed).
119
+ * Managed turns instead carry OpenRouter's exact `usage.cost` through to {@link addUsage}. Public so
120
+ * the loop can stamp the per-step `cost_usd` column on `run_steps` without re-deriving the rate table.
121
+ */
122
+ costFor(delta: UsageDelta): number;
123
+ }
@@ -0,0 +1,174 @@
1
+ // BudgetMeter — enforces token / USD / duration caps declared in the manifest.
2
+ //
3
+ // One meter per run. The agent loop calls `addUsage()` after each LLM turn and
4
+ // `assertWithinCaps()` between turns. When a cap is exceeded the meter throws
5
+ // `AppError(BUDGET_EXCEEDED)` and the loop tears down with a `turn_ended
6
+ // reason='error'` event.
7
+ //
8
+ // USD here tracks the bill closely: for a MANAGED turn the meter is fed OpenRouter's exact per-request
9
+ // `usage.cost` (already cache-discounted + model-correct), so the `max_usd` cap reflects real spend —
10
+ // not a hand-rolled token estimate. Only a BYO turn (no upstream price) or a managed turn whose cost
11
+ // the broker couldn't read falls back to a single flat representative rate
12
+ // (`model_rates.ts::BUDGET_GUARDRAIL_RATE`), since a per-`agent()`-model run has no one model. Actual
13
+ // billing remains per-request cost pass-through via Stripe meters (MASTER_SPEC §15.2). The meter takes
14
+ // the fallback rate as a constructor arg so tests inject a fixed rate.
15
+ import { AppError, ErrorCode } from "../support/index.js";
16
+ const ZERO_PRIOR = {
17
+ inputTokens: 0,
18
+ outputTokens: 0,
19
+ cacheReadTokens: 0,
20
+ cacheWriteTokens: 0,
21
+ totalUsd: 0,
22
+ activeMs: 0,
23
+ };
24
+ export class BudgetMeter {
25
+ budget;
26
+ rate;
27
+ startedAt;
28
+ deadlineStartedAt;
29
+ prior;
30
+ now;
31
+ inputTokens = 0;
32
+ outputTokens = 0;
33
+ cacheReadTokens = 0;
34
+ cacheWriteTokens = 0;
35
+ totalUsd = 0;
36
+ constructor(opts) {
37
+ if (opts.budget !== undefined)
38
+ this.budget = opts.budget;
39
+ this.rate = opts.rate;
40
+ this.startedAt = opts.startedAt;
41
+ this.deadlineStartedAt = opts.deadlineStartedAt ?? null;
42
+ this.prior = opts.priorUsage ?? ZERO_PRIOR;
43
+ this.now = opts.now ?? Date.now;
44
+ }
45
+ /**
46
+ * Add a usage delta to the accumulator + recompute USD. `realCostUsd`, when provided, is the EXACT
47
+ * upstream cost the broker observed for this turn (OpenRouter's per-request `usage.cost`) — used
48
+ * verbatim so the `max_usd` cap tracks ACTUAL spend (already cache-discounted, model-correct).
49
+ * Omitted for a BYO turn (no upstream price) or a managed turn whose cost the broker couldn't read,
50
+ * which fall back to the representative-rate {@link costFor} estimate. Token counts accumulate on
51
+ * both paths (they drive the `max_tokens` cap + the snapshot); only the USD basis differs.
52
+ */
53
+ addUsage(delta, realCostUsd) {
54
+ this.inputTokens += delta.inputTokens ?? 0;
55
+ this.outputTokens += delta.outputTokens ?? 0;
56
+ this.cacheReadTokens += delta.cacheReadTokens ?? 0;
57
+ this.cacheWriteTokens += delta.cacheWriteTokens ?? 0;
58
+ this.totalUsd += realCostUsd ?? this.costFor(delta);
59
+ }
60
+ /**
61
+ * THIS SESSION's accumulator snapshot (excludes prior-session usage). Read by per-session token
62
+ * metering — which reports token deltas to Stripe (deferred; not yet wired into the brokered
63
+ * loop) — and by audit/post-run cost rollups. Cap enforcement uses {@link cumulative} instead.
64
+ */
65
+ snapshot() {
66
+ return {
67
+ inputTokens: this.inputTokens,
68
+ outputTokens: this.outputTokens,
69
+ cacheReadTokens: this.cacheReadTokens,
70
+ cacheWriteTokens: this.cacheWriteTokens,
71
+ totalTokens: this.inputTokens + this.outputTokens,
72
+ totalUsd: this.totalUsd,
73
+ elapsedMs: this.now() - this.startedAt,
74
+ };
75
+ }
76
+ /**
77
+ * RUN-CUMULATIVE snapshot: this session's usage PLUS the prior sessions' usage seeded at
78
+ * construction. Cap enforcement ({@link assertWithinCaps}) and checkpoint persistence use
79
+ * this so a run that resumes after a sleep/wait/crash is bounded by its declared caps across
80
+ * the whole run — not once per session (review #2). `snapshot()` stays session-local because
81
+ * per-session token metering reports token deltas; seeding it would double-report to Stripe.
82
+ */
83
+ cumulative() {
84
+ const s = this.snapshot();
85
+ return {
86
+ inputTokens: s.inputTokens + this.prior.inputTokens,
87
+ outputTokens: s.outputTokens + this.prior.outputTokens,
88
+ cacheReadTokens: s.cacheReadTokens + this.prior.cacheReadTokens,
89
+ cacheWriteTokens: s.cacheWriteTokens + this.prior.cacheWriteTokens,
90
+ totalTokens: s.inputTokens + this.prior.inputTokens + s.outputTokens + this.prior.outputTokens,
91
+ totalUsd: s.totalUsd + this.prior.totalUsd,
92
+ elapsedMs: s.elapsedMs + this.prior.activeMs,
93
+ };
94
+ }
95
+ /**
96
+ * Throws `AppError(BUDGET_EXCEEDED)` when any cap is breached. Call BETWEEN
97
+ * turns (after `addUsage` reflects the latest delta) — that way the meter
98
+ * tears the loop down before another LLM call is dispatched.
99
+ */
100
+ assertWithinCaps() {
101
+ if (this.budget === undefined)
102
+ return;
103
+ // Cumulative across resume sessions — caps bound the whole run, not each session (review #2).
104
+ const snap = this.cumulative();
105
+ // `max_tokens` deliberately bounds CONVERSATION tokens (input + output) only; cache-read /
106
+ // cache-write tokens are tracked + fully billed via costFor() and are bounded by `max_usd`,
107
+ // not by this cap (review #19). totalTokens (snapshot/cumulative) reflects that choice.
108
+ if (this.budget.max_tokens !== undefined && snap.totalTokens > this.budget.max_tokens) {
109
+ throw new AppError(ErrorCode.BUDGET_EXCEEDED, `Token cap exceeded: ${snap.totalTokens.toString()} > ${this.budget.max_tokens.toString()}`, { kind: "tokens", used: snap.totalTokens, cap: this.budget.max_tokens });
110
+ }
111
+ if (this.budget.max_usd !== undefined && snap.totalUsd > this.budget.max_usd) {
112
+ throw new AppError(ErrorCode.BUDGET_EXCEEDED, `USD cap exceeded: $${snap.totalUsd.toFixed(4)} > $${this.budget.max_usd.toFixed(4)}`, { kind: "usd", used: snap.totalUsd, cap: this.budget.max_usd });
113
+ }
114
+ if (this.budget.max_duration_seconds !== undefined) {
115
+ const elapsedSeconds = Math.floor(snap.elapsedMs / 1000);
116
+ if (elapsedSeconds > this.budget.max_duration_seconds) {
117
+ throw new AppError(ErrorCode.BUDGET_EXCEEDED, `Duration cap exceeded: ${elapsedSeconds.toString()}s > ${this.budget.max_duration_seconds.toString()}s`, { kind: "duration", used: elapsedSeconds, cap: this.budget.max_duration_seconds });
118
+ }
119
+ }
120
+ // deadline_seconds: WALL-CLOCK from the run's original start, INCLUDING suspended idle (a run
121
+ // resumed past its deadline trips on its first cap check; a long-running one trips per turn).
122
+ // Distinct from max_duration_seconds (active compute) — a long sleep / human-input wait does not
123
+ // burn that, but DOES count here.
124
+ if (this.budget.deadline_seconds !== undefined && this.deadlineStartedAt !== null) {
125
+ const wallSeconds = Math.floor((this.now() - this.deadlineStartedAt) / 1000);
126
+ if (wallSeconds > this.budget.deadline_seconds) {
127
+ throw new AppError(ErrorCode.BUDGET_EXCEEDED, `Deadline exceeded: ${wallSeconds.toString()}s > ${this.budget.deadline_seconds.toString()}s (wall-clock)`, { kind: "deadline", used: wallSeconds, cap: this.budget.deadline_seconds });
128
+ }
129
+ }
130
+ }
131
+ /**
132
+ * Predicate variant for callers that prefer to handle the cap-hit path
133
+ * inline (e.g., the sleep tool rejects a sleep that would breach the
134
+ * duration cap). Returns the first breach reason or null.
135
+ */
136
+ capBreachReason() {
137
+ try {
138
+ this.assertWithinCaps();
139
+ return null;
140
+ }
141
+ catch (err) {
142
+ if (err instanceof AppError &&
143
+ typeof err.detail === "object" &&
144
+ err.detail !== null &&
145
+ "kind" in err.detail) {
146
+ return err.detail.kind;
147
+ }
148
+ throw err;
149
+ }
150
+ }
151
+ /**
152
+ * The declared `max_duration_seconds` cap, or null when unset. Lets callers that
153
+ * reason about wall-clock ahead of time (e.g. the sleep tool rejecting a sleep that
154
+ * would overrun the run) read the cap without reaching into the meter's internals.
155
+ */
156
+ durationCapSeconds() {
157
+ return this.budget?.max_duration_seconds ?? null;
158
+ }
159
+ /**
160
+ * USD cost for a single delta — the representative-rate ESTIMATE used only when the broker reports
161
+ * no real upstream cost for the turn (a BYO-provider turn, or a managed turn whose cost tap missed).
162
+ * Managed turns instead carry OpenRouter's exact `usage.cost` through to {@link addUsage}. Public so
163
+ * the loop can stamp the per-step `cost_usd` column on `run_steps` without re-deriving the rate table.
164
+ */
165
+ costFor(delta) {
166
+ const inputCost = ((delta.inputTokens ?? 0) * this.rate.inputPerMillion) / 1_000_000;
167
+ const outputCost = ((delta.outputTokens ?? 0) * this.rate.outputPerMillion) / 1_000_000;
168
+ const cacheReadRate = this.rate.cacheReadPerMillion ?? this.rate.inputPerMillion / 10;
169
+ const cacheWriteRate = this.rate.cacheWritePerMillion ?? this.rate.inputPerMillion * 1.25;
170
+ const cacheReadCost = ((delta.cacheReadTokens ?? 0) * cacheReadRate) / 1_000_000;
171
+ const cacheWriteCost = ((delta.cacheWriteTokens ?? 0) * cacheWriteRate) / 1_000_000;
172
+ return inputCost + outputCost + cacheReadCost + cacheWriteCost;
173
+ }
174
+ }
@@ -0,0 +1,46 @@
1
+ export { runEventSchema, CHANNELS, DEFAULT_CHANNELS, channelOf, matchesChannels, makeCursor, TURN_CURSOR_STRIDE, type RunEvent, type RunEventKind, type Channel, type EventEnvelope, type TokenUsage, type ToolReturn, } from "@boardwalk-labs/workflow";
2
+ import type { RunEvent } from "@boardwalk-labs/workflow";
3
+ /** Error shape on `tool_call_error` and `turn_ended` (reason='error') — the SDK's event error. */
4
+ export interface AgentApiError {
5
+ code: string;
6
+ message: string;
7
+ }
8
+ /** Distributive omit that preserves the discriminated union. */
9
+ type BodyOf<E> = E extends RunEvent ? Omit<E, "runId" | "turnId" | "seq" | "t"> : never;
10
+ /**
11
+ * A run event WITHOUT its envelope — what producers (the normalizer, the phase tracker, the log
12
+ * capture) build. The shared emitter stamps the envelope + computes the cursor.
13
+ */
14
+ export type RunEventBody = BodyOf<RunEvent>;
15
+ /** The published/stored row: the run-global cursor + the full enveloped event (engine parity). */
16
+ export interface RunEventRow {
17
+ cursor: number;
18
+ event: RunEvent;
19
+ }
20
+ /**
21
+ * Minimal Redis publisher surface (publish only). We depend only on `publish` so tests inject a
22
+ * fake without pulling in ioredis; production wires the broker telemetry publisher.
23
+ */
24
+ export interface RedisPublisher {
25
+ publish(channel: string, message: string): Promise<number>;
26
+ }
27
+ /**
28
+ * The shared per-run emitter surface a turn stamps through (the worker's WorkerRunEventEmitter
29
+ * satisfies it). `emit` stamps the envelope + cursor on a body and rides the current stride block;
30
+ * `beginTurn` opens a new agent stride block, then emits its opening frame (the caller supplies
31
+ * `turn_started`).
32
+ */
33
+ export interface TurnEventSink {
34
+ emit(body: RunEventBody, turnId?: string): RunEvent;
35
+ beginTurn(turnId: string, started: RunEventBody): void;
36
+ }
37
+ /**
38
+ * Identity of one agent() leaf — stamped on its `turn_started`/`turn_ended` frames so a stream
39
+ * consumer can tell concurrent agents apart. `agentId` is stable + run-unique (worker-assigned);
40
+ * `agentName` is the author's `AgentOptions.name`, present only when set. Mirrors the engine's
41
+ * `AgentIdentity` shape so the platform's wire matches `boardwalk dev` and the self-hosted server.
42
+ */
43
+ export interface AgentIdentity {
44
+ agentId: string;
45
+ agentName?: string;
46
+ }
@@ -0,0 +1,8 @@
1
+ // Run-event wire format — ADOPTED from the published SDK (flip item, 2026-06-12).
2
+ //
3
+ // The contract lives in `@boardwalk-labs/workflow` (`runEventSchema`, CHANNELS, cursors): one
4
+ // typed, ordered stream per run, identical in every engine. This module re-exports the SDK
5
+ // contract and defines the BODY shape producers build (the envelope — runId / turnId / seq / t —
6
+ // is stamped centrally by the worker's shared RunEventEmitter, which owns the run-global
7
+ // `cursor = turn * TURN_CURSOR_STRIDE + seq` counter exactly like the OSS engine's supervisor).
8
+ export { runEventSchema, CHANNELS, DEFAULT_CHANNELS, channelOf, matchesChannels, makeCursor, TURN_CURSOR_STRIDE, } from "@boardwalk-labs/workflow";
@@ -0,0 +1,7 @@
1
+ import type { ModelRate } from "./budget.js";
2
+ /**
3
+ * The flat representative rate (USD / million tokens) the BudgetMeter applies to the `max_usd` cap
4
+ * ONLY as the fallback for a turn with no real upstream cost (BYO / unavailable); a managed turn caps
5
+ * on its exact `usage.cost`. Sonnet-class list pricing — a model-agnostic backstop, not the billed price.
6
+ */
7
+ export declare const BUDGET_GUARDRAIL_RATE: ModelRate;
@@ -0,0 +1,21 @@
1
+ // The flat budget-guardrail rate — the FALLBACK basis for the in-run `max_usd` cap (BudgetMeter).
2
+ //
3
+ // A MANAGED turn now caps on OpenRouter's EXACT per-request `usage.cost`, forwarded from the broker to
4
+ // the worker (inference_proxy result frame → BudgetMeter.addUsage `realCostUsd`), so the cap tracks
5
+ // real spend — already cache-discounted + model-correct. This flat rate is used ONLY when a turn has
6
+ // no upstream cost: a BYO-provider turn (the org pays its own key), or a managed turn whose cost the
7
+ // broker couldn't read. It bounds runaway loops; it is NOT the bill. Actual billing is per-request
8
+ // COST PASS-THROUGH (OpenRouter's reported cost × margin) via Stripe meters (domain/billing +
9
+ // run_usage_service). MASTER_SPEC §15.2.
10
+ //
11
+ // Deliberately NOT a per-model table with a silent fallback: a lookup that returns a Sonnet-class
12
+ // default for any unknown id only created the ILLUSION of precision. The real per-request cost (above)
13
+ // is the precise path; this rate is just the model-agnostic backstop. Un-metered managed models are
14
+ // rejected fail-closed at resolution (domain/inference/model_resolver), so the cap never has to price
15
+ // a model we don't support.
16
+ /**
17
+ * The flat representative rate (USD / million tokens) the BudgetMeter applies to the `max_usd` cap
18
+ * ONLY as the fallback for a turn with no real upstream cost (BYO / unavailable); a managed turn caps
19
+ * on its exact `usage.cost`. Sonnet-class list pricing — a model-agnostic backstop, not the billed price.
20
+ */
21
+ export const BUDGET_GUARDRAIL_RATE = { inputPerMillion: 3, outputPerMillion: 15 };
@@ -0,0 +1,42 @@
1
+ /** Replacement token substituted for any known secret value. Generic on purpose — it must not
2
+ * reveal WHICH secret matched. */
3
+ export declare const REDACTION_PLACEHOLDER = "[REDACTED]";
4
+ /**
5
+ * Values shorter than this are NOT recorded. A 1–3 char "secret" would match common substrings and
6
+ * shred unrelated text; real credentials are long. Mirrors GitHub Actions' masking floor. A value
7
+ * this short should not be a secret in the first place — and the structural guarantee (secrets never
8
+ * flow to the LLM by construction) still holds for it; only the belt-and-suspenders scrub is skipped.
9
+ */
10
+ export declare const MIN_REDACTABLE_LENGTH = 4;
11
+ export interface SecretRedactorOptions {
12
+ placeholder?: string;
13
+ minLength?: number;
14
+ }
15
+ export declare class SecretRedactor {
16
+ /** Recorded secret values, kept sorted longest-first so a longer secret is replaced before any
17
+ * shorter secret that is its substring. */
18
+ private recorded;
19
+ private readonly seen;
20
+ private readonly placeholder;
21
+ private readonly minLength;
22
+ constructor(opts?: SecretRedactorOptions);
23
+ /** Number of distinct values currently being redacted. */
24
+ get size(): number;
25
+ /** A snapshot of the recorded secret values (longest-first). Read by the worker to seed a fresh
26
+ * engine `Redactor` per leaf so the engine loop scrubs the SAME values out of model-bound content. */
27
+ get values(): readonly string[];
28
+ /**
29
+ * Record a resolved secret value so future LLM-bound content has it scrubbed. No-op for values
30
+ * below the length floor or already known. Idempotent.
31
+ */
32
+ record(value: string): void;
33
+ /** Replace every occurrence of every known secret value in `text` with the placeholder. */
34
+ redactText(text: string): string;
35
+ /**
36
+ * Deep-redact a JSON-shaped value: strings are scrubbed, arrays/objects recursed. Non-string
37
+ * primitives pass through (a secret is always a string). Returns the input unchanged when nothing
38
+ * is recorded, so the common no-secrets case is allocation-free.
39
+ */
40
+ redactValue(value: unknown): unknown;
41
+ private redactAt;
42
+ }
@@ -0,0 +1,98 @@
1
+ // SecretRedactor — scrubs known secret values from everything bound for an LLM (MASTER_SPEC §12,
2
+ // docs/WORKFLOW_RUNTIME.md §6.2, plan #9).
3
+ //
4
+ // The architectural guarantee is structural: secrets live ONLY in the workflow PROGRAM (the trusted
5
+ // deterministic tool layer), which holds them via `secrets.get` / `ctx.secrets.resolve`. The
6
+ // `agent()` leaf never receives a secret by design — so prompt injection can't reach one. This
7
+ // redactor is DEFENSE-IN-DEPTH for the path where a program (or a tool result) inadvertently
8
+ // carries a known secret value into LLM context (prompt, system prompt, tool args/results,
9
+ // transcript): every value the run resolves is recorded here, and the leaf scrubs known values out
10
+ // of LLM-bound text before the model ever sees them.
11
+ //
12
+ // Pure logic by design (no I/O, no deps) so it can be tested exhaustively — the platform's secret
13
+ // boundary lives here.
14
+ /** Replacement token substituted for any known secret value. Generic on purpose — it must not
15
+ * reveal WHICH secret matched. */
16
+ export const REDACTION_PLACEHOLDER = "[REDACTED]";
17
+ /**
18
+ * Values shorter than this are NOT recorded. A 1–3 char "secret" would match common substrings and
19
+ * shred unrelated text; real credentials are long. Mirrors GitHub Actions' masking floor. A value
20
+ * this short should not be a secret in the first place — and the structural guarantee (secrets never
21
+ * flow to the LLM by construction) still holds for it; only the belt-and-suspenders scrub is skipped.
22
+ */
23
+ export const MIN_REDACTABLE_LENGTH = 4;
24
+ /** Bound on recursion into nested tool results. JSON has no cycles, but a pathological structure
25
+ * shouldn't blow the stack; beyond this depth a value can't realistically still be a secret. */
26
+ const MAX_REDACT_DEPTH = 100;
27
+ export class SecretRedactor {
28
+ /** Recorded secret values, kept sorted longest-first so a longer secret is replaced before any
29
+ * shorter secret that is its substring. */
30
+ recorded = [];
31
+ seen = new Set();
32
+ placeholder;
33
+ minLength;
34
+ constructor(opts = {}) {
35
+ this.placeholder = opts.placeholder ?? REDACTION_PLACEHOLDER;
36
+ this.minLength = opts.minLength ?? MIN_REDACTABLE_LENGTH;
37
+ }
38
+ /** Number of distinct values currently being redacted. */
39
+ get size() {
40
+ return this.recorded.length;
41
+ }
42
+ /** A snapshot of the recorded secret values (longest-first). Read by the worker to seed a fresh
43
+ * engine `Redactor` per leaf so the engine loop scrubs the SAME values out of model-bound content. */
44
+ get values() {
45
+ return [...this.recorded];
46
+ }
47
+ /**
48
+ * Record a resolved secret value so future LLM-bound content has it scrubbed. No-op for values
49
+ * below the length floor or already known. Idempotent.
50
+ */
51
+ record(value) {
52
+ if (value.length < this.minLength)
53
+ return;
54
+ if (this.seen.has(value))
55
+ return;
56
+ this.seen.add(value);
57
+ this.recorded.push(value);
58
+ // Longest-first: replacing the longer match first keeps a shorter substring secret from
59
+ // fragmenting the longer one (e.g. "abc" before "abcdef" would leave "[REDACTED]def").
60
+ this.recorded.sort((a, b) => b.length - a.length);
61
+ }
62
+ /** Replace every occurrence of every known secret value in `text` with the placeholder. */
63
+ redactText(text) {
64
+ if (this.recorded.length === 0)
65
+ return text;
66
+ let out = text;
67
+ for (const v of this.recorded) {
68
+ if (out.includes(v))
69
+ out = out.split(v).join(this.placeholder);
70
+ }
71
+ return out;
72
+ }
73
+ /**
74
+ * Deep-redact a JSON-shaped value: strings are scrubbed, arrays/objects recursed. Non-string
75
+ * primitives pass through (a secret is always a string). Returns the input unchanged when nothing
76
+ * is recorded, so the common no-secrets case is allocation-free.
77
+ */
78
+ redactValue(value) {
79
+ if (this.recorded.length === 0)
80
+ return value;
81
+ return this.redactAt(value, 0);
82
+ }
83
+ redactAt(value, depth) {
84
+ if (typeof value === "string")
85
+ return this.redactText(value);
86
+ if (depth >= MAX_REDACT_DEPTH)
87
+ return value;
88
+ if (Array.isArray(value))
89
+ return value.map((v) => this.redactAt(v, depth + 1));
90
+ if (value !== null && typeof value === "object") {
91
+ const out = {};
92
+ for (const [k, v] of Object.entries(value))
93
+ out[k] = this.redactAt(v, depth + 1);
94
+ return out;
95
+ }
96
+ return value;
97
+ }
98
+ }
@@ -0,0 +1,17 @@
1
+ import type { ArtifactCommitInput, ArtifactPresignInput, ArtifactPresignResult, ArtifactSignResult, ArtifactStore, ArtifactSummary, ArtifactWriteInput, ArtifactWriteResult } from "./tools/artifacts.js";
2
+ /** The broker surface the store needs (RunnerControlClient satisfies it). */
3
+ export interface ArtifactBrokerTransport {
4
+ writeArtifact(input: ArtifactWriteInput): Promise<ArtifactWriteResult>;
5
+ presignArtifact(input: ArtifactPresignInput): Promise<ArtifactPresignResult>;
6
+ uploadBytes(url: string, headers: Record<string, string>, body: Uint8Array): Promise<void>;
7
+ commitArtifact(input: ArtifactCommitInput): Promise<ArtifactWriteResult>;
8
+ listArtifacts(): Promise<ArtifactSummary[]>;
9
+ signArtifactUrl(artifactId: string, ttlSeconds: number): Promise<ArtifactSignResult>;
10
+ }
11
+ export declare class BrokerArtifactStore implements ArtifactStore {
12
+ private readonly broker;
13
+ constructor(broker: ArtifactBrokerTransport);
14
+ write(input: ArtifactWriteInput): Promise<ArtifactWriteResult>;
15
+ list(): Promise<ArtifactSummary[]>;
16
+ signedUrl(artifactId: string, ttlSeconds: number): Promise<ArtifactSignResult>;
17
+ }
@@ -0,0 +1,46 @@
1
+ // BrokerArtifactStore — the ArtifactStore the `artifacts` tool uses under the Runner Credential
2
+ // Broker (docs/RUNNER_BROKER.md §4 — Artifacts). It forwards every op to the Runner Control API:
3
+ // the broker computes the S3 key, neutralizes the served content type (review #32), PUTs with its
4
+ // own creds, and records the catalog row — so the untrusted runner holds no S3 credential and can't
5
+ // bypass those server-side rules. A thin adapter over the run-bound RunnerControlClient.
6
+ //
7
+ // Writes route by size (docs/RUNNER_BROKER.md §7 step 4): a SMALL body is proxied inline through the
8
+ // broker (`POST .../artifacts`); a LARGE one takes the presigned-PUT path — the broker signs an S3
9
+ // PUT (still owning the key + content-type neutralization + catalog row) and the runner streams the
10
+ // bytes straight to S3, so they never pass through the control plane (no body cap).
11
+ import { decodeArtifactBody, shouldPresignArtifact } from "./wire/artifact_storage.js";
12
+ export class BrokerArtifactStore {
13
+ broker;
14
+ constructor(broker) {
15
+ this.broker = broker;
16
+ }
17
+ async write(input) {
18
+ const bytes = decodeArtifactBody(input.body, input.encoding);
19
+ if (!shouldPresignArtifact(bytes.length)) {
20
+ // Small: proxy the bytes inline (the broker neutralizes + PUTs + catalogs in one round trip).
21
+ return this.broker.writeArtifact(input);
22
+ }
23
+ // Large: presign → PUT bytes straight to S3 → commit. The broker owns the key, content-type
24
+ // neutralization, and the catalog row; only the byte transfer skips the control plane. The row is
25
+ // registered at commit, AFTER the PUT — so a failed upload (uploadBytes throws here) leaves none.
26
+ const presign = await this.broker.presignArtifact({
27
+ name: input.name,
28
+ contentType: input.contentType,
29
+ sizeBytes: bytes.length,
30
+ });
31
+ await this.broker.uploadBytes(presign.uploadUrl, presign.uploadHeaders, bytes);
32
+ return this.broker.commitArtifact({
33
+ s3Key: presign.s3Key,
34
+ name: input.name,
35
+ contentType: input.contentType,
36
+ sizeBytes: bytes.length,
37
+ ...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
38
+ });
39
+ }
40
+ list() {
41
+ return this.broker.listArtifacts();
42
+ }
43
+ signedUrl(artifactId, ttlSeconds) {
44
+ return this.broker.signArtifactUrl(artifactId, ttlSeconds);
45
+ }
46
+ }
@@ -0,0 +1,31 @@
1
+ import type { CallOptions } from "@boardwalk-labs/workflow/runtime";
2
+ import type { ChildDispatcher, ChildResult, ScheduleOptions } from "./workflow_host.js";
3
+ import type { RunnerControlClient } from "./runner_control_client.js";
4
+ export interface BrokerChildDispatcherDeps {
5
+ client: RunnerControlClient;
6
+ /** Wait between polls. Injected so tests don't sleep on real time. Defaults to setTimeout. */
7
+ sleep?: (ms: number) => Promise<void>;
8
+ pollIntervalMs?: number;
9
+ }
10
+ export declare class BrokerChildDispatcher implements ChildDispatcher {
11
+ private readonly deps;
12
+ private readonly sleep;
13
+ private readonly pollIntervalMs;
14
+ constructor(deps: BrokerChildDispatcherDeps);
15
+ /** Create (or re-attach to) the child, then hold + poll to terminal and return its output. An
16
+ * abort (`signal`) stops the hold within one poll interval and throws RunAbortedError. */
17
+ call(slug: string, input: unknown, _opts: CallOptions | undefined, signal?: AbortSignal): Promise<unknown>;
18
+ /** Start (or idempotently re-attach to) the child and resolve its CURRENT state — no hold. The
19
+ * durable callWorkflow seam decides whether to suspend (non-terminal) or return (terminal). */
20
+ start(slug: string, input: unknown, _opts: CallOptions | undefined, signal?: AbortSignal): Promise<ChildResult>;
21
+ /** Poll a child's current state by id (the resume path), or null when it isn't this run's child. */
22
+ poll(childRunId: string): Promise<ChildResult | null>;
23
+ /** Fire-and-forget: create (or re-attach to) the child and return its id without holding. */
24
+ run(slug: string, input: unknown, _opts: CallOptions | undefined): Promise<string>;
25
+ /** Provision a durable schedule via the broker; returns the new schedule's id. A `Date` `at` is
26
+ * normalized to an ISO string (the broker spec carries string | number, not Date). */
27
+ schedule(slug: string, input: unknown, opts: ScheduleOptions): Promise<string>;
28
+ private pollToCompletion;
29
+ /** A completed child returns its output; a failed/cancelled child rejects the parent's await. */
30
+ private childOutput;
31
+ }