@intentius/chant 0.124.23 → 0.124.24
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/dist/ci-jobs.d.ts +41 -1
- package/dist/ci-jobs.d.ts.map +1 -1
- package/dist/ci-jobs.js +48 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +14 -0
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +9 -0
- package/dist/cli/handlers/emulator.d.ts +13 -2
- package/dist/cli/handlers/emulator.d.ts.map +1 -1
- package/dist/cli/handlers/emulator.js +63 -3
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +7 -1
- package/dist/cli/handlers/run-wave.d.ts.map +1 -1
- package/dist/cli/handlers/run-wave.js +5 -4
- package/dist/cli/handlers/run.d.ts +4 -3
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +73 -9
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +17 -4
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +31 -0
- package/dist/cli/registry.d.ts +12 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +45 -6
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +97 -22
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +25 -10
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +24 -2
- package/dist/lifecycle/git.d.ts +11 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/git.js +62 -6
- package/dist/lifecycle/no-record.d.ts +59 -0
- package/dist/lifecycle/no-record.d.ts.map +1 -0
- package/dist/lifecycle/no-record.js +88 -0
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activities/shell.js +30 -9
- package/dist/op/emulator-lifecycle.d.ts +23 -0
- package/dist/op/emulator-lifecycle.d.ts.map +1 -1
- package/dist/op/emulator-lifecycle.js +39 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +3 -0
- package/dist/op/index.d.ts +1 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +1 -1
- package/dist/op/local-executor.d.ts +10 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +7 -2
- package/dist/op/op-waves-notify.d.ts +2 -0
- package/dist/op/op-waves-notify.d.ts.map +1 -1
- package/dist/op/op-waves-notify.js +9 -3
- package/dist/op/op-waves-run.d.ts +8 -2
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +36 -11
- package/dist/op/op-waves.d.ts +85 -1
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/op-waves.js +153 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/runtimes/local.js +6 -0
- package/dist/op/step-console.d.ts +65 -0
- package/dist/op/step-console.d.ts.map +1 -0
- package/dist/op/step-console.js +140 -0
- package/dist/point-decision.d.ts.map +1 -1
- package/dist/point-decision.js +3 -0
- package/dist/wave-runtime.mjs +238 -238
- package/dist/workspace/compose-graph.d.ts +22 -3
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/compose-graph.js +18 -5
- package/dist/workspace/graph.schema.json +68 -1
- package/dist/workspace/nested-graph.d.ts +1 -1
- package/dist/workspace/nested-graph.d.ts.map +1 -1
- package/dist/workspace/nested-graph.js +5 -1
- package/dist/workspace/runs.d.ts.map +1 -1
- package/dist/workspace/runs.js +4 -2
- package/package.json +1 -1
- package/src/ci-jobs.ts +58 -1
- package/src/cli/commands/build.ts +18 -0
- package/src/cli/handlers/approve.ts +9 -0
- package/src/cli/handlers/emulator.test.ts +61 -1
- package/src/cli/handlers/emulator.ts +65 -4
- package/src/cli/handlers/promote.test.ts +7 -0
- package/src/cli/handlers/promote.ts +7 -1
- package/src/cli/handlers/run-no-record.e2e.test.ts +87 -0
- package/src/cli/handlers/run-wave.ts +5 -4
- package/src/cli/handlers/run.test.ts +126 -0
- package/src/cli/handlers/run.ts +69 -9
- package/src/cli/main.test.ts +24 -0
- package/src/cli/main.ts +17 -4
- package/src/cli/parse-args.ts +21 -0
- package/src/cli/registry.ts +12 -0
- package/src/components/cli-support.test.ts +75 -0
- package/src/components/cli-support.ts +130 -25
- package/src/components/driver.test.ts +28 -0
- package/src/components/driver.ts +23 -10
- package/src/components/generate-disabled.test.ts +56 -0
- package/src/graph-ci-edges.test.ts +50 -0
- package/src/graph-detail.ts +21 -2
- package/src/lifecycle/git.ts +61 -6
- package/src/lifecycle/no-record.test.ts +175 -0
- package/src/lifecycle/no-record.ts +108 -0
- package/src/op/activities/shell.ts +28 -6
- package/src/op/emulator-lifecycle.test.ts +24 -1
- package/src/op/emulator-lifecycle.ts +58 -2
- package/src/op/gate.ts +3 -0
- package/src/op/index.ts +1 -1
- package/src/op/local-executor.ts +20 -3
- package/src/op/op-waves-jobs.test.ts +213 -0
- package/src/op/op-waves-notify.ts +11 -5
- package/src/op/op-waves-run.ts +42 -11
- package/src/op/op-waves.ts +202 -2
- package/src/op/runtimes/local.ts +6 -0
- package/src/op/step-console.test.ts +150 -0
- package/src/op/step-console.ts +155 -0
- package/src/point-decision-bundle.test.ts +1 -0
- package/src/point-decision.ts +3 -0
- package/src/workspace/compose-graph.test.ts +55 -0
- package/src/workspace/compose-graph.ts +28 -6
- package/src/workspace/graph-contract.test.ts +26 -1
- package/src/workspace/graph.schema.json +68 -1
- package/src/workspace/nested-graph.test.ts +14 -0
- package/src/workspace/nested-graph.ts +7 -2
- package/src/workspace/runs.ts +4 -2
package/src/op/op-waves.ts
CHANGED
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
*/
|
|
68
68
|
|
|
69
69
|
import type { OpEnvironment, OpSetupStep } from "../lexicon";
|
|
70
|
+
import type { CiRunner } from "../ci-jobs";
|
|
70
71
|
import { gatePointProblems, type GatePoint } from "./gate-point";
|
|
71
72
|
import { notifyConfigProblem, type NotifyConfig } from "../notify";
|
|
72
73
|
import { WAVE_GATE_NAME_PATTERN, waveGateNameFrom, waveShare } from "../wave-layout";
|
|
@@ -197,6 +198,51 @@ export interface OpWaveRun {
|
|
|
197
198
|
op?: string;
|
|
198
199
|
}
|
|
199
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Where a wave's jobs run (#3914): a runner label, a list of labels the
|
|
203
|
+
* runner must all carry, or on GitHub a runner group with optional labels.
|
|
204
|
+
* Rendered as `runs-on` on GitHub and Forgejo and as `tags` on GitLab; a
|
|
205
|
+
* runner group on GitLab or Forgejo is a spec error.
|
|
206
|
+
*/
|
|
207
|
+
export type OpWaveRunner = CiRunner;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* One job group of a wave (#3914): the runs it applies, and the job that
|
|
211
|
+
* applies them, with its own runner, forge environment, variables, setup and
|
|
212
|
+
* id tokens. The wave's deciding job plans every run and decides the wave's
|
|
213
|
+
* one gate; the group's job plans its own runs again and applies those whose
|
|
214
|
+
* plan has not moved since.
|
|
215
|
+
*/
|
|
216
|
+
export interface OpWaveJobGroup {
|
|
217
|
+
/** The group's name, in its job's name (`wave-<k>-<wave>-<name>`). Lowercase letters, digits, `-` and `_`. */
|
|
218
|
+
name: string;
|
|
219
|
+
/**
|
|
220
|
+
* The runs it applies: targets, or patterns where `*` matches within one
|
|
221
|
+
* path segment and `**` across segments. Each run of the wave is in exactly
|
|
222
|
+
* one group. On a `discover` wave the deciding job checks this once the
|
|
223
|
+
* runs are found.
|
|
224
|
+
*/
|
|
225
|
+
runs: string[];
|
|
226
|
+
/** Split the group's runs across this many jobs, as a wave's `shares` does. Default 1. */
|
|
227
|
+
shares?: number;
|
|
228
|
+
/** The group's runner. Default: the wave's, else the spec's. */
|
|
229
|
+
runner?: OpWaveRunner;
|
|
230
|
+
/** The forge deployment environment of the group's jobs (GitHub and GitLab; a spec error on Forgejo). Default: the wave's. */
|
|
231
|
+
environment?: OpEnvironment;
|
|
232
|
+
/** Variables for the group's jobs, over the wave's own. */
|
|
233
|
+
variables?: Record<string, string>;
|
|
234
|
+
/** Steps the group's jobs run after the wave's setup. */
|
|
235
|
+
setup?: OpSetupStep[];
|
|
236
|
+
/**
|
|
237
|
+
* The OIDC tokens the group's jobs get, as `{ <variable>: <audience> }`, in
|
|
238
|
+
* place of the wave's. GitLab declares them as the job's `id_tokens:`.
|
|
239
|
+
* GitHub and Forgejo give the job `id-token: write` and the audience as
|
|
240
|
+
* `<variable>_AUDIENCE`, since there the audience is chosen when the token
|
|
241
|
+
* is requested.
|
|
242
|
+
*/
|
|
243
|
+
idTokens?: Record<string, string>;
|
|
244
|
+
}
|
|
245
|
+
|
|
200
246
|
/** One wave: runs that apply together, behind one gate. */
|
|
201
247
|
export interface OpWave {
|
|
202
248
|
/** The wave's name, usually its environment. Lowercase letters, digits, `-` and `_`. */
|
|
@@ -247,6 +293,21 @@ export interface OpWave {
|
|
|
247
293
|
setup?: OpSetupStep[];
|
|
248
294
|
/** This wave's stage hooks (#3762), run after the spec's own. Read at the base commit. */
|
|
249
295
|
hooks?: OpWaveHooks;
|
|
296
|
+
/** Where this wave's jobs run (#3914). Default: the spec's `runner`. */
|
|
297
|
+
runner?: OpWaveRunner;
|
|
298
|
+
/**
|
|
299
|
+
* The OIDC tokens this wave's jobs get (#3914), as `{ <variable>: <audience> }`;
|
|
300
|
+
* see {@link OpWaveJobGroup.idTokens}. With `jobs`, these are the deciding
|
|
301
|
+
* job's, and a group's own replace them.
|
|
302
|
+
*/
|
|
303
|
+
idTokens?: Record<string, string>;
|
|
304
|
+
/**
|
|
305
|
+
* Split the wave's apply into job groups (#3914): a deciding job, which
|
|
306
|
+
* runs with the wave's own settings, plans every run and decides the gate,
|
|
307
|
+
* and a job per group (per share of a group), each applying its runs. Not
|
|
308
|
+
* with the wave's own `shares` (a group has its own) or `rest`.
|
|
309
|
+
*/
|
|
310
|
+
jobs?: OpWaveJobGroup[];
|
|
250
311
|
}
|
|
251
312
|
|
|
252
313
|
/**
|
|
@@ -279,6 +340,12 @@ export interface OpWavesSpec {
|
|
|
279
340
|
neverOverMcp?: boolean;
|
|
280
341
|
/** Branches whose pushes run the waves. Default `["main"]`. */
|
|
281
342
|
branches?: string[];
|
|
343
|
+
/**
|
|
344
|
+
* Where every job the spec renders runs unless a wave or a job group names
|
|
345
|
+
* another (#3914). Default: the forge's default runner (`ubuntu-latest` on
|
|
346
|
+
* GitHub, the dialect's label on Forgejo, no `tags` on GitLab).
|
|
347
|
+
*/
|
|
348
|
+
runner?: OpWaveRunner;
|
|
282
349
|
/**
|
|
283
350
|
* The commit the gate policy is read from. Default `HEAD^1`, the first
|
|
284
351
|
* parent: on a merge or squash onto the branch, the branch before the change.
|
|
@@ -423,8 +490,10 @@ export interface OpWaveJob {
|
|
|
423
490
|
waveName: string;
|
|
424
491
|
/** `wave`: plan, decide and apply. `decide`: plan and decide. `share`: apply one slice. */
|
|
425
492
|
kind: "wave" | "decide" | "share";
|
|
426
|
-
/** 1-based, on a share job. */
|
|
493
|
+
/** 1-based, on a share job (and a group's job when the group has shares). */
|
|
427
494
|
share?: number;
|
|
495
|
+
/** On a job group's job (#3914): the group's name. Its kind is `share`. */
|
|
496
|
+
group?: string;
|
|
428
497
|
/** Jobs this one waits on. */
|
|
429
498
|
needs: string[];
|
|
430
499
|
/** The job's command, as argv. */
|
|
@@ -508,6 +577,7 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
|
|
|
508
577
|
if (problems.length > 0) throw new Error(`${where}: ${problems.join("; ")}.`);
|
|
509
578
|
}
|
|
510
579
|
if (spec.neverOverMcp !== undefined && typeof spec.neverOverMcp !== "boolean") throw new Error(`${where}: neverOverMcp must be true or false.`);
|
|
580
|
+
if (spec.runner !== undefined) assertOpWaveRunner(where, "runner", spec.runner);
|
|
511
581
|
if (spec.notify !== undefined) {
|
|
512
582
|
const problem = notifyConfigProblem(spec.notify, "notify");
|
|
513
583
|
if (problem) throw new Error(`${where}: ${problem}`);
|
|
@@ -530,6 +600,9 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
|
|
|
530
600
|
const problems = opWavePointProblems(wave.point);
|
|
531
601
|
if (problems.length > 0) throw new Error(`${where}: wave "${wave.name}": ${problems.join("; ")}.`);
|
|
532
602
|
}
|
|
603
|
+
if (wave.runner !== undefined) assertOpWaveRunner(where, `wave "${wave.name}" runner`, wave.runner);
|
|
604
|
+
if (wave.idTokens !== undefined) assertOpWaveIdTokens(where, `wave "${wave.name}" idTokens`, wave.idTokens);
|
|
605
|
+
if (wave.jobs !== undefined) assertOpWaveJobGroups(where, wave);
|
|
533
606
|
if (wave.discover !== undefined && typeof wave.discover !== "boolean") throw new Error(`${where}: wave "${wave.name}" has discover ${JSON.stringify(wave.discover)}; use true or false.`);
|
|
534
607
|
if (wave.rest !== undefined && typeof wave.rest !== "boolean") throw new Error(`${where}: wave "${wave.name}" has rest ${JSON.stringify(wave.rest)}; use true or false.`);
|
|
535
608
|
if (wave.discover) {
|
|
@@ -560,7 +633,109 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
|
|
|
560
633
|
if (shares > wave.runs.length) {
|
|
561
634
|
throw new Error(`${where}: wave "${wave.name}" has ${shares} shares for ${wave.runs.length} runs, so a share would apply nothing.`);
|
|
562
635
|
}
|
|
636
|
+
if (wave.jobs !== undefined) {
|
|
637
|
+
const problem = opWaveGroupsProblem(wave, wave.runs.map((r) => r.target));
|
|
638
|
+
if (problem) throw new Error(`${where}: ${problem}.`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
644
|
+
|
|
645
|
+
/** Refuse a `runner` no forge could read (#3914). */
|
|
646
|
+
function assertOpWaveRunner(where: string, field: string, runner: unknown): void {
|
|
647
|
+
const labels = (value: unknown): boolean => Array.isArray(value) && value.length > 0 && value.every((l) => typeof l === "string" && l.trim() !== "");
|
|
648
|
+
if (typeof runner === "string" && runner.trim() !== "") return;
|
|
649
|
+
if (labels(runner)) return;
|
|
650
|
+
if (runner !== null && typeof runner === "object" && !Array.isArray(runner)) {
|
|
651
|
+
const { group, labels: l, ...rest } = runner as Record<string, unknown>;
|
|
652
|
+
const extra = Object.keys(rest)[0];
|
|
653
|
+
if (extra !== undefined) throw new Error(`${where}: ${field}.${extra} is not a setting; a runner group is { "group": "<name>", "labels"?: [...] }.`);
|
|
654
|
+
if (typeof group === "string" && group.trim() !== "" && (l === undefined || labels(l))) return;
|
|
655
|
+
}
|
|
656
|
+
throw new Error(`${where}: ${field} must be a runner label, a non-empty list of labels, or { "group": "<name>", "labels"?: [...] } (GitHub only).`);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Refuse `idTokens` that are not `{ <variable>: <audience> }` (#3914). */
|
|
660
|
+
function assertOpWaveIdTokens(where: string, field: string, idTokens: unknown): void {
|
|
661
|
+
if (idTokens === null || typeof idTokens !== "object" || Array.isArray(idTokens) || Object.keys(idTokens).length === 0) {
|
|
662
|
+
throw new Error(`${where}: ${field} must be { "<variable>": "<audience>" }, with at least one token.`);
|
|
663
|
+
}
|
|
664
|
+
for (const [name, aud] of Object.entries(idTokens as Record<string, unknown>)) {
|
|
665
|
+
if (!ENV_NAME.test(name)) throw new Error(`${where}: ${field} names "${name}", which is not a variable name (letters, digits and "_", not starting with a digit).`);
|
|
666
|
+
if (typeof aud !== "string" || aud.trim() === "") throw new Error(`${where}: ${field}.${name} must be an audience, a non-empty string.`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Refuse a wave's `jobs` no pipeline could render (#3914). Which runs each group holds is {@link opWaveGroupsProblem}'s. */
|
|
671
|
+
function assertOpWaveJobGroups(where: string, wave: OpWave): void {
|
|
672
|
+
const at = `wave "${wave.name}"`;
|
|
673
|
+
if (!Array.isArray(wave.jobs) || wave.jobs.length === 0) throw new Error(`${where}: ${at} jobs must be a non-empty list of job groups.`);
|
|
674
|
+
if ((wave.shares ?? 1) > 1) throw new Error(`${where}: ${at} has jobs and shares; split each job group with its own shares.`);
|
|
675
|
+
if (wave.rest) throw new Error(`${where}: ${at} has jobs and rest; the waves after it run in its one job, so its apply cannot be split into groups.`);
|
|
676
|
+
const names = new Set<string>();
|
|
677
|
+
wave.jobs.forEach((group, i) => {
|
|
678
|
+
if (group === null || typeof group !== "object") throw new Error(`${where}: ${at} jobs[${i}] must be { "name", "runs", ... }.`);
|
|
679
|
+
const field = `${at} jobs[${i}]${typeof group.name === "string" ? ` (${group.name})` : ""}`;
|
|
680
|
+
if (typeof group.name !== "string" || !NAME.test(group.name)) throw new Error(`${where}: ${field} must be named with lowercase letters, digits, "-" and "_".`);
|
|
681
|
+
if (group.name === "decide" || /^share-\d+$/.test(group.name)) throw new Error(`${where}: ${field} is named like the wave's own jobs (decide, share-<i>); name it another way.`);
|
|
682
|
+
if (names.has(group.name)) throw new Error(`${where}: ${at} names job group "${group.name}" twice.`);
|
|
683
|
+
names.add(group.name);
|
|
684
|
+
const known = new Set(["name", "runs", "shares", "runner", "environment", "variables", "setup", "idTokens"]);
|
|
685
|
+
const extra = Object.keys(group).find((key) => !known.has(key));
|
|
686
|
+
if (extra !== undefined) throw new Error(`${where}: ${field}.${extra} is not a setting; a job group takes ${[...known].join(", ")}.`);
|
|
687
|
+
if (!Array.isArray(group.runs) || group.runs.length === 0 || !group.runs.every((r) => typeof r === "string" && r.trim() !== "")) {
|
|
688
|
+
throw new Error(`${where}: ${field} runs must be a non-empty list of targets or patterns.`);
|
|
689
|
+
}
|
|
690
|
+
const shares = group.shares ?? 1;
|
|
691
|
+
if (!Number.isInteger(shares) || shares < 1) throw new Error(`${where}: ${field} has shares ${group.shares}; use a whole number from 1.`);
|
|
692
|
+
if (group.runner !== undefined) assertOpWaveRunner(where, `${field} runner`, group.runner);
|
|
693
|
+
if (group.idTokens !== undefined) assertOpWaveIdTokens(where, `${field} idTokens`, group.idTokens);
|
|
694
|
+
if (group.variables !== undefined && (group.variables === null || typeof group.variables !== "object" || Array.isArray(group.variables) || !Object.values(group.variables).every((v) => typeof v === "string"))) {
|
|
695
|
+
throw new Error(`${where}: ${field} variables must be { "<name>": "<value>" }.`);
|
|
696
|
+
}
|
|
697
|
+
if (group.setup !== undefined && !Array.isArray(group.setup)) throw new Error(`${where}: ${field} setup must be a list of steps.`);
|
|
698
|
+
if (group.environment !== undefined && (group.environment === null || typeof group.environment !== "object" || typeof group.environment.name !== "string")) {
|
|
699
|
+
throw new Error(`${where}: ${field} environment must be { "name", "url"? }.`);
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* True when `target` matches a job group's `runs` entry (#3914): the target
|
|
706
|
+
* itself, or a pattern where `*` matches within one `/` segment and `**`
|
|
707
|
+
* across segments.
|
|
708
|
+
*/
|
|
709
|
+
export function opWaveRunMatches(pattern: string, target: string): boolean {
|
|
710
|
+
if (!pattern.includes("*")) return pattern === target;
|
|
711
|
+
let re = "";
|
|
712
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
713
|
+
const c = pattern[i]!;
|
|
714
|
+
if (c === "*" && pattern[i + 1] === "*") {
|
|
715
|
+
re += ".*";
|
|
716
|
+
i++;
|
|
717
|
+
} else if (c === "*") re += "[^/]*";
|
|
718
|
+
else re += c.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
563
719
|
}
|
|
720
|
+
return new RegExp(`^${re}$`).test(target);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** The runs of `runs` that job group `group` holds (#3914). */
|
|
724
|
+
export function opWaveGroupRuns<T extends { target: string }>(group: Pick<OpWaveJobGroup, "runs">, runs: readonly T[]): T[] {
|
|
725
|
+
return runs.filter((run) => group.runs.some((pattern) => opWaveRunMatches(pattern, run.target)));
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* What is wrong with how a wave's job groups hold `targets` (#3914): a run in
|
|
730
|
+
* two groups, or in none. Undefined when each run is in exactly one.
|
|
731
|
+
*/
|
|
732
|
+
export function opWaveGroupsProblem(wave: Pick<OpWave, "name" | "jobs">, targets: readonly string[]): string | undefined {
|
|
733
|
+
for (const target of targets) {
|
|
734
|
+
const holders = (wave.jobs ?? []).filter((group) => group.runs.some((pattern) => opWaveRunMatches(pattern, target))).map((g) => `"${g.name}"`);
|
|
735
|
+
if (holders.length > 1) return `wave "${wave.name}" jobs: run "${target}" is in job groups ${holders.join(" and ")}; each run is in exactly one`;
|
|
736
|
+
if (holders.length === 0) return `wave "${wave.name}" jobs: run "${target}" is in no job group; each run is in exactly one`;
|
|
737
|
+
}
|
|
738
|
+
return undefined;
|
|
564
739
|
}
|
|
565
740
|
|
|
566
741
|
function assertArgv(where: string, field: string, argv: unknown): void {
|
|
@@ -655,7 +830,7 @@ export function opWaveJobs(spec: OpWavesSpec, specFile: string): OpWaveJob[] {
|
|
|
655
830
|
const command = ["chant", "run", "wave", "--spec", specFile, "--wave", String(k), ...(spec.base ? ["--base", spec.base] : [])];
|
|
656
831
|
const prefix = `wave-${k}-${wave.name}`;
|
|
657
832
|
const shares = wave.shares ?? 1;
|
|
658
|
-
if (shares === 1) {
|
|
833
|
+
if (shares === 1 && wave.jobs === undefined) {
|
|
659
834
|
jobs.push({ jobName: prefix, wave: k, waveName: wave.name, kind: "wave", needs: previous, command });
|
|
660
835
|
previous = [prefix];
|
|
661
836
|
return;
|
|
@@ -664,6 +839,26 @@ export function opWaveJobs(spec: OpWavesSpec, specFile: string): OpWaveJob[] {
|
|
|
664
839
|
const decide = `${prefix}-decide`;
|
|
665
840
|
jobs.push({ jobName: decide, wave: k, waveName: wave.name, kind: "decide", needs: previous, command: [...command, "--decide"], record });
|
|
666
841
|
const shareJobs: string[] = [];
|
|
842
|
+
if (wave.jobs !== undefined) {
|
|
843
|
+
// #3914: a job per group, or per share of a group, each applying its runs under the deciding job's decision.
|
|
844
|
+
for (const group of wave.jobs) {
|
|
845
|
+
const groupShares = group.shares ?? 1;
|
|
846
|
+
const groupCommand = [...command, "--group", group.name];
|
|
847
|
+
if (groupShares === 1) {
|
|
848
|
+
const jobName = `${prefix}-${group.name}`;
|
|
849
|
+
shareJobs.push(jobName);
|
|
850
|
+
jobs.push({ jobName, wave: k, waveName: wave.name, kind: "share", group: group.name, needs: [decide], command: groupCommand, record });
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
for (let share = 1; share <= groupShares; share++) {
|
|
854
|
+
const jobName = `${prefix}-${group.name}-share-${share}`;
|
|
855
|
+
shareJobs.push(jobName);
|
|
856
|
+
jobs.push({ jobName, wave: k, waveName: wave.name, kind: "share", group: group.name, share, needs: [decide], command: [...groupCommand, "--share", String(share)], record });
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
previous = shareJobs;
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
667
862
|
for (let share = 1; share <= shares; share++) {
|
|
668
863
|
const jobName = `${prefix}-share-${share}`;
|
|
669
864
|
shareJobs.push(jobName);
|
|
@@ -674,6 +869,11 @@ export function opWaveJobs(spec: OpWavesSpec, specFile: string): OpWaveJob[] {
|
|
|
674
869
|
return jobs;
|
|
675
870
|
}
|
|
676
871
|
|
|
872
|
+
/** The job group a job applies (#3914), or undefined for a wave's own jobs. */
|
|
873
|
+
export function opWaveGroupOf(spec: OpWavesSpec, job: Pick<OpWaveJob, "wave" | "group">): OpWaveJobGroup | undefined {
|
|
874
|
+
return job.group === undefined ? undefined : spec.waves[job.wave - 1]?.jobs?.find((g) => g.name === job.group);
|
|
875
|
+
}
|
|
876
|
+
|
|
677
877
|
/** The wave a job belongs to. */
|
|
678
878
|
export function opWaveOf(spec: OpWavesSpec, job: Pick<OpWaveJob, "wave">): OpWave {
|
|
679
879
|
return spec.waves[job.wave - 1]!;
|
package/src/op/runtimes/local.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { lexiconNames } from "../../lexicon-module";
|
|
|
22
22
|
import { loadChantConfig } from "../../config";
|
|
23
23
|
import { loadActivities, loadProfiles } from "../activity-registry";
|
|
24
24
|
import { runOpLocally, OpRunFailure, type OpRunResult } from "../local-executor";
|
|
25
|
+
import { stepHeartbeatMs } from "../step-console";
|
|
25
26
|
import { runComponents } from "../../components/cli-support";
|
|
26
27
|
import { discoverOps } from "../discover";
|
|
27
28
|
import { readRunLedger, runEnvOf, DEFAULT_RUN_ENV } from "../../lifecycle/run-ledger";
|
|
@@ -172,6 +173,11 @@ export function createLocalOpRuntime(opts: { projectPath?: string } = {}): OpRun
|
|
|
172
173
|
`appended to the run ledger: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
173
174
|
),
|
|
174
175
|
...(startOpts.progress ? { onRecord: startOpts.progress } : {}),
|
|
176
|
+
// #3956: a running step's output, and a still-running line while
|
|
177
|
+
// it is quiet, on stderr as they happen; stdout stays the
|
|
178
|
+
// `--json` record's and `--progress-json`'s.
|
|
179
|
+
stepOutput: (line) => process.stderr.write(`${line}\n`),
|
|
180
|
+
stepHeartbeatMs: stepHeartbeatMs(process.env, (message) => process.stderr.write(`warning: ${message}\n`)),
|
|
175
181
|
...(startOpts.work ? { work: startOpts.work } : {}),
|
|
176
182
|
onWorkLeaseWarning: (message) =>
|
|
177
183
|
process.stderr.write(`warning: "${op.name}" could not renew its work lease, retrying at the next beat: ${message}\n`),
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A running step's console (#3956): a step's output is printed as it
|
|
3
|
+
* arrives, each line carrying its phase and step, and a quiet step says it
|
|
4
|
+
* is still running.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, test, expect, vi } from "vitest";
|
|
8
|
+
import { shellCmd, type ShellCmdArgs } from "./activities/shell";
|
|
9
|
+
import { activity, phase, shell } from "./builders";
|
|
10
|
+
import { ACTIVITY_PROFILES } from "./activity-profiles";
|
|
11
|
+
import { runOpLocally } from "./local-executor";
|
|
12
|
+
import { memoryGateLedgerPort } from "./gate";
|
|
13
|
+
import type { ActivityFn } from "./activity-registry";
|
|
14
|
+
import { DEFAULT_STEP_HEARTBEAT_MS, stepHeartbeatMs, stepLabel } from "./step-console";
|
|
15
|
+
|
|
16
|
+
const shellFn: ActivityFn = (args, signal) => shellCmd(args as unknown as ShellCmdArgs, signal);
|
|
17
|
+
|
|
18
|
+
/** A writer that keeps each line with when it arrived. */
|
|
19
|
+
function recorder() {
|
|
20
|
+
const lines: Array<{ at: number; text: string }> = [];
|
|
21
|
+
return { lines, write: (text: string) => void lines.push({ at: Date.now(), text }) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A command that writes a line, waits, and writes another on stderr, then a
|
|
25
|
+
// last line on stdout with no newline.
|
|
26
|
+
const SLOW = `node -e "console.log('first'); setTimeout(() => { console.error('second'); process.stdout.write('third'); }, 600)"`;
|
|
27
|
+
|
|
28
|
+
describe("a step's output streams as it arrives (#3956)", () => {
|
|
29
|
+
test("the first line is printed before the command exits, prefixed with its phase and step", async () => {
|
|
30
|
+
const out = recorder();
|
|
31
|
+
let ended = 0;
|
|
32
|
+
const timed: ActivityFn = async (args, signal) => {
|
|
33
|
+
try {
|
|
34
|
+
return await shellFn(args, signal);
|
|
35
|
+
} finally {
|
|
36
|
+
ended = Date.now();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const result = await runOpLocally(
|
|
40
|
+
{ name: "seed", overview: "", phases: [phase("Seed", [shell(SLOW, { id: "wait-agent" })])] },
|
|
41
|
+
new Map([["shellCmd", timed]]),
|
|
42
|
+
ACTIVITY_PROFILES,
|
|
43
|
+
undefined,
|
|
44
|
+
{ gates: memoryGateLedgerPort(), stepOutput: out.write, stepHeartbeatMs: 0 },
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(result.status).toBe("ok");
|
|
48
|
+
expect(out.lines.map((l) => l.text)).toEqual([
|
|
49
|
+
"[Seed] wait-agent | first",
|
|
50
|
+
"[Seed] wait-agent | second",
|
|
51
|
+
"[Seed] wait-agent | third",
|
|
52
|
+
]);
|
|
53
|
+
// The first line arrived well before the command ended, not with it.
|
|
54
|
+
expect(ended - out.lines[0]!.at).toBeGreaterThan(300);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a step without an id is named by its call, as the run's render names it", async () => {
|
|
58
|
+
const out = recorder();
|
|
59
|
+
await runOpLocally(
|
|
60
|
+
{ name: "hello", overview: "", phases: [phase("Go", [shell("echo hi")])] },
|
|
61
|
+
new Map([["shellCmd", shellFn]]),
|
|
62
|
+
ACTIVITY_PROFILES,
|
|
63
|
+
undefined,
|
|
64
|
+
{ gates: memoryGateLedgerPort(), stepOutput: out.write, stepHeartbeatMs: 0 },
|
|
65
|
+
);
|
|
66
|
+
expect(out.lines.map((l) => l.text)).toEqual(["[Go] shellCmd(cmd=echo hi) | hi"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a failing command's output is printed too, before the failure", async () => {
|
|
70
|
+
const out = recorder();
|
|
71
|
+
await expect(
|
|
72
|
+
runOpLocally(
|
|
73
|
+
{ name: "broken", overview: "", phases: [phase("Go", [shell(`node -e "console.error('no such table'); process.exit(2)"`, { id: "migrate" })])] },
|
|
74
|
+
new Map([["shellCmd", shellFn]]),
|
|
75
|
+
ACTIVITY_PROFILES,
|
|
76
|
+
undefined,
|
|
77
|
+
{ gates: memoryGateLedgerPort(), stepOutput: out.write, stepHeartbeatMs: 0 },
|
|
78
|
+
),
|
|
79
|
+
).rejects.toThrow();
|
|
80
|
+
expect(out.lines.map((l) => l.text)).toEqual(["[Go] migrate | no such table"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("without a writer nothing is printed while the step runs, and stdout is not echoed", async () => {
|
|
84
|
+
const written = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
85
|
+
const logged = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
86
|
+
const result = await runOpLocally(
|
|
87
|
+
{ name: "quiet", overview: "", phases: [phase("Go", [shell("echo hi", { id: "say" })])] },
|
|
88
|
+
new Map([["shellCmd", shellFn]]),
|
|
89
|
+
ACTIVITY_PROFILES,
|
|
90
|
+
undefined,
|
|
91
|
+
{ gates: memoryGateLedgerPort() },
|
|
92
|
+
).finally(() => {
|
|
93
|
+
written.mockRestore();
|
|
94
|
+
logged.mockRestore();
|
|
95
|
+
});
|
|
96
|
+
expect(result.status).toBe("ok");
|
|
97
|
+
expect(written).not.toHaveBeenCalled();
|
|
98
|
+
expect(logged).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("a quiet step says it is still running (#3956)", () => {
|
|
103
|
+
test("an activity that only returns a value gets a still-running line each interval", async () => {
|
|
104
|
+
const out = recorder();
|
|
105
|
+
const slow: ActivityFn = () => new Promise((r) => setTimeout(() => r({ done: true }), 280));
|
|
106
|
+
await runOpLocally(
|
|
107
|
+
{ name: "wait", overview: "", phases: [phase("Deploy", [activity("waitForAgent", {}, { id: "agent" })])] },
|
|
108
|
+
new Map([["waitForAgent", slow]]),
|
|
109
|
+
ACTIVITY_PROFILES,
|
|
110
|
+
undefined,
|
|
111
|
+
{ gates: memoryGateLedgerPort(), stepOutput: out.write, stepHeartbeatMs: 100 },
|
|
112
|
+
);
|
|
113
|
+
expect(out.lines.map((l) => l.text.replace(/\d+s$/, "Ns"))).toEqual([
|
|
114
|
+
"[Deploy] agent: still running after Ns",
|
|
115
|
+
"[Deploy] agent: still running after Ns",
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("a step printing output is not interrupted by the still-running line", async () => {
|
|
120
|
+
const out = recorder();
|
|
121
|
+
const chatty = `node -e "let n = 0; const t = setInterval(() => { console.log('tick ' + n); if (++n === 6) clearInterval(t); }, 40)"`;
|
|
122
|
+
await runOpLocally(
|
|
123
|
+
{ name: "chatty", overview: "", phases: [phase("Go", [shell(chatty, { id: "ticks" })])] },
|
|
124
|
+
new Map([["shellCmd", shellFn]]),
|
|
125
|
+
ACTIVITY_PROFILES,
|
|
126
|
+
undefined,
|
|
127
|
+
{ gates: memoryGateLedgerPort(), stepOutput: out.write, stepHeartbeatMs: 100 },
|
|
128
|
+
);
|
|
129
|
+
expect(out.lines.filter((l) => l.text.includes("still running"))).toEqual([]);
|
|
130
|
+
expect(out.lines.map((l) => l.text)).toContain("[Go] ticks | tick 5");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("CHANT_STEP_HEARTBEAT sets the interval, and 0 or off turns it off", () => {
|
|
134
|
+
expect(stepHeartbeatMs({})).toBe(DEFAULT_STEP_HEARTBEAT_MS);
|
|
135
|
+
expect(stepHeartbeatMs({ CHANT_STEP_HEARTBEAT: "30s" })).toBe(30_000);
|
|
136
|
+
expect(stepHeartbeatMs({ CHANT_STEP_HEARTBEAT: "5m" })).toBe(300_000);
|
|
137
|
+
expect(stepHeartbeatMs({ CHANT_STEP_HEARTBEAT: "0" })).toBe(0);
|
|
138
|
+
expect(stepHeartbeatMs({ CHANT_STEP_HEARTBEAT: "off" })).toBe(0);
|
|
139
|
+
const warnings: string[] = [];
|
|
140
|
+
expect(stepHeartbeatMs({ CHANT_STEP_HEARTBEAT: "soon" }, (m) => warnings.push(m))).toBe(DEFAULT_STEP_HEARTBEAT_MS);
|
|
141
|
+
expect(warnings).toHaveLength(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("a long call is cut to a readable label", () => {
|
|
145
|
+
const label = stepLabel("shellCmd", { cmd: "x".repeat(200) });
|
|
146
|
+
expect(label.length).toBeLessThanOrEqual(60);
|
|
147
|
+
expect(label.endsWith("...)")).toBe(true);
|
|
148
|
+
expect(stepLabel("shellCmd", { cmd: "echo hi" }, "say")).toBe("say");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A running step's console (#3956): its output as it arrives, and a
|
|
3
|
+
* "still running" line while it is quiet.
|
|
4
|
+
*
|
|
5
|
+
* The run's own render (`./local-output.ts`) is printed once the run
|
|
6
|
+
* settles, so before this a 15-minute step printed nothing for 15 minutes and
|
|
7
|
+
* a watchdog reading the output killed a healthy run. When the caller hands
|
|
8
|
+
* the executor a writer (`RunOpOptions.stepOutput`, which the local runtime
|
|
9
|
+
* passes, writing to stderr), each activity step runs with a console:
|
|
10
|
+
*
|
|
11
|
+
* - an activity that runs a process (`shellCmd`) passes each line the process
|
|
12
|
+
* writes, stdout and stderr alike, through {@link stepOutputLine}, and it is
|
|
13
|
+
* printed at once as `[<phase>] <step> | <line>`;
|
|
14
|
+
* - a step that prints nothing for `heartbeatMs` (an activity that only
|
|
15
|
+
* returns a value, or a command that is quiet) gets
|
|
16
|
+
* `[<phase>] <step>: still running after <n>s` each time that much passes.
|
|
17
|
+
*
|
|
18
|
+
* `<step>` is the step's id when it has one, else its call as the run's
|
|
19
|
+
* render names it, `fn(arg=value, ...)`, cut to a readable length. Without a
|
|
20
|
+
* writer there is no console, and `shellCmd` prints a command's stderr when it
|
|
21
|
+
* ends, as it did before.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
25
|
+
import { parseDuration } from "./duration";
|
|
26
|
+
|
|
27
|
+
/** How long a step may be quiet before the runner says it is still running. */
|
|
28
|
+
export const DEFAULT_STEP_HEARTBEAT_MS = 60_000;
|
|
29
|
+
|
|
30
|
+
/** The environment variable that sets the interval: a duration ("30s", "5m"), or `0` / `off`. */
|
|
31
|
+
export const STEP_HEARTBEAT_ENV = "CHANT_STEP_HEARTBEAT";
|
|
32
|
+
|
|
33
|
+
/** The longest step label a line carries before it is cut. */
|
|
34
|
+
const MAX_LABEL = 60;
|
|
35
|
+
|
|
36
|
+
interface StepConsole {
|
|
37
|
+
line(text: string): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Where a run's step lines go, and how often a quiet step says it is still running. */
|
|
41
|
+
export interface RunConsole {
|
|
42
|
+
write: (line: string) => void;
|
|
43
|
+
heartbeatMs?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const store = new AsyncLocalStorage<StepConsole>();
|
|
47
|
+
const runStore = new AsyncLocalStorage<RunConsole>();
|
|
48
|
+
|
|
49
|
+
/** Run `fn` as a run whose steps print through `run`. Without one, steps get no console. */
|
|
50
|
+
export function withRunConsole<T>(run: RunConsole | undefined, fn: () => Promise<T>): Promise<T> {
|
|
51
|
+
return run ? runStore.run(run, fn) : fn();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatArgs(args: Record<string, unknown>): string {
|
|
55
|
+
return Object.entries(args)
|
|
56
|
+
.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
|
|
57
|
+
.join(", ");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The name a step's lines carry: its id, else `fn(args)` on one line, cut to {@link MAX_LABEL}. */
|
|
61
|
+
export function stepLabel(fn: string, args: Record<string, unknown>, id?: string): string {
|
|
62
|
+
if (id) return id;
|
|
63
|
+
const call = `${fn}(${formatArgs(args)})`.replace(/\s+/g, " ");
|
|
64
|
+
return call.length > MAX_LABEL ? `${call.slice(0, MAX_LABEL - 4)}...)` : call;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run `fn` with a console for one step, when the run has one
|
|
69
|
+
* ({@link withRunConsole}). The heartbeat timer is stopped when `fn`
|
|
70
|
+
* settles; it never holds the process open on its own.
|
|
71
|
+
*/
|
|
72
|
+
export async function withStepConsole<T>(opts: { phase: string; label: string }, fn: () => Promise<T>): Promise<T> {
|
|
73
|
+
const run = runStore.getStore();
|
|
74
|
+
if (!run) return fn();
|
|
75
|
+
const { write } = run;
|
|
76
|
+
const prefix = `[${opts.phase}] ${opts.label}`;
|
|
77
|
+
const started = Date.now();
|
|
78
|
+
let spoke = false;
|
|
79
|
+
const sink: StepConsole = {
|
|
80
|
+
line(text) {
|
|
81
|
+
spoke = true;
|
|
82
|
+
write(`${prefix} | ${text}`);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
const every = run.heartbeatMs ?? DEFAULT_STEP_HEARTBEAT_MS;
|
|
86
|
+
const timer = every > 0
|
|
87
|
+
? setInterval(() => {
|
|
88
|
+
if (spoke) {
|
|
89
|
+
spoke = false;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
write(`${prefix}: still running after ${Math.round((Date.now() - started) / 1000)}s`);
|
|
93
|
+
}, every)
|
|
94
|
+
: undefined;
|
|
95
|
+
timer?.unref();
|
|
96
|
+
try {
|
|
97
|
+
return await store.run(sink, fn);
|
|
98
|
+
} finally {
|
|
99
|
+
if (timer) clearInterval(timer);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Whether the calling activity runs under a step console, so its output is printed as it arrives. */
|
|
104
|
+
export function hasStepConsole(): boolean {
|
|
105
|
+
return store.getStore() !== undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Print one line of the calling step's output. A no-op outside a step console. */
|
|
109
|
+
export function stepOutputLine(text: string): void {
|
|
110
|
+
store.getStore()?.line(text);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A sink for a stream's chunks that prints whole lines to the calling step's
|
|
115
|
+
* console as they complete; `end()` prints what is left. The console is the
|
|
116
|
+
* one current when the splitter is made, since a stream's `data` events need
|
|
117
|
+
* not run in the step's async context.
|
|
118
|
+
*/
|
|
119
|
+
export function stepLineSplitter(): { push(chunk: string | Buffer): void; end(): void } {
|
|
120
|
+
const sink = store.getStore();
|
|
121
|
+
let pending = "";
|
|
122
|
+
const emit = (line: string) => sink?.line(line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
123
|
+
return {
|
|
124
|
+
push(chunk) {
|
|
125
|
+
pending += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
126
|
+
let at = pending.indexOf("\n");
|
|
127
|
+
while (at >= 0) {
|
|
128
|
+
emit(pending.slice(0, at));
|
|
129
|
+
pending = pending.slice(at + 1);
|
|
130
|
+
at = pending.indexOf("\n");
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
end() {
|
|
134
|
+
if (pending !== "") emit(pending);
|
|
135
|
+
pending = "";
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The heartbeat interval `CHANT_STEP_HEARTBEAT` sets, or the default. `0` and
|
|
142
|
+
* `off` turn it off. A value that is not a duration falls back to the default
|
|
143
|
+
* and says so through `warn`.
|
|
144
|
+
*/
|
|
145
|
+
export function stepHeartbeatMs(env: NodeJS.ProcessEnv = process.env, warn?: (message: string) => void): number {
|
|
146
|
+
const raw = env[STEP_HEARTBEAT_ENV]?.trim();
|
|
147
|
+
if (!raw) return DEFAULT_STEP_HEARTBEAT_MS;
|
|
148
|
+
if (raw === "0" || raw.toLowerCase() === "off") return 0;
|
|
149
|
+
try {
|
|
150
|
+
return parseDuration(raw);
|
|
151
|
+
} catch {
|
|
152
|
+
warn?.(`${STEP_HEARTBEAT_ENV}="${raw}" is not a duration (such as 30s or 5m) or off; using ${DEFAULT_STEP_HEARTBEAT_MS / 1000}s`);
|
|
153
|
+
return DEFAULT_STEP_HEARTBEAT_MS;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -51,6 +51,7 @@ describe("the point-decision bundle", () => {
|
|
|
51
51
|
"packages/core/src/intrinsic.ts",
|
|
52
52
|
"packages/core/src/lifecycle/gate-ledger-select.ts",
|
|
53
53
|
"packages/core/src/lifecycle/gate-pending.ts",
|
|
54
|
+
"packages/core/src/lifecycle/no-record.ts",
|
|
54
55
|
"packages/core/src/lifecycle/plan-digest.ts",
|
|
55
56
|
"packages/core/src/op/duration.ts",
|
|
56
57
|
"packages/core/src/op/gate-point.ts",
|
package/src/point-decision.ts
CHANGED
|
@@ -37,6 +37,7 @@ import type { GateLedgerPort } from "./op/gate";
|
|
|
37
37
|
import { DEFAULT_GATE_POINT_PASS, defaultGatePointStore, gatePointInputs, gateSubject, type GatePoint, type GatePointFacts, type GatePointStore } from "./op/gate-point";
|
|
38
38
|
import type { WaitingPoint } from "./op/steward-points";
|
|
39
39
|
import { resolveGateRunLocator, type GateRunLocator } from "./op/gate-run-locator";
|
|
40
|
+
import { refuseGateWithoutRecording } from "./lifecycle/no-record";
|
|
40
41
|
|
|
41
42
|
export {
|
|
42
43
|
DEFAULT_GATE_POINT_PASS,
|
|
@@ -170,6 +171,8 @@ function resolvedByOf(q: GatePointQuestion): string {
|
|
|
170
171
|
|
|
171
172
|
/** Decide a gate that asks a point: ask it, then record the pending fact or the resolution that cites the answer. */
|
|
172
173
|
export async function evaluatePointGate(port: PointGateLedgerPort, asker: GatePointAsker, input: PointGateInput): Promise<PointGateCheck> {
|
|
174
|
+
// #3955: refused before the point is asked, which would itself write to the ledger.
|
|
175
|
+
refuseGateWithoutRecording(input.op, input.gate);
|
|
173
176
|
const now = input.now ?? new Date().toISOString();
|
|
174
177
|
const facts: GatePointFacts = { component: input.op, name: input.gate, env: input.env ?? null, planDigest: input.planDigest ?? null };
|
|
175
178
|
const q = await asker.ask({ point: input.point.name, inputs: input.inputs ?? {}, facts, subject: gateSubject(input.op, input.gate, input.env), ...(input.point.store ? { store: input.point.store } : {}) });
|