@boardwalk-labs/runner 0.2.17 → 0.3.0
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/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/index.js +45 -12
- 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/shell_exec.d.ts +17 -0
- package/dist/runtime/shell_exec.js +144 -0
- package/dist/runtime/support/index.d.ts +6 -0
- package/dist/runtime/support/index.js +14 -0
- 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
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// invokePythonProgram — the runner side of the Python program path (the workflow-format
|
|
3
|
+
// redesign, P5.5). A `.py` entry runs as a SUBPROCESS speaking the same host protocol the
|
|
4
|
+
// in-process TS loader speaks:
|
|
5
|
+
//
|
|
6
|
+
// <python3> -m boardwalk._loader <absolute entry path>
|
|
7
|
+
//
|
|
8
|
+
// with cwd = the run's workspace and env = the worker's env plus `BOARDWALK_HOST_SOCK`
|
|
9
|
+
// (already set process-wide by the runner, re-stamped here explicitly), `HOME` = the workspace
|
|
10
|
+
// (the I1 convention the TS path lives under: the workspace IS cwd + HOME for author code),
|
|
11
|
+
// `PYTHONUNBUFFERED=1` so the child's prints stream line-by-line instead of arriving in one
|
|
12
|
+
// buffered burst at exit, and the platform-owned `PYTHONPATH` binding the ratified artifact
|
|
13
|
+
// layout (sources under `.bw-src/`, frozen deps under `.bw-machine/site-packages/` — see
|
|
14
|
+
// {@link pythonModulePath} for the exact value + ordering rationale). The loader module + the
|
|
15
|
+
// `boardwalk` package come from the runner IMAGE (P5.3: CPython in the base image, the SDK
|
|
16
|
+
// importable) — the runner installs nothing.
|
|
17
|
+
//
|
|
18
|
+
// LIFECYCLE + FAILURE CURATION (the module's real job — the loader deliberately reports no
|
|
19
|
+
// failure over the wire; see sdk-python's `_loader.py` docstring):
|
|
20
|
+
// - The run COMPLETES when `report_return` landed — the host server already validated the
|
|
21
|
+
// value against `output_schema` and holds it; the child's subsequent disconnect + exit is a
|
|
22
|
+
// clean shutdown.
|
|
23
|
+
// - A non-zero exit WITHOUT a reported return is a program failure. Preferred curation source:
|
|
24
|
+
// the host server's recorded `report_return` failure (the schema-mismatch AppError with its
|
|
25
|
+
// real VALIDATION_FAILED code — richer than the traceback the child printed for it). Else
|
|
26
|
+
// the captured stderr TAIL: the Python traceback's final line (e.g. `ValueError: boom`) is
|
|
27
|
+
// the message, code `PROGRAM_ERROR`. The caller (`runWorkflowProgram`'s catch) secret-redacts
|
|
28
|
+
// message/code/hint through the run's redactor, same as every other failure.
|
|
29
|
+
// - Exit 0 without a reported return is a loader-contract violation → a clear INTERNAL error.
|
|
30
|
+
// - Run abort (cancel / credit) kills the child: SIGTERM, then SIGKILL after a grace — the
|
|
31
|
+
// same cooperative-signal plumbing the shell executor uses, plus the escalation a whole
|
|
32
|
+
// program process warrants.
|
|
33
|
+
// - A missing interpreter fails CLOSED with an UNSUPPORTED-class message naming the image
|
|
34
|
+
// expectation — never a cryptic ENOENT.
|
|
35
|
+
//
|
|
36
|
+
// The child's stdout/stderr ride the SAME program log capture the TS path uses: each complete
|
|
37
|
+
// line is re-emitted through the global `console` (`log` for stdout, `error` for stderr — the
|
|
38
|
+
// method-to-stream mapping program_log_capture.ts owns), which the worker patched via
|
|
39
|
+
// `captureConsole` for the program's duration. That one hop buys redaction, the CloudWatch
|
|
40
|
+
// print, the frame cap/truncation, and the `program_output` run-event — with zero new seams.
|
|
41
|
+
import { spawn } from "node:child_process";
|
|
42
|
+
import { stat } from "node:fs/promises";
|
|
43
|
+
import { delimiter, join } from "node:path";
|
|
44
|
+
import { HOST_SOCK_ENV } from "@boardwalk-labs/workflow/runtime";
|
|
45
|
+
import { OUTPUT_MISMATCH_HINT } from "./host_server.js";
|
|
46
|
+
import { AppError, ErrorCode } from "./support/index.js";
|
|
47
|
+
import { throwIfAborted } from "./run_abort.js";
|
|
48
|
+
/** Default interpreter, resolved on PATH — the base image bakes one CPython (P5.3). */
|
|
49
|
+
export const DEFAULT_PYTHON_INTERPRETER = "python3";
|
|
50
|
+
/** Where the ratified Python artifact layout keeps the author's SOURCE tree. A stored Python
|
|
51
|
+
* entry (`main.py`) is relative to THIS dir — Python has no bundle step, so the shipped source
|
|
52
|
+
* is what runs (`<extractDir>/.bw-src/main.py`), never a root module like the TS `index.mjs`. */
|
|
53
|
+
export const PYTHON_SOURCE_DIR = ".bw-src";
|
|
54
|
+
/** Where the ratified layout keeps the uv-materialized frozen dependency tree (build-time
|
|
55
|
+
* `uv lock` → `export --frozen` → `pip install --target`; never installed on the hot path). */
|
|
56
|
+
export const PYTHON_SITE_PACKAGES_DIR = join(".bw-machine", "site-packages");
|
|
57
|
+
/**
|
|
58
|
+
* The platform-owned `PYTHONPATH` for a Python program run:
|
|
59
|
+
*
|
|
60
|
+
* <programDir>/.bw-src <sep> <programDir>/.bw-machine/site-packages
|
|
61
|
+
*
|
|
62
|
+
* ORDER (deliberate): the author's sources BEFORE the frozen deps, so an author module wins a
|
|
63
|
+
* name collision with a dependency. That mirrors CPython's own convention — the interpreter puts
|
|
64
|
+
* a script's directory ahead of site-packages — extended to the whole tree, which needs an
|
|
65
|
+
* explicit entry because the loader is launched `-m` style (sys.path[0] is the workspace cwd,
|
|
66
|
+
* not `.bw-src`) yet sibling imports (`import helper`) must resolve from the source tree. The
|
|
67
|
+
* file an author can SEE in the Code tab beating an invisible dep is the predictable reading.
|
|
68
|
+
*
|
|
69
|
+
* The value REPLACES any PYTHONPATH inherited from the worker's env: for a Python run the
|
|
70
|
+
* module path is platform-owned (the guest image provides python3 with the `boardwalk` loader
|
|
71
|
+
* package importable at site level; nothing from the worker's own environment may steer author
|
|
72
|
+
* import resolution). A no-dep package ships no site-packages dir at all — Python silently
|
|
73
|
+
* skips a nonexistent sys.path entry, so the value is set unconditionally, never stat-gated.
|
|
74
|
+
*/
|
|
75
|
+
export function pythonModulePath(programDir) {
|
|
76
|
+
return [join(programDir, PYTHON_SOURCE_DIR), join(programDir, PYTHON_SITE_PACKAGES_DIR)].join(delimiter);
|
|
77
|
+
}
|
|
78
|
+
/** The loader module the guest image's `boardwalk` package provides (`python -m <module> <entry>`). */
|
|
79
|
+
export const PYTHON_LOADER_MODULE = "boardwalk._loader";
|
|
80
|
+
/** How long after SIGTERM an aborted child gets before SIGKILL. */
|
|
81
|
+
export const DEFAULT_PYTHON_KILL_GRACE_MS = 5_000;
|
|
82
|
+
/** How many trailing stderr lines are kept for failure curation (the traceback tail). */
|
|
83
|
+
export const STDERR_TAIL_LINES = 20;
|
|
84
|
+
/** The language dispatch decision (P5.5): `.py` takes the subprocess path; everything else
|
|
85
|
+
* (`.ts`/`.js`/`.mjs`) keeps the in-process TS loader. Case-insensitive on the extension. */
|
|
86
|
+
export function isPythonEntry(entry) {
|
|
87
|
+
return entry.toLowerCase().endsWith(".py");
|
|
88
|
+
}
|
|
89
|
+
/** Split a piped stream into complete lines (LF or CRLF). `flush()` emits a trailing partial
|
|
90
|
+
* line (a crash mid-line must not swallow the last words of the traceback). */
|
|
91
|
+
export function lineSplitter(onLine) {
|
|
92
|
+
let buffer = "";
|
|
93
|
+
const emit = (line) => {
|
|
94
|
+
onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
95
|
+
};
|
|
96
|
+
return {
|
|
97
|
+
push(chunk) {
|
|
98
|
+
buffer += chunk;
|
|
99
|
+
let newline = buffer.indexOf("\n");
|
|
100
|
+
while (newline !== -1) {
|
|
101
|
+
emit(buffer.slice(0, newline));
|
|
102
|
+
buffer = buffer.slice(newline + 1);
|
|
103
|
+
newline = buffer.indexOf("\n");
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
flush() {
|
|
107
|
+
if (buffer !== "")
|
|
108
|
+
emit(buffer);
|
|
109
|
+
buffer = "";
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Curate a child that ended WITHOUT completing the loader contract (no `report_return`) into a
|
|
115
|
+
* throwable the runner's failure path understands (`{code, message, hint}`, duck-typed). The
|
|
116
|
+
* caller redacts — nothing here needs to.
|
|
117
|
+
*/
|
|
118
|
+
export function curatePythonFailure(facts) {
|
|
119
|
+
if (facts.exitCode === 0) {
|
|
120
|
+
// Clean exit, no report: the loader contract was violated (an `os._exit(0)`/`sys.exit(0)`
|
|
121
|
+
// before run() returned, or a program bypassing the loader). INTERNAL-class, but the hint
|
|
122
|
+
// still tells an author what they most likely did.
|
|
123
|
+
return Object.assign(new Error("The Python program exited without reporting a result — the loader contract was not completed."), {
|
|
124
|
+
code: "INTERNAL_ERROR",
|
|
125
|
+
hint: "Return a value from run() (or None) instead of exiting the process early.",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
// Non-zero / killed: the loader let the exception propagate, so the traceback's FINAL line
|
|
129
|
+
// (`ValueError: boom`) names the error — that is the message. The full traceback already
|
|
130
|
+
// streamed to the run log via the stderr capture.
|
|
131
|
+
const lastLine = [...facts.stderrTail].reverse().find((line) => line.trim() !== "");
|
|
132
|
+
if (lastLine !== undefined) {
|
|
133
|
+
return Object.assign(new Error(lastLine), {
|
|
134
|
+
code: "PROGRAM_ERROR",
|
|
135
|
+
hint: "The full Python traceback is in the run log.",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const ended = facts.signal !== null
|
|
139
|
+
? `was killed by ${facts.signal}`
|
|
140
|
+
: `exited with code ${String(facts.exitCode ?? "unknown")}`;
|
|
141
|
+
return Object.assign(new Error(`The Python program ${ended} without any error output.`), {
|
|
142
|
+
code: "PROGRAM_ERROR",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/** Fail CLOSED on a spawn failure: a missing interpreter gets an UNSUPPORTED-class error naming
|
|
146
|
+
* the image expectation instead of a bare ENOENT; anything else passes through untouched. */
|
|
147
|
+
export function curateSpawnFailure(interpreter, err) {
|
|
148
|
+
if (err.code !== "ENOENT")
|
|
149
|
+
return err;
|
|
150
|
+
return Object.assign(new Error(`This runner image has no "${interpreter}" interpreter, so a Python workflow cannot run on it.`), {
|
|
151
|
+
code: "UNSUPPORTED_RUNTIME",
|
|
152
|
+
hint: "Run Python workflows on an image that ships the Python runtime — the hosted " +
|
|
153
|
+
"boardwalk/linux image does; a custom runs_on image must put python3 on PATH with the " +
|
|
154
|
+
"boardwalk package importable.",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Run a Python workflow program to completion: spawn the loader subprocess against the already
|
|
159
|
+
* -listening host server, stream its output into the program log capture, and read the terminal
|
|
160
|
+
* state off the server once the child exits. Resolves `completed` when `report_return` landed;
|
|
161
|
+
* THROWS on failure (the caller curates + redacts, same as the TS path).
|
|
162
|
+
*/
|
|
163
|
+
export async function invokePythonProgram(entryPath, programDir, sockPath, server, deps) {
|
|
164
|
+
throwIfAborted(deps.signal);
|
|
165
|
+
// Fail loud on an unusable workspace BEFORE spawning: spawn reports a missing cwd as the same
|
|
166
|
+
// ENOENT a missing interpreter gets, and that must not masquerade as "no Python on this image".
|
|
167
|
+
// (Parity with the TS path's chdir guard, which fails the run with the same message shape.)
|
|
168
|
+
const ws = await stat(deps.workspaceRoot).catch(() => null);
|
|
169
|
+
if (ws === null || !ws.isDirectory()) {
|
|
170
|
+
throw new AppError(ErrorCode.VALIDATION_FAILED, `The run's workspace "${deps.workspaceRoot}" is not usable as the working directory: not a directory`);
|
|
171
|
+
}
|
|
172
|
+
const interpreter = deps.pythonInterpreter ?? DEFAULT_PYTHON_INTERPRETER;
|
|
173
|
+
const child = spawn(interpreter, ["-m", PYTHON_LOADER_MODULE, entryPath], {
|
|
174
|
+
cwd: deps.workspaceRoot,
|
|
175
|
+
env: {
|
|
176
|
+
...process.env,
|
|
177
|
+
[HOST_SOCK_ENV]: sockPath,
|
|
178
|
+
HOME: deps.workspaceRoot,
|
|
179
|
+
PYTHONUNBUFFERED: "1",
|
|
180
|
+
// Platform-owned module path (REPLACES any inherited PYTHONPATH — see pythonModulePath).
|
|
181
|
+
PYTHONPATH: pythonModulePath(programDir),
|
|
182
|
+
},
|
|
183
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
184
|
+
});
|
|
185
|
+
// Line-buffered piping into the program log capture (see the module header): stdout lines via
|
|
186
|
+
// console.log, stderr lines via console.error — the patched console redacts, prints, caps, and
|
|
187
|
+
// emits the `program_output` frame. Stderr also feeds the curation tail.
|
|
188
|
+
const stderrTail = [];
|
|
189
|
+
const stdoutLines = lineSplitter((line) => {
|
|
190
|
+
console.log(line);
|
|
191
|
+
});
|
|
192
|
+
const stderrLines = lineSplitter((line) => {
|
|
193
|
+
stderrTail.push(line);
|
|
194
|
+
if (stderrTail.length > STDERR_TAIL_LINES)
|
|
195
|
+
stderrTail.shift();
|
|
196
|
+
console.error(line);
|
|
197
|
+
});
|
|
198
|
+
if (child.stdout !== null) {
|
|
199
|
+
child.stdout.setEncoding("utf8");
|
|
200
|
+
child.stdout.on("data", (chunk) => {
|
|
201
|
+
stdoutLines.push(chunk);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (child.stderr !== null) {
|
|
205
|
+
child.stderr.setEncoding("utf8");
|
|
206
|
+
child.stderr.on("data", (chunk) => {
|
|
207
|
+
stderrLines.push(chunk);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
// Run abort (cancel / credit exhaustion) kills the child: SIGTERM first (Python raises it as a
|
|
211
|
+
// clean KeyboardInterrupt-style teardown), SIGKILL after a grace for a child that ignores it.
|
|
212
|
+
let killTimer = null;
|
|
213
|
+
const graceMs = deps.pythonKillGraceMs ?? DEFAULT_PYTHON_KILL_GRACE_MS;
|
|
214
|
+
const onAbort = () => {
|
|
215
|
+
child.kill("SIGTERM");
|
|
216
|
+
killTimer = setTimeout(() => {
|
|
217
|
+
child.kill("SIGKILL");
|
|
218
|
+
}, graceMs);
|
|
219
|
+
killTimer.unref();
|
|
220
|
+
};
|
|
221
|
+
deps.signal?.addEventListener("abort", onAbort, { once: true });
|
|
222
|
+
try {
|
|
223
|
+
// `close` (not `exit`) so both stdio pipes have drained — the last stderr lines are in the
|
|
224
|
+
// tail before curation reads it. A spawn failure emits `error` and may never emit `close`.
|
|
225
|
+
const outcome = await new Promise((resolve) => {
|
|
226
|
+
child.once("error", (error) => {
|
|
227
|
+
resolve({ kind: "spawn_error", error });
|
|
228
|
+
});
|
|
229
|
+
child.once("close", (code, signal) => {
|
|
230
|
+
resolve({ kind: "exit", code, signal });
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
stdoutLines.flush();
|
|
234
|
+
stderrLines.flush();
|
|
235
|
+
if (outcome.kind === "spawn_error")
|
|
236
|
+
throw curateSpawnFailure(interpreter, outcome.error);
|
|
237
|
+
// An aborted run supersedes whatever the child's exit looked like (we killed it): throw the
|
|
238
|
+
// signal's own RunAbortedError so the reason propagates; the orchestrator's post-body
|
|
239
|
+
// `signal.aborted` check is authoritative for the terminal write either way.
|
|
240
|
+
throwIfAborted(deps.signal);
|
|
241
|
+
if (server.hasReturn()) {
|
|
242
|
+
// The run completed when report_return landed — the server validated + persisted the
|
|
243
|
+
// value; the child disconnecting and exiting afterwards is its clean shutdown.
|
|
244
|
+
const output = server.reportedReturn();
|
|
245
|
+
if (output !== null)
|
|
246
|
+
deps.onOutput?.(output);
|
|
247
|
+
return { kind: "completed", output };
|
|
248
|
+
}
|
|
249
|
+
// No return reported. A recorded report_return failure (the output-schema mismatch) is a
|
|
250
|
+
// richer curation source than the traceback the child printed for it: the real
|
|
251
|
+
// VALIDATION_FAILED code + the server's own detail, with the same hint the TS loader adds.
|
|
252
|
+
const recorded = server.reportReturnFailure();
|
|
253
|
+
if (recorded !== null) {
|
|
254
|
+
const carried = recorded;
|
|
255
|
+
if (carried.code === ErrorCode.VALIDATION_FAILED.valueOf() && carried.hint === undefined) {
|
|
256
|
+
Object.assign(recorded, { hint: OUTPUT_MISMATCH_HINT });
|
|
257
|
+
}
|
|
258
|
+
throw recorded;
|
|
259
|
+
}
|
|
260
|
+
throw curatePythonFailure({ exitCode: outcome.code, signal: outcome.signal, stderrTail });
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
deps.signal?.removeEventListener("abort", onAbort);
|
|
264
|
+
if (killTimer !== null)
|
|
265
|
+
clearTimeout(killTimer);
|
|
266
|
+
// Never leak the subprocess past the run, whatever path threw above (kill after exit no-ops).
|
|
267
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
268
|
+
child.kill("SIGKILL");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ContextData } from "@boardwalk-labs/workflow/runtime";
|
|
2
|
+
import type { Run } from "./wire/run.js";
|
|
3
|
+
/** The claim-payload siblings of the run row that feed `context` (P3.7). Fields are optional so
|
|
4
|
+
* an older backend's claim (predating them) degrades to the logged fallbacks. */
|
|
5
|
+
export interface ClaimContextExtras {
|
|
6
|
+
workflowVersion?: number | null;
|
|
7
|
+
environment?: {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
} | null;
|
|
11
|
+
}
|
|
12
|
+
/** Build the bootstrap `context` data for a claimed run. Pure; fallbacks are logged, never thrown. */
|
|
13
|
+
export declare function buildContextData(run: Run, workspaceRoot: string, extras?: ClaimContextExtras): ContextData;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { createLogger } from "./support/index.js";
|
|
3
|
+
const log = createLogger("RunContext");
|
|
4
|
+
/** `trigger.kind` is the TRANSPORT (the two-axis rule): cron timer, webhook delivery, or a
|
|
5
|
+
* direct invocation = `manual`. Anything unrecognized maps to `manual` with a warning — the
|
|
6
|
+
* actor still says who fired it. */
|
|
7
|
+
function triggerKind(run) {
|
|
8
|
+
const kind = run.triggerKind;
|
|
9
|
+
if (kind === "cron" || kind === "webhook" || kind === "manual")
|
|
10
|
+
return kind;
|
|
11
|
+
log.warn("context_trigger_kind_unrecognized", { runId: run.id, triggerKind: kind });
|
|
12
|
+
return "manual";
|
|
13
|
+
}
|
|
14
|
+
/** The runner's wire `RunActor` mirrors the backend's `runActorSchema`, which is exactly the
|
|
15
|
+
* SDK's `actorSchema` — an identity mapping, typed as such so a drift breaks the build. */
|
|
16
|
+
function toActor(actor) {
|
|
17
|
+
return actor;
|
|
18
|
+
}
|
|
19
|
+
/** A trigger-specific `source` when the actor names one (webhook source / cron rule /
|
|
20
|
+
* event subscription), else absent. */
|
|
21
|
+
function triggerSource(actor) {
|
|
22
|
+
switch (actor.type) {
|
|
23
|
+
case "webhook":
|
|
24
|
+
return actor.source;
|
|
25
|
+
case "cron":
|
|
26
|
+
return actor.rule;
|
|
27
|
+
case "event":
|
|
28
|
+
return actor.subscription_id;
|
|
29
|
+
default:
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Build the bootstrap `context` data for a claimed run. Pure; fallbacks are logged, never thrown. */
|
|
34
|
+
export function buildContextData(run, workspaceRoot, extras = {}) {
|
|
35
|
+
let workflowVersion = extras.workflowVersion ?? null;
|
|
36
|
+
if (workflowVersion === null) {
|
|
37
|
+
// Older backend (field absent) or a backend integrity anomaly (explicit null) — fall back.
|
|
38
|
+
log.warn("context_workflow_version_unavailable", {
|
|
39
|
+
runId: run.id,
|
|
40
|
+
workflowVersionId: run.workflowVersionId,
|
|
41
|
+
});
|
|
42
|
+
workflowVersion = 1;
|
|
43
|
+
}
|
|
44
|
+
let environment;
|
|
45
|
+
if (extras.environment !== undefined) {
|
|
46
|
+
environment = extras.environment; // null here is the REAL org-base value.
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
environment = null;
|
|
50
|
+
if (run.environmentId !== null) {
|
|
51
|
+
// Older backend: an environment was selected but the claim carries no name.
|
|
52
|
+
log.warn("context_environment_name_unavailable", {
|
|
53
|
+
runId: run.id,
|
|
54
|
+
environmentId: run.environmentId,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (run.attempt === undefined) {
|
|
59
|
+
log.warn("context_attempt_unavailable", { runId: run.id });
|
|
60
|
+
}
|
|
61
|
+
const source = triggerSource(run.actor);
|
|
62
|
+
return {
|
|
63
|
+
runId: run.id,
|
|
64
|
+
workflowId: run.workflowId,
|
|
65
|
+
workflowVersion,
|
|
66
|
+
orgId: run.orgId,
|
|
67
|
+
environment,
|
|
68
|
+
actor: toActor(run.actor),
|
|
69
|
+
attempt: run.attempt ?? 1,
|
|
70
|
+
trigger: {
|
|
71
|
+
kind: triggerKind(run),
|
|
72
|
+
firedAt: run.createdAt,
|
|
73
|
+
...(source !== undefined ? { source } : {}),
|
|
74
|
+
},
|
|
75
|
+
workspaceDir: workspaceRoot,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -45,6 +45,10 @@ export interface BrokerChild {
|
|
|
45
45
|
childRunId: string;
|
|
46
46
|
status: string;
|
|
47
47
|
output: unknown;
|
|
48
|
+
/** The callee's PINNED version's stored `output_schema` — what lets the SDK revive a typed
|
|
49
|
+
* child's return. `null` = untyped callee; `undefined` = a non-terminal poll, a null output,
|
|
50
|
+
* or an older backend that predates the field. */
|
|
51
|
+
outputSchema?: Record<string, unknown> | null;
|
|
48
52
|
}
|
|
49
53
|
export declare class RunnerControlClient {
|
|
50
54
|
private readonly cfg;
|
|
@@ -72,6 +76,14 @@ export declare class RunnerControlClient {
|
|
|
72
76
|
/** Bulk transfers (artifact + workspace up/download over presigned S3) — a much larger ceiling
|
|
73
77
|
* than a control call, but still bounded so a dead socket can't hang the run. */
|
|
74
78
|
private bulkFetch;
|
|
79
|
+
/** GET a run-scoped control path; any status but 200 throws. The `as T` is the client's one
|
|
80
|
+
* trust-boundary cast: the broker is the platform's own API and each caller names the
|
|
81
|
+
* handler's documented reply shape. */
|
|
82
|
+
private getJson;
|
|
83
|
+
/** POST a JSON body to a run-scoped control path; any status but `expect` throws. */
|
|
84
|
+
private postJson;
|
|
85
|
+
/** POST a JSON body to a run-scoped control path expecting an empty 204 reply. */
|
|
86
|
+
private postVoid;
|
|
75
87
|
/**
|
|
76
88
|
* One attempt per entry in the backoff schedule (+1): retry thrown network failures (connection
|
|
77
89
|
* reset/refused mid-rollover, our own per-attempt timeout on a dead socket) and the
|
|
@@ -87,6 +99,16 @@ export declare class RunnerControlClient {
|
|
|
87
99
|
claim(workerId: string, leaseSeconds: number): Promise<{
|
|
88
100
|
run: Run;
|
|
89
101
|
lastEventCursor: number;
|
|
102
|
+
/** The pinned version's SEQUENTIAL int (context.workflowVersion). `undefined` = an older
|
|
103
|
+
* backend that predates the field (fallback 1, warned); `null` = a defensive backend
|
|
104
|
+
* integrity anomaly. */
|
|
105
|
+
workflowVersion?: number | null;
|
|
106
|
+
/** The run's selected environment (context.environment). `undefined` = an older backend;
|
|
107
|
+
* `null` = org base (or a deleted environment) — a REAL value, no fallback needed. */
|
|
108
|
+
environment?: {
|
|
109
|
+
id: string;
|
|
110
|
+
name: string;
|
|
111
|
+
} | null;
|
|
90
112
|
} | null>;
|
|
91
113
|
/** Heartbeat: extend our lease so a long run isn't reclaimed mid-flight. Returns the new
|
|
92
114
|
* `leaseUntil`, or null when the lease was lost (409 — another worker reclaimed the run), which
|
|
@@ -197,6 +219,7 @@ export declare class RunnerControlClient {
|
|
|
197
219
|
id: string;
|
|
198
220
|
status: string;
|
|
199
221
|
output: unknown;
|
|
222
|
+
outputSchema?: Record<string, unknown> | null;
|
|
200
223
|
} | null>;
|
|
201
224
|
/**
|
|
202
225
|
* Proxy one model turn through the broker (the Runner Credential Broker model). POSTs the
|