@wenathlan/saddle 2.0.163 → 2.0.164

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/scriptlanes.ts ADDED
@@ -0,0 +1,774 @@
1
+ /**
2
+ * scriptlanes.ts — the script lanes of the saddle engine: sh execution
3
+ * through the universal runner, yml and yaml workflow descriptors
4
+ * interpreted and compiled through babel into executable job plans,
5
+ * background execution through the house job queue, and docker-ready
6
+ * descriptors for the container lane.
7
+ *
8
+ * The lanes this file opens answer the modalities the registry declares
9
+ * (runner-universal for the bash lane, runner-babel for the compile lane,
10
+ * container-docker for the descriptor lane) and keep the owner's PR15
11
+ * mandate verbatim: "Implemente execução de SHs / interpretação de YML /
12
+ * YAML com compilação via Babel para permitir execução em segundo plano /
13
+ * Docker".
14
+ *
15
+ * The laws every lane below keeps:
16
+ * - sh scripts execute server-side through the engine (the bash lane),
17
+ * never the visitor machine: every public run entry refuses a scope
18
+ * that carries window or document (assertservercontext);
19
+ * - yml and yaml descriptors interpret into executable job plans through
20
+ * the house yaml subset (documented at the parser), and the babel lane
21
+ * compiles the descriptor through @babel/core into a regenerated
22
+ * literal the engine parses back through node:vm — the roundtrip is
23
+ * validated, never presumed;
24
+ * - background execution rides the house jobqueue and the runstatuses
25
+ * vocabulary (created, submitted, running, succeeded, failed,
26
+ * cancelled): the lane kicks the run detached from the caller and the
27
+ * status stays observable through scriptRunStatus;
28
+ * - honest execution only: no faked results, a {code, message} shape on
29
+ * every fallible path, and deadlines enforced with a timeout kill on
30
+ * the child process.
31
+ */
32
+
33
+ import { spawn } from "node:child_process";
34
+ import { runInNewContext } from "node:vm";
35
+ import { jobqueue, runrecord, runstatuses, transitionrun } from "./execution.ts";
36
+ import { aserror, idfactory, saddleerror, validationerror } from "./foundation.ts";
37
+
38
+ /* ════════════════════════════════════════════════════════════════════ */
39
+ /* Section 1: the server context law and the plan nouns — the lanes never touch the visitor machine. */
40
+ /* ════════════════════════════════════════════════════════════════════ */
41
+ /**
42
+ * the server context law and the plan nouns: the types the lanes speak
43
+ * and the refusal every public run entry carries.
44
+ */
45
+
46
+ /** one interpreted step of a script plan: the name the run reports, the
47
+ * sh text the bash lane executes, and the optional per-step deadline in
48
+ * milliseconds. */
49
+ export type ScriptStep = { name: string; run: string; timeoutms?: number };
50
+
51
+ /** an executable job plan: the steps in execution order, the descriptor
52
+ * source the plan was interpreted from, and the compile lane the plan
53
+ * rides (direct for the sh lane, babel for the yml and yaml lanes —
54
+ * compilewithbabel performs the babel lane and stamps it). */
55
+ export type ScriptPlan = {
56
+ steps: ScriptStep[];
57
+ source: "sh" | "yml" | "yaml";
58
+ compiledvia: "babel" | "direct";
59
+ };
60
+
61
+ /** the run options the execution lanes accept: the storage handle is
62
+ * carried for the engine commit lane (the script lane itself executes
63
+ * and answers — it never pretends a commit), and deadlinems is the
64
+ * fallback deadline for steps that carry no timeoutms of their own. */
65
+ export type ScriptRunOptions = { storage?: unknown; deadlinems?: number };
66
+
67
+ /** one honestly observed step execution: the exit code (null when the
68
+ * step never ran — skipped, killed or unspawnable), the captured
69
+ * streams, the measured duration, and the skip or timeout flags. */
70
+ export type ScriptStepResult = {
71
+ name: string;
72
+ exitcode: number | null;
73
+ stdout: string;
74
+ stderr: string;
75
+ durationms: number;
76
+ skipped?: boolean;
77
+ timedout?: boolean;
78
+ signal?: string | null;
79
+ truncated?: boolean;
80
+ error?: { code: string; message: string };
81
+ };
82
+
83
+ /** the honest result of one plan run: succeeded only when every step
84
+ * exited zero, failed with the first failure otherwise. */
85
+ export type ScriptRunResult = {
86
+ status: "succeeded" | "failed";
87
+ steps: ScriptStepResult[];
88
+ error?: { code: string; message: string };
89
+ };
90
+
91
+ /** the observable record of one background run: the runstatuses
92
+ * vocabulary the transitions walk, plus the result once the detached run
93
+ * settles. */
94
+ export type ScriptRunRecord = {
95
+ version: number;
96
+ requestid: string;
97
+ name: string;
98
+ status: string;
99
+ runid?: string;
100
+ attempt: number;
101
+ createdat: number;
102
+ updatedat: number;
103
+ history: Array<{ from: string; to: string; at: number; reason?: string }>;
104
+ metadata: Record<string, unknown>;
105
+ result?: ScriptRunResult;
106
+ };
107
+
108
+ /** refuses a scope that carries window or document: the script lanes
109
+ * execute server-side only, and the visitor machine is never touched
110
+ * (the never-the-visitor-browser law). every public run entry calls
111
+ * this before any work. */
112
+ export function assertservercontext(scope: Record<string, unknown> = globalThis): void {
113
+ if (scope.window !== undefined || scope.document !== undefined) {
114
+ throw saddleerror("the script lanes execute server-side only — the visitor browser context is refused", {
115
+ code: "VISITOR_CONTEXT_REFUSED",
116
+ details: { window: scope.window !== undefined, document: scope.document !== undefined },
117
+ });
118
+ }
119
+ }
120
+
121
+ /** validates a script plan honestly: a non-empty steps array where every
122
+ * step carries a non-empty string name, a string run and an optional
123
+ * positive finite timeoutms, with a known source and compile lane. */
124
+ function validateplan(plan: unknown): ScriptPlan {
125
+ if (!plan || typeof plan !== "object") throw validationerror("the script plan must be an object");
126
+ const candidate = plan as Partial<ScriptPlan> & { steps?: unknown };
127
+ if (!Array.isArray(candidate.steps) || candidate.steps.length === 0) {
128
+ throw validationerror("the script plan must carry a non-empty steps array");
129
+ }
130
+ for (const step of candidate.steps) {
131
+ if (!step || typeof step !== "object") throw validationerror("every script step must be an object");
132
+ const record = step as Partial<ScriptStep>;
133
+ if (typeof record.name !== "string" || record.name.trim().length === 0) {
134
+ throw validationerror("every script step requires a non-empty string name");
135
+ }
136
+ if (typeof record.run !== "string")
137
+ throw validationerror(`the step "${record.name}" requires a string run command`);
138
+ if (
139
+ record.timeoutms !== undefined &&
140
+ (typeof record.timeoutms !== "number" || !Number.isFinite(record.timeoutms) || record.timeoutms < 1)
141
+ ) {
142
+ throw validationerror(`the step "${record.name}" timeoutms must be a positive finite number`);
143
+ }
144
+ }
145
+ if (!["sh", "yml", "yaml"].includes(candidate.source ?? "")) {
146
+ throw validationerror("the script plan source must be sh, yml or yaml");
147
+ }
148
+ if (!["babel", "direct"].includes(candidate.compiledvia ?? "")) {
149
+ throw validationerror("the script plan compile lane must be babel or direct");
150
+ }
151
+ return candidate as ScriptPlan;
152
+ }
153
+
154
+ /** validates the shared deadline option: absent passes as undefined, a
155
+ * present value must be a positive finite number of milliseconds. */
156
+ function validatedeadline(value: unknown): number | undefined {
157
+ if (value === undefined) return undefined;
158
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1) {
159
+ throw validationerror("the script run deadlinems must be a positive finite number of milliseconds");
160
+ }
161
+ return value;
162
+ }
163
+
164
+ /* ════════════════════════════════════════════════════════════════════ */
165
+ /* Section 2: the interpretation lanes — the sh wrap and the house yaml subset parser. */
166
+ /* ════════════════════════════════════════════════════════════════════ */
167
+ /**
168
+ * the interpretation lanes: interpretsh wraps a raw sh script as one
169
+ * direct step, and the house yaml subset parser reads the workflow
170
+ * descriptors (yml and yaml spellings) into step lists.
171
+ */
172
+
173
+ /** wraps a raw sh script as a one-step plan: the script rides the bash
174
+ * lane directly, no compile step, so the plan declares the direct lane. */
175
+ export function interpretsh(script: string): ScriptPlan {
176
+ if (typeof script !== "string" || script.trim().length === 0) {
177
+ throw validationerror("the sh script must be a non-empty string");
178
+ }
179
+ return { steps: [{ name: "script", run: script }], source: "sh", compiledvia: "direct" };
180
+ }
181
+
182
+ /** the yaml error factory: every interpret failure throws the honest
183
+ * house shape with the YAML_INTERPRET_FAILED code. */
184
+ function yamlerror(message: string) {
185
+ return saddleerror(`the yml interpretation failed: ${message}`, { code: "YAML_INTERPRET_FAILED" });
186
+ }
187
+
188
+ /** strips a `#` comment the way yaml does: outside quotes, and only at
189
+ * line start or after whitespace (a `#` glued to a word is content). */
190
+ function striphyamlcomment(line: string): string {
191
+ let quote: string | null = null;
192
+ for (let index = 0; index < line.length; index += 1) {
193
+ const char = line[index];
194
+ if (quote) {
195
+ if (char === quote) quote = null;
196
+ continue;
197
+ }
198
+ if (char === '"' || char === "'") {
199
+ quote = char;
200
+ continue;
201
+ }
202
+ if (char === "#" && (index === 0 || line[index - 1] === " " || line[index - 1] === "\t")) {
203
+ return line.slice(0, index);
204
+ }
205
+ }
206
+ return line;
207
+ }
208
+
209
+ /** parses one scalar of the house subset: a plain string, a double- or
210
+ * single-quoted string, an integer or decimal, or one of the two
211
+ * booleans. the subset never invents types beyond these (nulls, dates
212
+ * and exponents stay plain strings — documented, not silently coerced). */
213
+ function parsehousescalar(raw: string, lineno: number): string | number | boolean {
214
+ const text = raw.trim();
215
+ if (text.length === 0) {
216
+ throw yamlerror(`line ${lineno}: the value after the colon is empty (block scalars are outside the house subset)`);
217
+ }
218
+ if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) {
219
+ const inner = text.slice(1, -1);
220
+ if (inner.includes('"')) throw yamlerror(`line ${lineno}: an unescaped double quote rides inside a quoted scalar`);
221
+ return inner;
222
+ }
223
+ if (text.length >= 2 && text.startsWith("'") && text.endsWith("'")) {
224
+ return text.slice(1, -1).replace(/''/g, "'");
225
+ }
226
+ if (text === "true") return true;
227
+ if (text === "false") return false;
228
+ if (/^-?\d+$/.test(text) || /^-?\d+\.\d+$/.test(text)) return Number(text);
229
+ return text;
230
+ }
231
+
232
+ /** the house yaml subset the lane interprets, documented honestly:
233
+ * exactly one top-level mapping key — `steps:` — whose value is an
234
+ * indented list of flat mappings; every list item carries `name:` (a
235
+ * string), `run:` (a string) and the optional `timeoutms:` (a positive
236
+ * number); values are single-line scalars (plain or quoted strings,
237
+ * integers, decimals, true and false); comments start at a `#` outside
238
+ * quotes after whitespace; every list line shares one indentation and
239
+ * every key of one item shares a deeper one — a dedent or a mismatch is
240
+ * an honest refusal, never a guess. unknown keys, duplicate keys,
241
+ * document markers, nested mappings and block scalars are all outside
242
+ * the subset and refuse with YAML_INTERPRET_FAILED. */
243
+ function parsehousesubset(text: string): ScriptStep[] {
244
+ if (typeof text !== "string") throw validationerror("the yml descriptor must be a string");
245
+ const steps: ScriptStep[] = [];
246
+ const lines = text.split(/\r?\n/);
247
+ let sawsteps = false;
248
+ let itemindent = -1;
249
+ let fieldindent = -1;
250
+ let current: Map<string, string | number | boolean> | null = null;
251
+
252
+ function closecurrent(lineno: number) {
253
+ if (!current) return;
254
+ const name = current.get("name");
255
+ const run = current.get("run");
256
+ const timeoutms = current.get("timeoutms");
257
+ if (typeof name !== "string" || name.trim().length === 0) {
258
+ throw yamlerror(`line ${lineno}: every step requires a non-empty string name`);
259
+ }
260
+ if (typeof run !== "string") throw yamlerror(`line ${lineno}: the step "${name}" requires a string run command`);
261
+ if (timeoutms !== undefined && (typeof timeoutms !== "number" || !Number.isFinite(timeoutms) || timeoutms < 1)) {
262
+ throw yamlerror(`line ${lineno}: the step "${name}" timeoutms must be a positive number`);
263
+ }
264
+ steps.push(timeoutms === undefined ? { name, run } : { name, run, timeoutms });
265
+ current = null;
266
+ }
267
+
268
+ function parsefield(content: string, lineno: number) {
269
+ const match = /^([A-Za-z][A-Za-z0-9_-]*):(?:[ \t]+(.*))?$/.exec(content);
270
+ if (!match) throw yamlerror(`line ${lineno}: the house subset speaks "key: value" lines — malformed: ${content}`);
271
+ const key = match[1];
272
+ if (!["name", "run", "timeoutms"].includes(key)) {
273
+ throw yamlerror(`line ${lineno}: unknown key "${key}" — the house subset carries name, run and timeoutms only`);
274
+ }
275
+ if (current?.has(key)) throw yamlerror(`line ${lineno}: the key "${key}" appears twice in one step`);
276
+ if (match[2] === undefined || match[2].trim().length === 0) {
277
+ throw yamlerror(`line ${lineno}: the value for "${key}" is missing (block scalars are outside the house subset)`);
278
+ }
279
+ current?.set(key, parsehousescalar(match[2], lineno));
280
+ }
281
+
282
+ for (let index = 0; index < lines.length; index += 1) {
283
+ const lineno = index + 1;
284
+ const stripped = striphyamlcomment(lines[index]).replace(/\s+$/, "");
285
+ if (stripped.trim().length === 0) continue;
286
+ const indent = stripped.length - stripped.replace(/^ +/, "").length;
287
+ const content = stripped.trim();
288
+
289
+ if (!sawsteps) {
290
+ if (indent !== 0)
291
+ throw yamlerror(`line ${lineno}: the descriptor must open with the top-level steps key at column zero`);
292
+ if (content !== "steps:") {
293
+ throw yamlerror(
294
+ `line ${lineno}: the house subset carries exactly one top-level key (steps) — found: ${content}`,
295
+ );
296
+ }
297
+ sawsteps = true;
298
+ continue;
299
+ }
300
+
301
+ if (content === "-" || content.startsWith("- ")) {
302
+ closecurrent(lineno);
303
+ if (itemindent === -1) {
304
+ if (indent === 0) throw yamlerror(`line ${lineno}: the steps list must be indented under the steps key`);
305
+ itemindent = indent;
306
+ } else if (indent !== itemindent) {
307
+ throw yamlerror(
308
+ `line ${lineno}: the steps list items must share one indentation (expected column ${itemindent}, found ${indent})`,
309
+ );
310
+ }
311
+ current = new Map();
312
+ fieldindent = -1;
313
+ const inline = content === "-" ? "" : content.slice(2).trim();
314
+ if (inline.length > 0) {
315
+ fieldindent = stripped.indexOf(inline);
316
+ parsefield(inline, lineno);
317
+ }
318
+ continue;
319
+ }
320
+
321
+ if (!current) throw yamlerror(`line ${lineno}: a steps entry must start with a list dash (found: ${content})`);
322
+ if (indent === 0) {
323
+ throw yamlerror(`line ${lineno}: a second top-level key arrived after the steps list: ${content}`);
324
+ }
325
+ if (fieldindent === -1) {
326
+ if (indent <= itemindent)
327
+ throw yamlerror(`line ${lineno}: a bare list dash must be followed by deeper-indented keys`);
328
+ fieldindent = indent;
329
+ }
330
+ if (indent !== fieldindent) {
331
+ throw yamlerror(
332
+ `line ${lineno}: bad indentation — the keys of one step must share column ${fieldindent} (found column ${indent})`,
333
+ );
334
+ }
335
+ parsefield(content, lineno);
336
+ }
337
+ closecurrent(lines.length);
338
+ if (!sawsteps) throw yamlerror("the descriptor never carries the top-level steps key");
339
+ if (steps.length === 0) throw yamlerror("the steps list is empty — the descriptor must carry at least one step");
340
+ return steps;
341
+ }
342
+
343
+ /** interprets a yml workflow descriptor into an executable job plan
344
+ * through the house yaml subset; the plan declares the babel compile
345
+ * lane (compilewithbabel performs it). a parse failure throws the honest
346
+ * YAML_INTERPRET_FAILED code. */
347
+ export function interpretyml(text: string): ScriptPlan {
348
+ return { steps: parsehousesubset(text), source: "yml", compiledvia: "babel" };
349
+ }
350
+
351
+ /** interprets a yaml workflow descriptor — the same house subset under
352
+ * the yaml spelling, so both file extensions ride one lane. */
353
+ export function interpretyaml(text: string): ScriptPlan {
354
+ return { steps: parsehousesubset(text), source: "yaml", compiledvia: "babel" };
355
+ }
356
+
357
+ /* ════════════════════════════════════════════════════════════════════ */
358
+ /* Section 3: the babel compile lane — the descriptor roundtrips through @babel/core and node:vm. */
359
+ /* ════════════════════════════════════════════════════════════════════ */
360
+ /**
361
+ * the babel compile lane: the plan descriptor is serialized to a
362
+ * javascript literal, regenerated through @babel/core transformSync (no
363
+ * presets — the transform parses and re-emits the source), and parsed
364
+ * back through node:vm runInNewContext in a fresh context that carries
365
+ * no globals, so the compiled text is data, never executable logic. the
366
+ * roundtrip is validated against the input plan — a loss refuses with
367
+ * BABEL_COMPILE_FAILED instead of degrading, and a missing @babel/core
368
+ * refuses with BABEL_RUNTIME_ABSENT.
369
+ */
370
+
371
+ /** the honest refusal when @babel/core is not resolvable at runtime. */
372
+ function babelabsenterror(cause?: unknown) {
373
+ return saddleerror(
374
+ "the babel compile lane requires @babel/core at runtime and it is absent in this install — the compile refuses instead of degrading",
375
+ { code: "BABEL_RUNTIME_ABSENT", cause, details: { lane: "runnerbabel" } },
376
+ );
377
+ }
378
+
379
+ /** compiles a script plan through the babel lane: serialize the plan to
380
+ * a javascript literal, transform it through @babel/core (no presets,
381
+ * no config files — the minimal options the runtime carries), parse the
382
+ * regenerated source back through node:vm, validate the roundtrip kept
383
+ * every step and the source, and return the plan stamped
384
+ * compiledvia "babel". */
385
+ export async function compilewithbabel(plan: ScriptPlan): Promise<ScriptPlan> {
386
+ assertservercontext();
387
+ validateplan(plan);
388
+ let transform: ((code: string, options: Record<string, unknown>) => { code: string } | null) | null = null;
389
+ try {
390
+ const mod = (await import("@babel/core")) as Record<string, unknown>;
391
+ const candidate = mod.transformSync ?? (mod.default as Record<string, unknown> | undefined)?.transformSync;
392
+ if (typeof candidate === "function") {
393
+ transform = candidate as (code: string, options: Record<string, unknown>) => { code: string } | null;
394
+ }
395
+ } catch (error) {
396
+ throw babelabsenterror(error);
397
+ }
398
+ if (!transform) throw babelabsenterror();
399
+
400
+ const serialized = `(${JSON.stringify({ steps: plan.steps, source: plan.source }, null, 2)});`;
401
+ let compiled: string;
402
+ try {
403
+ const result = transform(serialized, { configFile: false, babelrc: false, filename: "scriptplan.js" });
404
+ if (result === null || typeof result.code !== "string") throw new Error("the babel transform returned no code");
405
+ compiled = result.code;
406
+ } catch (error) {
407
+ const failure = error as Error;
408
+ throw saddleerror(
409
+ `the babel compile of the ${plan.source} descriptor failed: ${String(failure?.message ?? failure)}`,
410
+ {
411
+ code: "BABEL_COMPILE_FAILED",
412
+ cause: error,
413
+ },
414
+ );
415
+ }
416
+
417
+ let parsed: unknown;
418
+ try {
419
+ parsed = runInNewContext(compiled, {}, { timeout: 5000 });
420
+ } catch (error) {
421
+ throw saddleerror("the compiled descriptor did not parse back through the vm lane", {
422
+ code: "BABEL_COMPILE_FAILED",
423
+ cause: error,
424
+ });
425
+ }
426
+
427
+ const roundtripped = parsed as { steps?: unknown; source?: unknown };
428
+ if (JSON.stringify(roundtripped?.steps) !== JSON.stringify(plan.steps) || roundtripped?.source !== plan.source) {
429
+ throw saddleerror("the babel roundtrip lost plan content — the compile is refused, never faked", {
430
+ code: "BABEL_COMPILE_FAILED",
431
+ });
432
+ }
433
+ /* the validated plan steps ride onward (the vm objects live in the
434
+ fresh context and are the proof of the roundtrip, not the payload) */
435
+ return { steps: plan.steps, source: plan.source, compiledvia: "babel" };
436
+ }
437
+
438
+ /* ════════════════════════════════════════════════════════════════════ */
439
+ /* Section 4: the execution lane — sequential server-side bash with enforced deadlines. */
440
+ /* ════════════════════════════════════════════════════════════════════ */
441
+ /**
442
+ * the execution lane: every step runs server-side through `bash -c` on
443
+ * the engine machine (the runner-universal lane), with the exit code,
444
+ * both streams and the duration captured honestly. a failing step fails
445
+ * the plan and the later steps are skipped — reported as skipped, never
446
+ * silently dropped.
447
+ */
448
+
449
+ /** the default per-step deadline when neither the step nor the caller
450
+ * carries one: ten seconds, the house default the mandate names. */
451
+ const DEFAULTSTEPTIMEOUTMS = 10000;
452
+
453
+ /** the stream capture cap (about one megabyte per stream): the lane
454
+ * keeps draining past the cap but stops accumulating, and flags the
455
+ * truncation on the step record so the report never lies about size. */
456
+ const STREAMCAP = 1_000_000;
457
+
458
+ /** builds the honest failure entry for a step that could not spawn. */
459
+ function spawnfailure(step: ScriptStep, error: unknown) {
460
+ const failure = error as Error;
461
+ return {
462
+ code: "SCRIPT_SPAWN_FAILED",
463
+ message: `the bash lane could not spawn the step "${step.name}": ${String(failure?.message ?? failure)}`,
464
+ };
465
+ }
466
+
467
+ /** runs one step through `bash -c` with a timeout kill: the child is
468
+ * spawned with piped streams, the deadline timer sends SIGKILL when it
469
+ * fires, and the record carries the exit code, the signal, both captured
470
+ * streams (capped honestly), the duration and the failure entry. */
471
+ function runstep(step: ScriptStep, timeoutms: number): Promise<ScriptStepResult> {
472
+ return new Promise((resolve) => {
473
+ const startedat = Date.now();
474
+ let stdout = "";
475
+ let stderr = "";
476
+ let stdouttruncated = false;
477
+ let stderrtruncated = false;
478
+ let timedout = false;
479
+ let settled = false;
480
+ const finish = (record: ScriptStepResult) => {
481
+ if (settled) return;
482
+ settled = true;
483
+ resolve(record);
484
+ };
485
+
486
+ let child: ReturnType<typeof spawn>;
487
+ try {
488
+ child = spawn("bash", ["-c", step.run], { stdio: ["ignore", "pipe", "pipe"] });
489
+ } catch (error) {
490
+ finish({
491
+ name: step.name,
492
+ exitcode: null,
493
+ signal: null,
494
+ stdout: "",
495
+ stderr: "",
496
+ durationms: Date.now() - startedat,
497
+ error: spawnfailure(step, error),
498
+ });
499
+ return;
500
+ }
501
+
502
+ const timer = setTimeout(() => {
503
+ timedout = true;
504
+ child.kill("SIGKILL");
505
+ }, timeoutms);
506
+
507
+ const capture = (chunk: Buffer, target: "stdout" | "stderr") => {
508
+ const text = chunk.toString("utf8");
509
+ if (target === "stdout") {
510
+ if (stdout.length < STREAMCAP) stdout += text;
511
+ else stdouttruncated = true;
512
+ } else if (stderr.length < STREAMCAP) stderr += text;
513
+ else stderrtruncated = true;
514
+ };
515
+ child.stdout?.on("data", (chunk: Buffer) => capture(chunk, "stdout"));
516
+ child.stderr?.on("data", (chunk: Buffer) => capture(chunk, "stderr"));
517
+
518
+ child.on("error", (error: Error) => {
519
+ clearTimeout(timer);
520
+ finish({
521
+ name: step.name,
522
+ exitcode: null,
523
+ signal: null,
524
+ stdout,
525
+ stderr,
526
+ durationms: Date.now() - startedat,
527
+ error: spawnfailure(step, error),
528
+ });
529
+ });
530
+
531
+ child.on("close", (exitcode: number | null, signal: string | null) => {
532
+ clearTimeout(timer);
533
+ const ok = exitcode === 0 && !timedout;
534
+ finish({
535
+ name: step.name,
536
+ exitcode,
537
+ signal,
538
+ stdout,
539
+ stderr,
540
+ durationms: Date.now() - startedat,
541
+ timedout: timedout || undefined,
542
+ truncated: stdouttruncated || stderrtruncated || undefined,
543
+ error: ok
544
+ ? undefined
545
+ : timedout
546
+ ? {
547
+ code: "STEP_DEADLINE_EXCEEDED",
548
+ message: `the step "${step.name}" exceeded its ${timeoutms}ms deadline and was killed`,
549
+ }
550
+ : {
551
+ code: "SCRIPT_STEP_FAILED",
552
+ message: `the step "${step.name}" exited with code ${exitcode}`,
553
+ },
554
+ });
555
+ });
556
+ });
557
+ }
558
+
559
+ /** executes a script plan sequentially server-side: each step runs
560
+ * through `bash -c` with the deadline `step.timeoutms ?? the caller
561
+ * deadlinems ?? ten seconds`, the exit code and both streams are
562
+ * captured, a failing step fails the plan honestly and every later step
563
+ * is skipped with a null exit code and the skipped flag. the storage
564
+ * option is carried for the engine commit lane — this lane executes and
565
+ * answers, it never pretends a commit. */
566
+ export async function runscript(plan: ScriptPlan, options: ScriptRunOptions = {}): Promise<ScriptRunResult> {
567
+ assertservercontext();
568
+ validateplan(plan);
569
+ const deadlinems = validatedeadline(options.deadlinems);
570
+ const steps: ScriptStepResult[] = [];
571
+ let failure: { code: string; message: string } | undefined;
572
+
573
+ for (const step of plan.steps) {
574
+ if (failure) {
575
+ steps.push({
576
+ name: step.name,
577
+ exitcode: null,
578
+ signal: null,
579
+ stdout: "",
580
+ stderr: "",
581
+ durationms: 0,
582
+ skipped: true,
583
+ });
584
+ continue;
585
+ }
586
+ const timeoutms = step.timeoutms ?? deadlinems ?? DEFAULTSTEPTIMEOUTMS;
587
+ const record = await runstep(step, timeoutms);
588
+ steps.push(record);
589
+ if (record.exitcode !== 0) {
590
+ failure = record.error ?? { code: "SCRIPT_STEP_FAILED", message: `the step "${step.name}" failed` };
591
+ }
592
+ }
593
+ return failure === undefined ? { status: "succeeded", steps } : { status: "failed", steps, error: failure };
594
+ }
595
+
596
+ /* ════════════════════════════════════════════════════════════════════ */
597
+ /* Section 5: the background lane — detached runs in the house job queue with observable statuses. */
598
+ /* ════════════════════════════════════════════════════════════════════ */
599
+ /**
600
+ * the background lane: runscriptbackground registers the run as a house
601
+ * run record (the runstatuses vocabulary), queues the execution through
602
+ * the execution.ts jobqueue (one attempt — script side effects never
603
+ * replay), kicks it without awaiting and returns immediately; the
604
+ * statuses stay observable through scriptRunStatus until the detached
605
+ * run settles with its result.
606
+ */
607
+
608
+ /** the id factory the background lane stamps run ids with. */
609
+ const scriptrunids = idfactory();
610
+
611
+ /** the house job queue the detached script runs ride: concurrency two
612
+ * (background lanes may overlap), one attempt per run (a failed script
613
+ * is reported, never silently retried — honest execution only). */
614
+ const scriptlanequeue = jobqueue({ concurrency: 2, maxattempts: 1, retryall: false });
615
+
616
+ /** the registry of background runs this process carries: runid to the
617
+ * mutable record holder with the result once the run settles. */
618
+ const scriptruns = new Map<string, { record: ScriptRunRecord; result?: ScriptRunResult }>();
619
+
620
+ /** settles one background run: walks the legal transition and stores the
621
+ * result. the transition from running to succeeded or failed is the one
622
+ * legal close of the lane, so a refusal here keeps the record as it was
623
+ * and never invents a status. */
624
+ function finalizescriptrun(runid: string, status: "succeeded" | "failed", result: ScriptRunResult) {
625
+ const holder = scriptruns.get(runid);
626
+ if (!holder) return;
627
+ try {
628
+ holder.record = transitionrun(holder.record, status, {
629
+ reason: `the detached run settled as ${result.status}`,
630
+ }) as ScriptRunRecord;
631
+ } catch {
632
+ /* the transition refused — the record stays where it was and the
633
+ result below still tells the truth about the execution */
634
+ }
635
+ holder.result = result;
636
+ }
637
+
638
+ /** kicks a script plan in the background: the run is registered as a
639
+ * house run record, queued through the house job queue and executed
640
+ * without awaiting — the call returns {runid, status} immediately and
641
+ * the statuses stay observable through scriptRunStatus (created,
642
+ * running, succeeded, failed — the runstatuses vocabulary). */
643
+ export function runscriptbackground(
644
+ plan: ScriptPlan,
645
+ options: ScriptRunOptions = {},
646
+ ): { runid: string; status: string } {
647
+ assertservercontext();
648
+ validateplan(plan);
649
+ validatedeadline(options.deadlinems);
650
+
651
+ const runid = scriptrunids.next("scriptrun");
652
+ let record = runrecord({
653
+ requestid: runid,
654
+ name: `scriptlane-${plan.source}`,
655
+ status: "created",
656
+ runid,
657
+ metadata: { source: plan.source, compiledvia: plan.compiledvia, stepcount: plan.steps.length },
658
+ }) as ScriptRunRecord;
659
+ const holder: { record: ScriptRunRecord; result?: ScriptRunResult } = { record };
660
+ scriptruns.set(runid, holder);
661
+
662
+ record = transitionrun(record, "submitted", {
663
+ runid,
664
+ reason: "the script lane queued the run in the house job queue",
665
+ }) as ScriptRunRecord;
666
+ record = transitionrun(record, "running", {
667
+ reason: "the script lane kicked the run detached from the caller",
668
+ }) as ScriptRunRecord;
669
+ holder.record = record;
670
+
671
+ void scriptlanequeue
672
+ .add(plan, async () => runscript(plan, options), { id: runid })
673
+ .then(
674
+ (result: ScriptRunResult) => {
675
+ finalizescriptrun(runid, result.status === "succeeded" ? "succeeded" : "failed", result);
676
+ },
677
+ (error: unknown) => {
678
+ const failure = aserror(error, runid);
679
+ finalizescriptrun(runid, "failed", {
680
+ status: "failed",
681
+ steps: [],
682
+ error: { code: failure.code, message: failure.message },
683
+ });
684
+ },
685
+ );
686
+
687
+ return { runid, status: record.status };
688
+ }
689
+
690
+ /** reads the observable record of one background run: the runstatuses
691
+ * vocabulary status, the auditable transition history, and the result
692
+ * once the detached run settled. an unknown runid refuses with the
693
+ * honest SCRIPT_RUN_NOT_FOUND code. */
694
+ export function scriptRunStatus(runid: string): ScriptRunRecord & { result?: ScriptRunResult } {
695
+ assertservercontext();
696
+ const key = String(runid ?? "");
697
+ const holder = scriptruns.get(key);
698
+ if (!holder) {
699
+ throw saddleerror(`the script run is unknown: ${key}`, { code: "SCRIPT_RUN_NOT_FOUND", details: { runid: key } });
700
+ }
701
+ if (!runstatuses.includes(holder.record.status)) {
702
+ throw saddleerror(`the script run record carries an out-of-vocabulary status: ${holder.record.status}`, {
703
+ code: "SCRIPT_RUN_CORRUPT",
704
+ details: { runid: key },
705
+ });
706
+ }
707
+ return { ...holder.record, history: [...holder.record.history], result: holder.result };
708
+ }
709
+
710
+ /* ════════════════════════════════════════════════════════════════════ */
711
+ /* Section 6: the docker lane — docker-ready descriptors for the container modality. */
712
+ /* ════════════════════════════════════════════════════════════════════ */
713
+ /**
714
+ * the docker lane: dockerdescriptor emits the docker-ready descriptor
715
+ * the container modality (container-docker) consumes — a Dockerfile on
716
+ * the house node base with every step as an escaped RUN line, plus the
717
+ * verbatim plan script and the sh entrypoint.
718
+ */
719
+
720
+ /** the house node base image the descriptor pins: the Dockerfile
721
+ * saddle-runtime stage rides FROM node:26.8.1-slim (the node-engine
722
+ * service image), so the descriptor asks for the base the house
723
+ * actually pins rather than a floating tag. */
724
+ export const SCRIPTLANES_NODE_BASE = "node:26.8.1-slim";
725
+
726
+ /** the heredoc sentinel of the plan script block: a quoted delimiter
727
+ * keeps the content verbatim (no expansion, quotes survive), and a
728
+ * descriptor whose script carries the sentinel itself refuses honestly
729
+ * instead of corrupting the block. */
730
+ const PLANHEREDOCSENTINEL = "SCRIPTLANES-PLAN-EOF";
731
+
732
+ /** escapes a step command for one dockerfile RUN line: the backslash
733
+ * first (so the other escapes cannot be forged), then the double quote
734
+ * and the dollar sign (the container shell never expands the plan), and
735
+ * a newline folds to an escaped \n so every step rides exactly one RUN
736
+ * line — the multi-line safety of the lane. */
737
+ function dockerescape(text: string): string {
738
+ return text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/\r?\n/g, "\\n");
739
+ }
740
+
741
+ /** emits the docker-ready descriptor for the container lane: a
742
+ * Dockerfile on the house node base carrying every step as an escaped
743
+ * RUN line (bash -c with the quotes and dollars escaped), the verbatim
744
+ * plan script written through a quoted heredoc COPY, and the sh
745
+ * entrypoint that runs it. the returned entrypoint matches the
746
+ * ENTRYPOINT line of the emitted Dockerfile. */
747
+ export function dockerdescriptor(plan: ScriptPlan): { dockerfile: string; entrypoint: string } {
748
+ assertservercontext();
749
+ validateplan(plan);
750
+
751
+ const runlines = plan.steps.map((step) => `RUN bash -c "${dockerescape(step.run)}"`);
752
+ const planscript = plan.steps.map((step) => `# ${step.name}\n${step.run}`).join("\n");
753
+ if (planscript.split(/\r?\n/).some((line) => line.trim() === PLANHEREDOCSENTINEL)) {
754
+ throw saddleerror(
755
+ "the plan script carries the heredoc sentinel — the docker descriptor refuses instead of corrupting",
756
+ {
757
+ code: "SCRIPTLANES_DESCRIPTOR_UNSAFE",
758
+ },
759
+ );
760
+ }
761
+
762
+ const dockerfile = [
763
+ `# docker-ready descriptor generated by the saddle script lanes (source: ${plan.source}, compile lane: ${plan.compiledvia}).`,
764
+ `FROM ${SCRIPTLANES_NODE_BASE}`,
765
+ "WORKDIR /app",
766
+ ...runlines,
767
+ `COPY <<'${PLANHEREDOCSENTINEL}' /app/plan.sh`,
768
+ planscript,
769
+ PLANHEREDOCSENTINEL,
770
+ 'ENTRYPOINT ["bash", "/app/plan.sh"]',
771
+ "",
772
+ ].join("\n");
773
+ return { dockerfile, entrypoint: "bash /app/plan.sh" };
774
+ }