@boardwalk-labs/runner 0.2.16 → 0.3.0-alpha.1
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.
- package/README.md +1 -1
- package/dist/bin.js +5 -0
- package/dist/runtime/agent/budget.d.ts +43 -24
- package/dist/runtime/agent/budget.js +108 -57
- package/dist/runtime/agent/events.d.ts +1 -1
- package/dist/runtime/broker_child_dispatcher.d.ts +5 -2
- package/dist/runtime/broker_child_dispatcher.js +24 -6
- package/dist/runtime/budget_gate.d.ts +58 -21
- package/dist/runtime/budget_gate.js +227 -46
- package/dist/runtime/host_capabilities.d.ts +4 -0
- package/dist/runtime/host_capabilities.js +25 -0
- package/dist/runtime/host_server.d.ts +137 -0
- package/dist/runtime/host_server.js +562 -0
- package/dist/runtime/identity_relay.js +19 -9
- package/dist/runtime/index.d.ts +38 -0
- package/dist/runtime/index.js +109 -35
- package/dist/runtime/leaf_executor.d.ts +7 -4
- package/dist/runtime/leaf_executor.js +11 -6
- package/dist/runtime/program_runner.d.ts +62 -42
- package/dist/runtime/program_runner.js +156 -101
- package/dist/runtime/program_worker.d.ts +22 -11
- package/dist/runtime/program_worker.js +26 -48
- package/dist/runtime/python_program.d.ts +68 -0
- package/dist/runtime/python_program.js +270 -0
- package/dist/runtime/run_context.d.ts +13 -0
- package/dist/runtime/run_context.js +77 -0
- package/dist/runtime/runner_control_client.d.ts +23 -0
- package/dist/runtime/runner_control_client.js +69 -147
- package/dist/runtime/screen_capture_backend.d.ts +3 -0
- package/dist/runtime/screen_capture_backend.js +17 -2
- package/dist/runtime/shell_exec.d.ts +17 -0
- package/dist/runtime/shell_exec.js +144 -0
- package/dist/runtime/support/index.d.ts +18 -0
- package/dist/runtime/support/index.js +38 -6
- package/dist/runtime/wire/inference_proxy.js +1 -1
- package/dist/runtime/wire/run.d.ts +5 -2
- package/dist/runtime/workflow_host.d.ts +53 -7
- package/dist/runtime/workflow_host.js +82 -42
- package/package.json +4 -3
|
@@ -1,10 +1,70 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
+
// The budget gate (docs/SUSPEND_POLICY.md Decision 3): when a run hits ANY of its budget caps —
|
|
3
|
+
// `max_usd`, `max_tokens`, or `max_compute_seconds` — PARK and ask a person instead of failing the
|
|
4
|
+
// run.
|
|
5
|
+
//
|
|
6
|
+
// Why park rather than throw: on the snapshot fleet a park costs a memory snapshot and releases the
|
|
7
|
+
// host, so "wait for a human" is effectively free — while a hard failure discards everything the run
|
|
8
|
+
// has done (a real run lost ~40 minutes of paid agent work to a cap breach with no warning). The cap
|
|
9
|
+
// stops being a cliff and becomes a checkpoint.
|
|
10
|
+
//
|
|
11
|
+
// This module is the POLICY (when to park, what to ask, what an answer means). The MECHANISM is the
|
|
12
|
+
// host's `budgetClearance` (register-without-release + freeze), which is the same machinery any
|
|
13
|
+
// `humanInput()` gate uses.
|
|
14
|
+
//
|
|
15
|
+
// ## Park points (where `clear()` is awaited)
|
|
16
|
+
//
|
|
17
|
+
// - the leaf executor's `streamModel` seam, before EVERY model call — the only place `usd` and
|
|
18
|
+
// `tokens` can move, so they always park here within one in-flight turn of the breach;
|
|
19
|
+
// - the workflow host's blocking/spending capability seams (`sleep`, `shell`, `workflows.call`) —
|
|
20
|
+
// `compute` burns continuously, so a breach between model calls parks at whichever capability
|
|
21
|
+
// the program touches next;
|
|
22
|
+
// - between seams, {@link ComputeBreachWatcher} detects a compute breach on a timer. It cannot
|
|
23
|
+
// itself freeze the VM (see its doc block — true park-anywhere needs fleet-host coordination),
|
|
24
|
+
// so its job is prompt detection + logging; the park still lands at the next seam above.
|
|
25
|
+
//
|
|
26
|
+
// ## The answer wire format (what backend/web/CLI must send back)
|
|
27
|
+
//
|
|
28
|
+
// The gate registers as an ordinary human-input row, key `budget`, with a `choice` input spec:
|
|
29
|
+
//
|
|
30
|
+
// { kind: "choice", options: [<dimension presets>, "cancel"], other: true }
|
|
31
|
+
//
|
|
32
|
+
// The presets are dimension-native (render them as the row's buttons verbatim):
|
|
33
|
+
//
|
|
34
|
+
// usd "+$10" | "+$25" | "+$100"
|
|
35
|
+
// tokens "+100k" | "+1M" | "+10M"
|
|
36
|
+
// compute "+15min" | "+1h" | "+4h"
|
|
37
|
+
//
|
|
38
|
+
// The ANSWER is a single string (a chosen option, or a free-form value via the choice's `other`),
|
|
39
|
+
// interpreted against the dimension the gate asked about (the runner knows which dimension parked;
|
|
40
|
+
// the answer does not repeat it). Grammar, per dimension — `+` prefix = INCREMENT on the current
|
|
41
|
+
// cap, no prefix = ABSOLUTE new cap:
|
|
42
|
+
//
|
|
43
|
+
// usd "+$25", "+25" → cap + 25 dollars; "50", "$50" → cap = $50
|
|
44
|
+
// tokens "+100k", "+1M", "+2500000" → cap + that many; "5M", "750k", "2000000" → cap = that
|
|
45
|
+
// (k = thousand, M = million, case-insensitive, fractions allowed: "+2.5M")
|
|
46
|
+
// compute "+15min", "+1h", "+90s", "+600" → cap + that; "2h", "90min", "5400" → cap = that
|
|
47
|
+
// (bare number = seconds; s / min / m = minutes / h accepted, fractions allowed: "1.5h")
|
|
48
|
+
//
|
|
49
|
+
// "cancel" (any case), an empty answer, an unparseable answer, or an absolute value at or below the
|
|
50
|
+
// current cap (it would re-park instantly) all CANCEL the run — never guess at a misread answer,
|
|
51
|
+
// resuming spends real money. An approved increment too small to clear the breach re-asks (the
|
|
52
|
+
// `clear()` loop), so a responder can never resume a run into an instant re-park.
|
|
53
|
+
//
|
|
54
|
+
// NOTE (inherited from the usd-only gate): concurrent leaves that breach together each register
|
|
55
|
+
// their own `budget` gate row; answering each with an increment applies each increment. Approvals
|
|
56
|
+
// are explicit per-row human actions, so this compounds by design.
|
|
57
|
+
import { createLogger } from "./support/index.js";
|
|
58
|
+
const log = createLogger("BudgetGate");
|
|
2
59
|
/** The stable key a responder answers a budget gate by (`boardwalk respond <runId> budget …`). */
|
|
3
60
|
export const BUDGET_GATE_KEY = "budget";
|
|
4
|
-
/** Preset approvals, plus the
|
|
5
|
-
|
|
6
|
-
export const
|
|
7
|
-
|
|
61
|
+
/** Preset approvals per dimension, plus the open-ended `other` answer. Kept small: the point is a
|
|
62
|
+
* fast decision. These strings ARE the wire options — backend/web render them verbatim. */
|
|
63
|
+
export const BUDGET_PRESETS = {
|
|
64
|
+
usd: ["+$10", "+$25", "+$100"],
|
|
65
|
+
tokens: ["+100k", "+1M", "+10M"],
|
|
66
|
+
compute: ["+15min", "+1h", "+4h"],
|
|
67
|
+
};
|
|
8
68
|
export const BUDGET_CHOICE_CANCEL = "cancel";
|
|
9
69
|
/** Raised when a responder answers `cancel`: the run stops, deliberately, at the user's word. */
|
|
10
70
|
export class BudgetGateCancelled extends Error {
|
|
@@ -17,51 +77,95 @@ export class BudgetGateCancelled extends Error {
|
|
|
17
77
|
function usd(n) {
|
|
18
78
|
return `$${n.toFixed(2)}`;
|
|
19
79
|
}
|
|
80
|
+
/** Token counts in a prompt: grouped digits ("1,200,000") — exact beats approximate at a gate. */
|
|
81
|
+
function tokens(n) {
|
|
82
|
+
return Math.round(n).toLocaleString("en-US");
|
|
83
|
+
}
|
|
84
|
+
/** Compute time in a prompt: "1h 15m", "45m", "30s" — seconds only below a minute. */
|
|
85
|
+
function computeTime(totalSeconds) {
|
|
86
|
+
const s = Math.max(0, Math.round(totalSeconds));
|
|
87
|
+
const h = Math.floor(s / 3600);
|
|
88
|
+
const m = Math.floor((s % 3600) / 60);
|
|
89
|
+
const sec = s % 60;
|
|
90
|
+
const parts = [];
|
|
91
|
+
if (h > 0)
|
|
92
|
+
parts.push(`${String(h)}h`);
|
|
93
|
+
if (m > 0)
|
|
94
|
+
parts.push(`${String(m)}m`);
|
|
95
|
+
if (parts.length === 0 || (h === 0 && sec > 0))
|
|
96
|
+
parts.push(`${String(sec)}s`);
|
|
97
|
+
return parts.join(" ");
|
|
98
|
+
}
|
|
20
99
|
/**
|
|
21
100
|
* The gate's question. It states the two numbers that matter (spent vs. cap) so the responder can
|
|
22
|
-
* decide without opening the dashboard, and names the run's own
|
|
23
|
-
* was hit (not the org's credit balance — a different failure with a different fix).
|
|
101
|
+
* decide without opening the dashboard, and names the run's own manifest cap field so it's obvious
|
|
102
|
+
* WHICH cap was hit (not the org's credit balance — a different failure with a different fix).
|
|
24
103
|
*/
|
|
25
|
-
export function budgetGatePrompt(
|
|
26
|
-
|
|
27
|
-
`
|
|
104
|
+
export function budgetGatePrompt(dimension, spent, cap) {
|
|
105
|
+
if (dimension === "usd") {
|
|
106
|
+
return (`Budget cap reached: this run has spent ${usd(spent)} of its ${usd(cap)} ` +
|
|
107
|
+
`max_usd cap. Approve more spend to continue, or cancel the run.`);
|
|
108
|
+
}
|
|
109
|
+
if (dimension === "tokens") {
|
|
110
|
+
return (`Budget cap reached: this run has used ${tokens(spent)} tokens of its ${tokens(cap)}-token ` +
|
|
111
|
+
`max_tokens cap. Approve more tokens to continue, or cancel the run.`);
|
|
112
|
+
}
|
|
113
|
+
return (`Budget cap reached: this run has used ${computeTime(spent)} of its ${computeTime(cap)} ` +
|
|
114
|
+
`max_compute_seconds cap. Approve more compute time to continue, or cancel the run.`);
|
|
28
115
|
}
|
|
29
116
|
/** The gate's response form — a choice, so the common answers are one click / one word. */
|
|
30
|
-
export function budgetGateInputSpec() {
|
|
117
|
+
export function budgetGateInputSpec(dimension) {
|
|
31
118
|
return {
|
|
32
119
|
kind: "choice",
|
|
33
|
-
options: [
|
|
34
|
-
BUDGET_CHOICE_ADD_10,
|
|
35
|
-
BUDGET_CHOICE_ADD_25,
|
|
36
|
-
BUDGET_CHOICE_ADD_100,
|
|
37
|
-
BUDGET_CHOICE_CANCEL,
|
|
38
|
-
],
|
|
120
|
+
options: [...BUDGET_PRESETS[dimension], BUDGET_CHOICE_CANCEL],
|
|
39
121
|
// `other` lets a responder type an absolute cap ("50") instead of taking a preset increment.
|
|
40
122
|
other: true,
|
|
41
123
|
};
|
|
42
124
|
}
|
|
125
|
+
/** Parse one numeric budget answer (`"25"`, `"2.5M"`, `"90min"`) into the dimension's native unit
|
|
126
|
+
* (dollars / tokens / seconds), or null when unreadable. The `+` increment prefix is handled by
|
|
127
|
+
* the caller — this reads only the magnitude. */
|
|
128
|
+
function parseAmount(dimension, raw) {
|
|
129
|
+
if (dimension === "usd") {
|
|
130
|
+
const m = /^\$?\s*(\d+(?:\.\d+)?)$/.exec(raw);
|
|
131
|
+
return m?.[1] === undefined ? null : Number(m[1]);
|
|
132
|
+
}
|
|
133
|
+
if (dimension === "tokens") {
|
|
134
|
+
const m = /^(\d+(?:\.\d+)?)\s*([km])?$/i.exec(raw);
|
|
135
|
+
if (m?.[1] === undefined)
|
|
136
|
+
return null;
|
|
137
|
+
const mult = m[2]?.toLowerCase() === "k" ? 1_000 : m[2]?.toLowerCase() === "m" ? 1_000_000 : 1;
|
|
138
|
+
return Math.round(Number(m[1]) * mult);
|
|
139
|
+
}
|
|
140
|
+
const m = /^(\d+(?:\.\d+)?)\s*(s|sec|secs|m|min|mins|h|hr|hrs)?$/i.exec(raw);
|
|
141
|
+
if (m?.[1] === undefined)
|
|
142
|
+
return null;
|
|
143
|
+
const unit = m[2]?.toLowerCase() ?? "s";
|
|
144
|
+
const mult = unit.startsWith("h") ? 3600 : unit.startsWith("m") ? 60 : 1;
|
|
145
|
+
return Math.round(Number(m[1]) * mult);
|
|
146
|
+
}
|
|
43
147
|
/**
|
|
44
|
-
* Interpret an answer as the NEW absolute `
|
|
148
|
+
* Interpret an answer as the NEW absolute cap for `dimension`, or null to cancel the run.
|
|
45
149
|
*
|
|
46
|
-
*
|
|
47
|
-
* "set a cap" escape hatch, via the choice's `other` entry). Anything unrecognized is
|
|
48
|
-
* cancel rather than guessed at — silently resuming a run on a misread answer spends
|
|
150
|
+
* `+<amount>` is an INCREMENT on the current cap (the presets); a bare amount is an ABSOLUTE new
|
|
151
|
+
* cap (the "set a cap" escape hatch, via the choice's `other` entry). Anything unrecognized is
|
|
152
|
+
* treated as cancel rather than guessed at — silently resuming a run on a misread answer spends
|
|
153
|
+
* real money. See the module header for the full per-dimension grammar.
|
|
49
154
|
*/
|
|
50
|
-
export function resolveBudgetAnswer(answer,
|
|
155
|
+
export function resolveBudgetAnswer(dimension, answer, currentCap) {
|
|
51
156
|
const raw = answer.trim();
|
|
52
157
|
if (raw === "" || raw.toLowerCase() === BUDGET_CHOICE_CANCEL)
|
|
53
158
|
return null;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return
|
|
57
|
-
const absolute = /^\$?\s*(\d+(?:\.\d+)?)$/.exec(raw);
|
|
58
|
-
if (absolute?.[1] !== undefined) {
|
|
59
|
-
const value = Number(absolute[1]);
|
|
60
|
-
// An "absolute" cap at or below what's already spent would re-park instantly — read it as a
|
|
61
|
-
// refusal to fund more, which is a cancel.
|
|
62
|
-
return value > currentCapUsd ? value : null;
|
|
159
|
+
if (raw.startsWith("+")) {
|
|
160
|
+
const amount = parseAmount(dimension, raw.slice(1).trim());
|
|
161
|
+
return amount === null ? null : currentCap + amount;
|
|
63
162
|
}
|
|
64
|
-
|
|
163
|
+
const absolute = parseAmount(dimension, raw);
|
|
164
|
+
if (absolute === null)
|
|
165
|
+
return null;
|
|
166
|
+
// An "absolute" cap at or below the current one would re-park instantly — read it as a refusal
|
|
167
|
+
// to fund more, which is a cancel.
|
|
168
|
+
return absolute > currentCap ? absolute : null;
|
|
65
169
|
}
|
|
66
170
|
/** Pull the answer's text out of the SDK's result union (text | choice | multiselect). */
|
|
67
171
|
function answerText(result) {
|
|
@@ -75,40 +179,117 @@ function answerText(result) {
|
|
|
75
179
|
return "";
|
|
76
180
|
}
|
|
77
181
|
/**
|
|
78
|
-
* Budget clearance, awaited
|
|
79
|
-
* overwhelming majority of calls). On a
|
|
80
|
-
* live meter and let the call proceed.
|
|
182
|
+
* Budget clearance, awaited at every park point (see the module header). Fast path: no breach ⇒
|
|
183
|
+
* resolve immediately (the overwhelming majority of calls). On a breach of ANY dimension: park at
|
|
184
|
+
* a gate, then apply the answer to the live meter and let the call proceed.
|
|
81
185
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
186
|
+
* The park's own wall-clock is EXCLUDED from the compute cap (`meter.excludeIdle`): parked time is
|
|
187
|
+
* not compute (SUSPEND_POLICY Decision 3.4), and on the snapshot fleet the guest clock resyncs
|
|
188
|
+
* across the frozen window — without the exclusion a compute park would re-breach the instant it
|
|
189
|
+
* woke, forever. `usage.get()` polled DURING a park may transiently show the parked wall-clock as
|
|
190
|
+
* compute spend; the exclusion lands when the park resolves.
|
|
85
191
|
*/
|
|
86
192
|
export class BudgetGate {
|
|
87
193
|
meter;
|
|
88
194
|
host;
|
|
89
|
-
|
|
195
|
+
now;
|
|
196
|
+
constructor(meter, host, now = Date.now) {
|
|
90
197
|
this.meter = meter;
|
|
91
198
|
this.host = host;
|
|
199
|
+
this.now = now;
|
|
92
200
|
}
|
|
93
201
|
async clear() {
|
|
94
202
|
// Loop, don't `if`: a responder can approve an increment too small to clear the breach (spend
|
|
95
|
-
// already exceeds it)
|
|
96
|
-
|
|
97
|
-
|
|
203
|
+
// already exceeds it) — ask again rather than resume into an instant re-park — and clearing
|
|
204
|
+
// one dimension can reveal a second breached one, which parks next.
|
|
205
|
+
for (;;) {
|
|
206
|
+
const dimension = this.meter.capBreachReason();
|
|
207
|
+
if (dimension === null)
|
|
208
|
+
return;
|
|
209
|
+
const cap = this.meter.cap(dimension);
|
|
98
210
|
if (cap === null)
|
|
99
211
|
return; // no cap ⇒ nothing to breach (defensive; capBreachReason implies one)
|
|
100
|
-
const spent = this.meter.
|
|
212
|
+
const spent = this.meter.spent(dimension);
|
|
101
213
|
// The park needs no bespoke event: registering the gate moves the run to `awaiting_input` and
|
|
102
214
|
// the prompt below rides the gate row, so the live tail + `boardwalk inputs` explain the pause
|
|
103
215
|
// through exactly the same path a humanInput() gate uses.
|
|
216
|
+
const parkedAt = this.now();
|
|
104
217
|
const answer = await this.host.budgetClearance({
|
|
105
|
-
prompt: budgetGatePrompt(spent, cap),
|
|
106
|
-
inputSpec: budgetGateInputSpec(),
|
|
218
|
+
prompt: budgetGatePrompt(dimension, spent, cap),
|
|
219
|
+
inputSpec: budgetGateInputSpec(dimension),
|
|
107
220
|
});
|
|
108
|
-
|
|
221
|
+
// Parked time is not compute — exclude it BEFORE re-checking the breach, or a compute park
|
|
222
|
+
// could never clear (the frozen/held wait would count as fresh compute spend).
|
|
223
|
+
this.meter.excludeIdle(this.now() - parkedAt);
|
|
224
|
+
const newCap = resolveBudgetAnswer(dimension, answerText(answer), cap);
|
|
109
225
|
if (newCap === null)
|
|
110
226
|
throw new BudgetGateCancelled();
|
|
111
|
-
this.meter.
|
|
227
|
+
this.meter.raiseCap(dimension, newCap);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** How often the watcher samples the meter. Coarse is fine: compute presets are minutes. */
|
|
232
|
+
export const DEFAULT_COMPUTE_WATCH_INTERVAL_MS = 15_000;
|
|
233
|
+
/**
|
|
234
|
+
* Detects a `max_compute_seconds` breach BETWEEN park points. `usd`/`tokens` only move at the
|
|
235
|
+
* `streamModel` seam, but compute burns continuously — a breach can land mid-shell, mid-turn, or
|
|
236
|
+
* mid-program-compute, far from any model call.
|
|
237
|
+
*
|
|
238
|
+
* WHY THIS ONLY DETECTS AND DOES NOT PARK — true park-anywhere needs fleet-host work. The freeze
|
|
239
|
+
* machinery is quiescence-gated: `budgetClearance` may only be entered from a work-tracked seam
|
|
240
|
+
* (its `freezeWait` steps the CALLER out of the work count; from a timer context that corrupts the
|
|
241
|
+
* count and could freeze around live in-flight work, which SUSPEND_POLICY Decision 1 forbids). A
|
|
242
|
+
* timer-initiated park would need (a) a host-agent-initiated out-of-band VM pause that does not
|
|
243
|
+
* require runner quiescence, plus a wake path that re-arms the runner-side gate and applies the
|
|
244
|
+
* answer to the meter, and (b) on hold-only substrates a program-process stop (SIGSTOP) in the
|
|
245
|
+
* program runner. Until that lands, the honest contract is: the watcher logs the breach the moment
|
|
246
|
+
* it happens, and the run PARKS AT ITS NEXT SEAM (`streamModel`, `sleep`, `shell`,
|
|
247
|
+
* `workflows.call`) via the same {@link BudgetGate.clear} every park point awaits.
|
|
248
|
+
*/
|
|
249
|
+
export class ComputeBreachWatcher {
|
|
250
|
+
meter;
|
|
251
|
+
opts;
|
|
252
|
+
timer = null;
|
|
253
|
+
announced = false;
|
|
254
|
+
constructor(meter, opts = { runId: "" }) {
|
|
255
|
+
this.meter = meter;
|
|
256
|
+
this.opts = opts;
|
|
257
|
+
}
|
|
258
|
+
/** True while a compute breach is standing (sampled; resets when an approval clears it). */
|
|
259
|
+
get breachDetected() {
|
|
260
|
+
return this.announced;
|
|
261
|
+
}
|
|
262
|
+
start() {
|
|
263
|
+
if (this.timer !== null)
|
|
264
|
+
return;
|
|
265
|
+
this.timer = setInterval(() => {
|
|
266
|
+
this.sample();
|
|
267
|
+
}, this.opts.intervalMs ?? DEFAULT_COMPUTE_WATCH_INTERVAL_MS);
|
|
268
|
+
// Never keep the worker process alive solely for this watcher.
|
|
269
|
+
this.timer.unref();
|
|
270
|
+
}
|
|
271
|
+
stop() {
|
|
272
|
+
if (this.timer !== null) {
|
|
273
|
+
clearInterval(this.timer);
|
|
274
|
+
this.timer = null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/** One sample tick (exposed for tests — real ticks come from the interval). */
|
|
278
|
+
sample() {
|
|
279
|
+
const cap = this.meter.cap("compute");
|
|
280
|
+
const breached = cap !== null && this.meter.spent("compute") > cap;
|
|
281
|
+
if (breached && !this.announced) {
|
|
282
|
+
this.announced = true;
|
|
283
|
+
log.warn("budget_compute_breach_between_seams", {
|
|
284
|
+
runId: this.opts.runId,
|
|
285
|
+
spentSeconds: this.meter.spent("compute"),
|
|
286
|
+
capSeconds: cap,
|
|
287
|
+
note: "run parks at its next capability seam (streamModel / sleep / shell / workflows.call)",
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
else if (!breached && this.announced) {
|
|
291
|
+
// The cap was raised at a gate — re-arm so a later breach of the NEW cap logs again.
|
|
292
|
+
this.announced = false;
|
|
112
293
|
}
|
|
113
294
|
}
|
|
114
295
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { HostCapabilities } from "./host_server.js";
|
|
2
|
+
import type { WorkerWorkflowHost } from "./workflow_host.js";
|
|
3
|
+
/** Build the protocol server's capability seam over the run's {@link WorkerWorkflowHost}. */
|
|
4
|
+
export declare function buildHostCapabilities(host: WorkerWorkflowHost): HostCapabilities;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/** Build the protocol server's capability seam over the run's {@link WorkerWorkflowHost}. */
|
|
3
|
+
export function buildHostCapabilities(host) {
|
|
4
|
+
return {
|
|
5
|
+
agent: (prompt, opts) => host.agent(prompt, opts),
|
|
6
|
+
callWorkflow: async (slug, input, opts) => {
|
|
7
|
+
const { output, outputSchema } = await host.callWorkflow(slug, input, opts);
|
|
8
|
+
return { output, outputSchema };
|
|
9
|
+
},
|
|
10
|
+
runWorkflow: (slug, input, opts) => host.runWorkflow(slug, input, opts),
|
|
11
|
+
scheduleWorkflow: (slug, input, opts) => host.scheduleWorkflow(slug, input, opts),
|
|
12
|
+
sleep: (arg) => host.sleep(arg),
|
|
13
|
+
humanInput: (opts) => host.humanInput(opts),
|
|
14
|
+
getSecret: (name) => host.getSecret(name),
|
|
15
|
+
writeArtifact: (name, contentType, body, metadata) => host.writeArtifact(name, contentType, body, metadata),
|
|
16
|
+
openBrowser: (opts) => host.openBrowserSession(opts),
|
|
17
|
+
shell: (cmd, opts) => host.shell(cmd, opts),
|
|
18
|
+
phase: (name, opts) => {
|
|
19
|
+
host.setPhase(name, opts);
|
|
20
|
+
},
|
|
21
|
+
idToken: (audience) => host.idToken(audience),
|
|
22
|
+
apiToken: () => host.apiToken(),
|
|
23
|
+
usage: () => host.usage(),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { type ContextData, type JsonValue, type ShellResult, type UsageSnapshot, type AgentOptions, type ArtifactBody, type ArtifactRef, type BrowserSession, type BrowserSessionOptions, type CallOptions, type HumanInputOptions, type HumanInputResult, type PhaseOptions, type ScheduleOptions, type SleepArg } from "@boardwalk-labs/workflow/runtime";
|
|
2
|
+
import type { ShellOptions } from "@boardwalk-labs/workflow";
|
|
3
|
+
/** The loader-side curation hint for a `report_return` output-schema mismatch. Shared by the
|
|
4
|
+
* in-process TS loader and the Python subprocess path so the author sees ONE message. */
|
|
5
|
+
export declare const OUTPUT_MISMATCH_HINT = "Return a value matching run()'s declared return type, or update the type and redeploy.";
|
|
6
|
+
/** What `workflows.call` resolves at the capability seam: the child's output plus the CALLEE's
|
|
7
|
+
* declared output schema (`null` for an untyped callee — the client passes the JSON through). */
|
|
8
|
+
export interface CapabilityCallResult {
|
|
9
|
+
output: unknown;
|
|
10
|
+
outputSchema: Record<string, unknown> | null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The typed seam the protocol server dispatches onto — the runner's existing machinery, one
|
|
14
|
+
* member per capability. Mirrors the SDK client's `HostInterface` so the two ends of the wire
|
|
15
|
+
* stay symmetric. `agent` receives NATIVE `AgentOptions`: the server has already turned wire
|
|
16
|
+
* tool declarations into executable `ToolDef`s (round-tripping `tool_invoke`) and resolved a
|
|
17
|
+
* wire `sessionId` to its live {@link BrowserSession}.
|
|
18
|
+
*/
|
|
19
|
+
export interface HostCapabilities {
|
|
20
|
+
agent(prompt: string, opts: AgentOptions | undefined): Promise<unknown>;
|
|
21
|
+
callWorkflow(slug: string, input: unknown, opts: CallOptions | undefined): Promise<CapabilityCallResult>;
|
|
22
|
+
runWorkflow(slug: string, input: unknown, opts: CallOptions | undefined): Promise<string>;
|
|
23
|
+
scheduleWorkflow(slug: string, input: unknown, opts: ScheduleOptions): Promise<string>;
|
|
24
|
+
sleep(arg: SleepArg): Promise<void>;
|
|
25
|
+
humanInput(opts: HumanInputOptions): Promise<HumanInputResult>;
|
|
26
|
+
getSecret(name: string): Promise<string>;
|
|
27
|
+
writeArtifact(name: string, contentType: string, body: ArtifactBody, metadata: Record<string, unknown> | undefined): Promise<ArtifactRef>;
|
|
28
|
+
openBrowser(opts: BrowserSessionOptions | undefined): Promise<BrowserSession>;
|
|
29
|
+
shell(cmd: string, opts: ShellOptions | undefined): Promise<ShellResult>;
|
|
30
|
+
phase(name: string, opts: PhaseOptions | undefined): void;
|
|
31
|
+
idToken(audience: string): Promise<string>;
|
|
32
|
+
apiToken(): Promise<string>;
|
|
33
|
+
usage(): Promise<UsageSnapshot>;
|
|
34
|
+
}
|
|
35
|
+
/** The `bootstrap` payload: the RAW JSON input + the stored input schema (`null` when untyped —
|
|
36
|
+
* the CLIENT applies the schema-guided revival pass) + the context DATA (never `signal`). */
|
|
37
|
+
export interface BootstrapData {
|
|
38
|
+
input: JsonValue;
|
|
39
|
+
inputSchema: Record<string, unknown> | null;
|
|
40
|
+
context: ContextData;
|
|
41
|
+
}
|
|
42
|
+
export interface WorkflowHostServerDeps {
|
|
43
|
+
capabilities: HostCapabilities;
|
|
44
|
+
bootstrap: BootstrapData;
|
|
45
|
+
/** The workflow's declared output schema; `null` ⇒ the return persists unvalidated. */
|
|
46
|
+
outputSchema: Record<string, unknown> | null;
|
|
47
|
+
/** The run's cooperative-cancellation signal: on abort, every connected client is sent the
|
|
48
|
+
* `cancel` notification (the SDK aborts `context.signal`). */
|
|
49
|
+
signal?: AbortSignal | undefined;
|
|
50
|
+
/** Directory the Unix socket file is created in. Default `os.tmpdir()` — deliberately short:
|
|
51
|
+
* `sun_path` caps a socket path at ~104 bytes on darwin. Ignored on win32 (named pipe). */
|
|
52
|
+
sockDir?: string | undefined;
|
|
53
|
+
/** Host-side ceiling on ONE `tool_invoke` round-trip. Default: none — parity with the engine,
|
|
54
|
+
* which awaits an inline tool's `execute()` without a timeout. When set, expiry throws an
|
|
55
|
+
* ordinary Error from `execute()` (a tool-error result to the model, never run-fatal) and the
|
|
56
|
+
* late response is discarded by id. */
|
|
57
|
+
toolInvokeTimeoutMs?: number | undefined;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The protocol server for ONE run. `listen()` binds the socket (the runner then exports the
|
|
61
|
+
* path as `BOARDWALK_HOST_SOCK`); `close()` tears everything down. The validated return the
|
|
62
|
+
* program reported is read via {@link reportedReturn} after the loader completes.
|
|
63
|
+
*/
|
|
64
|
+
export declare class WorkflowHostServer {
|
|
65
|
+
private readonly deps;
|
|
66
|
+
private readonly server;
|
|
67
|
+
private readonly connections;
|
|
68
|
+
/** sessionId → live handle, backing `computer.browser.*` and `agent({ session })`. */
|
|
69
|
+
private readonly browserSessions;
|
|
70
|
+
private readonly validateOutput;
|
|
71
|
+
private nextInvokeId;
|
|
72
|
+
private sockPath;
|
|
73
|
+
private returned;
|
|
74
|
+
private reportFailure;
|
|
75
|
+
private cancelled;
|
|
76
|
+
private readonly onAbort;
|
|
77
|
+
constructor(deps: WorkflowHostServerDeps);
|
|
78
|
+
/** Bind the socket and resolve its path (a Unix socket path; a named pipe on win32). */
|
|
79
|
+
listen(): Promise<string>;
|
|
80
|
+
/** The bound socket path; null before `listen()`. */
|
|
81
|
+
get socketPath(): string | null;
|
|
82
|
+
/** The validated value the program's loader reported via `report_return`, or null when no
|
|
83
|
+
* return was reported (the program never finished, or returned void ⇒ the client sent null). */
|
|
84
|
+
reportedReturn(): JsonValue | null;
|
|
85
|
+
/** Whether `report_return` was received at all (distinguishes "returned null" from "never
|
|
86
|
+
* reported" for callers that care). */
|
|
87
|
+
hasReturn(): boolean;
|
|
88
|
+
/** The error the most recent `report_return` attempt failed with (the output-schema mismatch),
|
|
89
|
+
* or null when none failed / a later report succeeded. An IN-PROCESS loader re-throws this
|
|
90
|
+
* error itself; a SUBPROCESS loader (Python) can only propagate it as a traceback + non-zero
|
|
91
|
+
* exit, so the runner reads it back here to curate the run's failure with the original
|
|
92
|
+
* code/message instead of the traceback's last line. */
|
|
93
|
+
reportReturnFailure(): Error | null;
|
|
94
|
+
/** Push the `cancel` notification to every connected client (idempotent). */
|
|
95
|
+
notifyCancel(reason?: string): void;
|
|
96
|
+
/** Tear the server down: reject in-flight tool invokes, destroy connections, unlink the socket. */
|
|
97
|
+
close(): Promise<void>;
|
|
98
|
+
private onFrame;
|
|
99
|
+
private settleInvoke;
|
|
100
|
+
private handleNotification;
|
|
101
|
+
private handleRequest;
|
|
102
|
+
private dispatch;
|
|
103
|
+
private get caps();
|
|
104
|
+
/**
|
|
105
|
+
* One handler per client→host method, each typed by ITS OWN `HostMethodParams`/`HostMethodResult`
|
|
106
|
+
* pair via the mapped type — params arrive already narrowed (no per-case casts) and a handler
|
|
107
|
+
* returning another method's result shape is a COMPILE error (a switch over the union couldn't
|
|
108
|
+
* catch that). Exhaustive by construction: a new wire method fails the build until a handler
|
|
109
|
+
* exists. `req` carries the originating connection + request id for `agent`, whose inline tools
|
|
110
|
+
* round-trip `tool_invoke` on that same connection, correlated by this request's id.
|
|
111
|
+
*/
|
|
112
|
+
private readonly handlers;
|
|
113
|
+
/** A live browser session by id, or a clear VALIDATION error for a closed/foreign one. */
|
|
114
|
+
private session;
|
|
115
|
+
/** Validate the program's return against the declared output schema (P3.4): a mismatch fails
|
|
116
|
+
* the run — the loader's `reportReturn` rejects and the failure is curated. `null` (a void
|
|
117
|
+
* return) skips validation per the contract. */
|
|
118
|
+
private assertReturnMatchesSchema;
|
|
119
|
+
/** Wire tool declarations → engine `ToolDef`s whose `execute()` round-trips `tool_invoke` to
|
|
120
|
+
* the program, correlated by `call_id` = the originating agent request's own id (stringified).
|
|
121
|
+
* Also resolves a wire `sessionId` back to its live browser session. */
|
|
122
|
+
private toAgentOptions;
|
|
123
|
+
/** One host → client `tool_invoke` round-trip. Concurrent invocations multiplex by this
|
|
124
|
+
* request's own JSON-RPC id; the optional host-side timeout abandons the call (its late
|
|
125
|
+
* response is discarded by id) and throws an ordinary Error — a tool-error result to the
|
|
126
|
+
* model, never run-fatal. */
|
|
127
|
+
private invokeTool;
|
|
128
|
+
}
|
|
129
|
+
/** Map a thrown value to the wire's `{code, message, data?}` (string code, engine taxonomy).
|
|
130
|
+
* An engine-style `hint` (the one-line "what to do") rides `data.hint` so it SURVIVES the wire —
|
|
131
|
+
* the loader's failure curation reads it back into the run's `output.error.hint` (the
|
|
132
|
+
* hint-reaches-hosted-authors contract). */
|
|
133
|
+
export declare function protocolErrorOf(err: unknown): {
|
|
134
|
+
code: string;
|
|
135
|
+
message: string;
|
|
136
|
+
data?: unknown;
|
|
137
|
+
};
|