@intentius/chant 0.53.0 → 0.54.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/dist/audit/catalog.d.ts +4 -0
- package/dist/audit/catalog.d.ts.map +1 -1
- package/dist/audit/nginx.d.ts +53 -0
- package/dist/audit/nginx.d.ts.map +1 -0
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/carve-apply.d.ts.map +1 -1
- package/dist/cli/commands/carve-status.d.ts +56 -0
- package/dist/cli/commands/carve-status.d.ts.map +1 -0
- package/dist/cli/handlers/carve-status.d.ts +9 -0
- package/dist/cli/handlers/carve-status.d.ts.map +1 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/op-progress.d.ts +37 -4
- package/dist/cli/handlers/op-progress.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +61 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +9 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +3 -1
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +2 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +7 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/lexicon.d.ts +8 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/converge-ledger.d.ts +81 -1
- package/dist/lifecycle/converge-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +43 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.d.ts +55 -1
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/op/activity-contract.d.ts +15 -1
- package/dist/op/activity-contract.d.ts.map +1 -1
- package/dist/terraform/manifest.d.ts +31 -6
- package/dist/terraform/manifest.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/catalog.ts +35 -0
- package/src/audit/edge-init-safety.test.ts +36 -3
- package/src/audit/nginx.test.ts +180 -0
- package/src/audit/nginx.ts +379 -0
- package/src/audit/rules-doc.ts +1 -0
- package/src/cli/commands/audit.ts +8 -3
- package/src/cli/commands/carve-apply.test.ts +4 -1
- package/src/cli/commands/carve-apply.ts +4 -2
- package/src/cli/commands/carve-bridge.test.ts +2 -1
- package/src/cli/commands/carve-emit-state.test.ts +3 -1
- package/src/cli/commands/carve-status.test.ts +116 -0
- package/src/cli/commands/carve-status.ts +163 -0
- package/src/cli/handlers/carve-status.ts +30 -0
- package/src/cli/handlers/components.ts +5 -1
- package/src/cli/handlers/graph.ts +3 -0
- package/src/cli/handlers/op-progress.test.ts +67 -1
- package/src/cli/handlers/op-progress.ts +70 -14
- package/src/cli/handlers/operator.test.ts +302 -1
- package/src/cli/handlers/operator.ts +193 -5
- package/src/cli/handlers/run.ts +15 -14
- package/src/cli/main.test.ts +19 -0
- package/src/cli/main.ts +34 -5
- package/src/cli/registry.ts +9 -1
- package/src/components/auto-release.ts +4 -1
- package/src/components/cli-support.ts +4 -2
- package/src/graph-ir.ts +7 -0
- package/src/lexicon.ts +8 -0
- package/src/lifecycle/converge-ledger.test.ts +110 -0
- package/src/lifecycle/converge-ledger.ts +110 -2
- package/src/lifecycle/gate-ledger.test.ts +81 -1
- package/src/lifecycle/gate-ledger.ts +64 -1
- package/src/lifecycle/release-ledger.test.ts +64 -0
- package/src/lifecycle/release-ledger.ts +79 -1
- package/src/op/activity-contract.ts +14 -0
- package/src/terraform/manifest.test.ts +52 -0
- package/src/terraform/manifest.ts +72 -10
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { carveStatus, carveStatusJson, formatCarveStatus } from "./carve-status";
|
|
6
|
+
import { writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
|
|
7
|
+
import type { CarveReport } from "../../terraform/carve";
|
|
8
|
+
|
|
9
|
+
function report(target: string): CarveReport {
|
|
10
|
+
return {
|
|
11
|
+
target,
|
|
12
|
+
carveSet: [{ address: target, type: target.split(".")[0] }],
|
|
13
|
+
peelability: 90,
|
|
14
|
+
inbound: [],
|
|
15
|
+
outbound: [],
|
|
16
|
+
reversible: true,
|
|
17
|
+
diagnostics: [],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function manifest(target: string): CarveManifest {
|
|
22
|
+
return { version: 1, target, from: "/estate", boundary: report(target) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function withDir<T>(fn: (dir: string) => T): T {
|
|
26
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-carve-status-"));
|
|
27
|
+
try {
|
|
28
|
+
return fn(dir);
|
|
29
|
+
} finally {
|
|
30
|
+
rmSync(dir, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("carve status", () => {
|
|
35
|
+
test("walks the tree at any depth and reports target, stage, and root-relative path", () => {
|
|
36
|
+
withDir((dir) => {
|
|
37
|
+
// Flat under a named output dir…
|
|
38
|
+
writeCarveManifest(join(dir, "carveout"), manifest("aws_s3_bucket.assets"));
|
|
39
|
+
// …and nested deeper than the two levels a walker would guess at.
|
|
40
|
+
const deep = { ...manifest("aws_sqs_queue.jobs"), tfType: "aws_sqs_queue" };
|
|
41
|
+
deep.emit = { source: "tfstate" as const, files: ["src/jobs.ts"], at: "2026-08-30T00:00:00Z" };
|
|
42
|
+
writeCarveManifest(join(dir, "stacks", "queues", "carveout"), deep);
|
|
43
|
+
|
|
44
|
+
const res = carveStatus({ from: dir });
|
|
45
|
+
expect(res.ok).toBe(true);
|
|
46
|
+
expect(res.from).toBe(dir);
|
|
47
|
+
expect(res.carves).toHaveLength(2);
|
|
48
|
+
|
|
49
|
+
const [flat, nested] = res.carves!;
|
|
50
|
+
expect(flat.path).toBe(join("carveout", "aws_s3_bucket-assets.carve.json"));
|
|
51
|
+
expect(flat.target).toBe("aws_s3_bucket.assets");
|
|
52
|
+
expect(flat.stage).toBe("planned"); // boundary only, no recorded step
|
|
53
|
+
expect(flat.at).toEqual({});
|
|
54
|
+
|
|
55
|
+
expect(nested.path).toBe(join("stacks", "queues", "carveout", "aws_sqs_queue-jobs.carve.json"));
|
|
56
|
+
expect(nested.target).toBe("aws_sqs_queue.jobs");
|
|
57
|
+
expect(nested.tfType).toBe("aws_sqs_queue");
|
|
58
|
+
expect(nested.stage).toBe("emitted");
|
|
59
|
+
expect(nested.at).toEqual({ emit: "2026-08-30T00:00:00Z" });
|
|
60
|
+
expect(nested.emittedFiles).toEqual(["src/jobs.ts"]);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("stage is the highest recorded step: emitted < bridged < applied", () => {
|
|
65
|
+
withDir((dir) => {
|
|
66
|
+
const m = manifest("aws_s3_bucket.assets");
|
|
67
|
+
m.emit = { source: "tfstate", files: ["src/assets.ts"], at: "t1" };
|
|
68
|
+
m.bridge = { written: ["runbook.md"], appliedInPlace: false, at: "t2" };
|
|
69
|
+
m.apply = { marker: { stack: "assets", env: "prod" }, ownershipTags: {}, at: "t3" };
|
|
70
|
+
writeCarveManifest(dir, m);
|
|
71
|
+
|
|
72
|
+
const res = carveStatus({ from: dir });
|
|
73
|
+
expect(res.carves![0].stage).toBe("applied");
|
|
74
|
+
expect(res.carves![0].at).toEqual({ emit: "t1", bridge: "t2", apply: "t3" });
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("reports unreadable manifest files instead of failing or silently dropping them", () => {
|
|
79
|
+
withDir((dir) => {
|
|
80
|
+
writeCarveManifest(dir, manifest("aws_s3_bucket.assets"));
|
|
81
|
+
writeFileSync(join(dir, "bad.carve.json"), "not json");
|
|
82
|
+
|
|
83
|
+
const res = carveStatus({ from: dir });
|
|
84
|
+
expect(res.ok).toBe(true);
|
|
85
|
+
expect(res.carves).toHaveLength(1);
|
|
86
|
+
expect(res.unreadable).toEqual(["bad.carve.json"]);
|
|
87
|
+
expect(formatCarveStatus(res)).toContain("bad.carve.json");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("skips node_modules and .git", () => {
|
|
92
|
+
withDir((dir) => {
|
|
93
|
+
mkdirSync(join(dir, "node_modules", "dep"), { recursive: true });
|
|
94
|
+
writeCarveManifest(join(dir, "node_modules", "dep"), manifest("aws_s3_bucket.vendored"));
|
|
95
|
+
const res = carveStatus({ from: dir });
|
|
96
|
+
expect(res.carves).toHaveLength(0);
|
|
97
|
+
expect(formatCarveStatus(res)).toContain("No carve manifests");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("errors on a non-directory root", () => {
|
|
102
|
+
const res = carveStatus({ from: "/definitely/not/a/dir" });
|
|
103
|
+
expect(res.ok).toBe(false);
|
|
104
|
+
expect(res.error).toContain("Not a directory");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("--json payload carries from, carves, and unreadable only when present", () => {
|
|
108
|
+
withDir((dir) => {
|
|
109
|
+
writeCarveManifest(dir, manifest("aws_s3_bucket.assets"));
|
|
110
|
+
const res = carveStatus({ from: dir });
|
|
111
|
+
const json = carveStatusJson(res);
|
|
112
|
+
expect(Object.keys(json)).toEqual(["from", "carves"]);
|
|
113
|
+
expect((json.carves as unknown[]).length).toBe(1);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant carve status` — the read over a tree of carve manifests (#2038).
|
|
3
|
+
*
|
|
4
|
+
* emit → bridge → apply each persist their step into `<target>.carve.json`
|
|
5
|
+
* (./../../terraform/manifest.ts), but until now nothing read that state back
|
|
6
|
+
* over more than one output dir at a time: a renderer wanting "what carves
|
|
7
|
+
* exist under this project, and how far along is each" had to walk the tree
|
|
8
|
+
* itself and guess at depth. This makes the walk a contract: recurse from
|
|
9
|
+
* `--from` (default: cwd), read every manifest the way bridge/apply's own
|
|
10
|
+
* `resolveCarveManifest` reads one, and answer with each manifest's target,
|
|
11
|
+
* stage, and path. Read-only — it writes nothing and touches no live resource.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readdirSync, statSync } from "fs";
|
|
15
|
+
import { join, relative, resolve } from "path";
|
|
16
|
+
import {
|
|
17
|
+
CARVE_MANIFEST_SUFFIX,
|
|
18
|
+
readCarveManifest,
|
|
19
|
+
type CarveManifest,
|
|
20
|
+
} from "../../terraform/manifest";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* How far along a carve is — the highest recorded step. `planned` is a
|
|
24
|
+
* manifest holding only the boundary classification (written, but `carve
|
|
25
|
+
* emit` recorded no emit section — e.g. a hand-seeded or partially-run
|
|
26
|
+
* carve).
|
|
27
|
+
*/
|
|
28
|
+
export type CarveStage = "planned" | "emitted" | "bridged" | "applied";
|
|
29
|
+
|
|
30
|
+
export interface CarveStatusRow {
|
|
31
|
+
/** Manifest path, relative to the walk root. */
|
|
32
|
+
path: string;
|
|
33
|
+
/** Terraform address of the carved resource. */
|
|
34
|
+
target: string;
|
|
35
|
+
/** Terraform resource type, when the manifest recorded one. */
|
|
36
|
+
tfType?: string;
|
|
37
|
+
/** The highest recorded step. */
|
|
38
|
+
stage: CarveStage;
|
|
39
|
+
/** ISO-8601 timestamp of each recorded step. */
|
|
40
|
+
at: { emit?: string; bridge?: string; apply?: string };
|
|
41
|
+
/** Emitted chant source file path(s), exactly as the manifest records them. */
|
|
42
|
+
emittedFiles?: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CarveStatusOptions {
|
|
46
|
+
/** Root of the walk (`--from`). Default: the current directory. */
|
|
47
|
+
from?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CarveStatusResult {
|
|
51
|
+
ok: boolean;
|
|
52
|
+
error?: string;
|
|
53
|
+
/** The resolved walk root. */
|
|
54
|
+
from?: string;
|
|
55
|
+
/** One row per readable manifest, sorted by path. */
|
|
56
|
+
carves?: CarveStatusRow[];
|
|
57
|
+
/** Manifest-suffixed files that did not read as a manifest (wrong version, malformed). */
|
|
58
|
+
unreadable?: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Directories that never hold a carve output dir and make the walk expensive
|
|
63
|
+
* or wrong: dependency trees, VCS internals, build output.
|
|
64
|
+
*/
|
|
65
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", ".chant"]);
|
|
66
|
+
|
|
67
|
+
/** Every `*.carve.json` under `root`, found by a bounded recursive walk. */
|
|
68
|
+
function findManifests(root: string): string[] {
|
|
69
|
+
const found: string[] = [];
|
|
70
|
+
const walk = (dir: string): void => {
|
|
71
|
+
let entries: string[];
|
|
72
|
+
try {
|
|
73
|
+
entries = readdirSync(dir);
|
|
74
|
+
} catch {
|
|
75
|
+
return; // unreadable dir — skip rather than fail the whole status
|
|
76
|
+
}
|
|
77
|
+
for (const entry of entries.sort()) {
|
|
78
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
79
|
+
const path = join(dir, entry);
|
|
80
|
+
let stat;
|
|
81
|
+
try {
|
|
82
|
+
stat = statSync(path);
|
|
83
|
+
} catch {
|
|
84
|
+
continue; // dangling symlink
|
|
85
|
+
}
|
|
86
|
+
if (stat.isDirectory()) walk(path);
|
|
87
|
+
else if (entry.endsWith(CARVE_MANIFEST_SUFFIX)) found.push(path);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
walk(root);
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function stageOf(manifest: CarveManifest): CarveStage {
|
|
95
|
+
if (manifest.apply) return "applied";
|
|
96
|
+
if (manifest.bridge) return "bridged";
|
|
97
|
+
if (manifest.emit) return "emitted";
|
|
98
|
+
return "planned";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function carveStatus(opts: CarveStatusOptions = {}): CarveStatusResult {
|
|
102
|
+
const root = resolve(opts.from ?? process.cwd());
|
|
103
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
104
|
+
return { ok: false, error: `Not a directory: ${root}` };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const carves: CarveStatusRow[] = [];
|
|
108
|
+
const unreadable: string[] = [];
|
|
109
|
+
for (const path of findManifests(root)) {
|
|
110
|
+
const rel = relative(root, path);
|
|
111
|
+
const manifest = readCarveManifest(path);
|
|
112
|
+
if (!manifest) {
|
|
113
|
+
unreadable.push(rel);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
carves.push({
|
|
117
|
+
path: rel,
|
|
118
|
+
target: manifest.target,
|
|
119
|
+
...(manifest.tfType ? { tfType: manifest.tfType } : {}),
|
|
120
|
+
stage: stageOf(manifest),
|
|
121
|
+
at: {
|
|
122
|
+
...(manifest.emit?.at ? { emit: manifest.emit.at } : {}),
|
|
123
|
+
...(manifest.bridge?.at ? { bridge: manifest.bridge.at } : {}),
|
|
124
|
+
...(manifest.apply?.at ? { apply: manifest.apply.at } : {}),
|
|
125
|
+
},
|
|
126
|
+
...(manifest.emit ? { emittedFiles: manifest.emit.files } : {}),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { ok: true, from: root, carves, ...(unreadable.length > 0 ? { unreadable } : {}) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The JSON payload `--json` emits — everything but `ok`. */
|
|
134
|
+
export function carveStatusJson(result: CarveStatusResult): Record<string, unknown> {
|
|
135
|
+
return {
|
|
136
|
+
from: result.from,
|
|
137
|
+
carves: result.carves ?? [],
|
|
138
|
+
...(result.unreadable ? { unreadable: result.unreadable } : {}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function formatCarveStatus(result: CarveStatusResult): string {
|
|
143
|
+
const carves = result.carves ?? [];
|
|
144
|
+
if (carves.length === 0) {
|
|
145
|
+
return `No carve manifests under ${result.from} — run \`chant carve emit\` first.`;
|
|
146
|
+
}
|
|
147
|
+
const L: string[] = [];
|
|
148
|
+
L.push(`${carves.length} carve${carves.length === 1 ? "" : "s"} under ${result.from}`);
|
|
149
|
+
L.push("");
|
|
150
|
+
for (const row of carves) {
|
|
151
|
+
const when = row.at.apply ?? row.at.bridge ?? row.at.emit;
|
|
152
|
+
L.push(` ${row.target} [${row.stage}${when ? ` @ ${when}` : ""}]`);
|
|
153
|
+
L.push(` manifest: ${row.path}`);
|
|
154
|
+
if (row.emittedFiles && row.emittedFiles.length > 0) {
|
|
155
|
+
L.push(` emitted: ${row.emittedFiles.join(", ")}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (result.unreadable && result.unreadable.length > 0) {
|
|
159
|
+
L.push("");
|
|
160
|
+
L.push(` Unreadable (malformed or unknown version): ${result.unreadable.join(", ")}`);
|
|
161
|
+
}
|
|
162
|
+
return L.join("\n");
|
|
163
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { carveStatus, carveStatusJson, formatCarveStatus } from "../commands/carve-status";
|
|
2
|
+
import { formatError } from "../format";
|
|
3
|
+
import type { CommandContext } from "../registry";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `chant carve status [--from <dir>] [--json]` — the read-only status read
|
|
7
|
+
* over a tree of carve manifests (#2038). Walks from `--from` (default: cwd),
|
|
8
|
+
* reports every manifest's target, stage (planned/emitted/bridged/applied)
|
|
9
|
+
* and path. No plugins, no project, no cloud call.
|
|
10
|
+
*/
|
|
11
|
+
export async function runCarveStatus(ctx: CommandContext): Promise<number> {
|
|
12
|
+
const { args } = ctx;
|
|
13
|
+
|
|
14
|
+
const result = carveStatus({ from: args.migrateFrom /* `--from <dir>` */ });
|
|
15
|
+
|
|
16
|
+
if (!result.ok) {
|
|
17
|
+
console.error(formatError({
|
|
18
|
+
message: result.error ?? "carve status failed",
|
|
19
|
+
hint: "chant carve status --from ./tf --json",
|
|
20
|
+
}));
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (args.json) {
|
|
25
|
+
console.log(JSON.stringify(carveStatusJson(result), null, 2));
|
|
26
|
+
} else {
|
|
27
|
+
console.log(formatCarveStatus(result));
|
|
28
|
+
}
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
readReleaseLedger,
|
|
35
35
|
listReleaseEnvironments,
|
|
36
36
|
latestPerComponent,
|
|
37
|
+
resolveRunId,
|
|
37
38
|
InvalidReleaseRecordError,
|
|
38
39
|
} from "../../lifecycle/release-ledger";
|
|
39
40
|
import { reconcileStatus, liveEvidenceFromChangeSet, compareAcrossEnvironments, mergeLiveEvidence, type LiveComponentEvidence } from "../../lifecycle/status";
|
|
@@ -94,7 +95,9 @@ export async function runComponentsReleaseRecord(ctx: CommandContext): Promise<n
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
const gitSha = args.gitSha ?? (await getHeadCommit().catch(() => undefined));
|
|
97
|
-
|
|
98
|
+
// Id + origin resolved together from the same environment (#2045), so the
|
|
99
|
+
// recorded runId always says which id space it lives in and how to reach it.
|
|
100
|
+
const { runId, runOrigin } = resolveRunId(args.runId);
|
|
98
101
|
const actor = args.actor ?? process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_LOGIN ?? process.env.USER;
|
|
99
102
|
// Who approved a gated change (#1035). Optional — omitted for an ungated
|
|
100
103
|
// change, and never defaulted to `actor`, since recording the approver as the
|
|
@@ -122,6 +125,7 @@ export async function runComponentsReleaseRecord(ctx: CommandContext): Promise<n
|
|
|
122
125
|
digest,
|
|
123
126
|
gitSha,
|
|
124
127
|
runId,
|
|
128
|
+
...(runOrigin ? { runOrigin } : {}),
|
|
125
129
|
timestamp,
|
|
126
130
|
actor,
|
|
127
131
|
...(approver ? { approver } : {}),
|
|
@@ -635,6 +635,9 @@ async function buildPipelineProjection(
|
|
|
635
635
|
success: true,
|
|
636
636
|
pipeline: {
|
|
637
637
|
provider: lexicon,
|
|
638
|
+
// The environment the projected pipeline deploys (#2046) — the
|
|
639
|
+
// generator's own resolution, carried rather than re-derived.
|
|
640
|
+
...(result.env ? { env: result.env } : {}),
|
|
638
641
|
stages: result.stages ?? [],
|
|
639
642
|
nodes: jobs.map((j) => ({ id: j.jobName, kind: "CIJob" as const, component: j.component, stage: j.stage })),
|
|
640
643
|
edges: jobs.flatMap((j) => j.needs.map((dep) => ({ from: j.jobName, to: dep, kind: "needs" as const }))),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { extractStepRecords, countActivities, queryGateState } from "./op-progress";
|
|
2
|
+
import { extractStepRecords, extractIndexedStepRecords, stepRecordEmitter, countActivities, queryGateState } from "./op-progress";
|
|
3
|
+
import type { StepRecord } from "../../op/local-executor";
|
|
3
4
|
import type { OpConfig } from "../../op/types";
|
|
4
5
|
import type { EffectReceiptRef } from "../../op/receipt-store";
|
|
5
6
|
import type { WorkflowHistoryRaw, HistoryEvent } from "./run-client";
|
|
@@ -171,6 +172,71 @@ describe("extractStepRecords", () => {
|
|
|
171
172
|
});
|
|
172
173
|
});
|
|
173
174
|
|
|
175
|
+
describe("stepRecordEmitter", () => {
|
|
176
|
+
// chant #2032. Declared order is main phases then `onFailure` reversed, so an
|
|
177
|
+
// unreached main phase sits *before* an `onFailure` phase that ran. The live
|
|
178
|
+
// poll therefore returns [Main(fail), Comp(ok)] and the terminal `final: true`
|
|
179
|
+
// pass returns [Main(fail), Never(skipped), Comp(ok)] — an insertion, not an
|
|
180
|
+
// append. The old emitted-count watermark sliced at 2 and re-shipped Comp
|
|
181
|
+
// while dropping Never entirely. The #1676 coverage above misses this because
|
|
182
|
+
// in those configs the skipped record lands last, where insert and append are
|
|
183
|
+
// the same operation.
|
|
184
|
+
const config: OpConfig = {
|
|
185
|
+
name: "op", overview: "o",
|
|
186
|
+
phases: [
|
|
187
|
+
{ name: "Main", steps: [{ kind: "activity", fn: "boom" }] },
|
|
188
|
+
{ name: "Never", steps: [{ kind: "activity", fn: "never" }] },
|
|
189
|
+
],
|
|
190
|
+
onFailure: [{ name: "Comp", steps: [{ kind: "activity", fn: "comp" }] }],
|
|
191
|
+
};
|
|
192
|
+
// Main fails, Never is never reached, compensation runs and succeeds.
|
|
193
|
+
const history: WorkflowHistoryRaw = {
|
|
194
|
+
events: [
|
|
195
|
+
scheduled("1", "boom", sec(0)),
|
|
196
|
+
failed("1", sec(1), "boom"),
|
|
197
|
+
scheduled("2", "comp", sec(2)),
|
|
198
|
+
completed("2", sec(3)),
|
|
199
|
+
],
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const MAIN: StepRecord = { phase: "Main", fn: "boom", status: "fail", durationMs: 1000, error: "boom" };
|
|
203
|
+
const NEVER: StepRecord = { phase: "Never", fn: "never", status: "skipped", durationMs: 0 };
|
|
204
|
+
const COMP: StepRecord = { phase: "Comp", fn: "comp", status: "ok", durationMs: 1000 };
|
|
205
|
+
|
|
206
|
+
test("the final pass inserts rather than appends, so list position is not an identity", () => {
|
|
207
|
+
expect(extractIndexedStepRecords(config, history)).toEqual([
|
|
208
|
+
{ index: 0, record: MAIN },
|
|
209
|
+
{ index: 2, record: COMP },
|
|
210
|
+
]);
|
|
211
|
+
expect(extractIndexedStepRecords(config, history, { final: true })).toEqual([
|
|
212
|
+
{ index: 0, record: MAIN },
|
|
213
|
+
{ index: 1, record: NEVER },
|
|
214
|
+
{ index: 2, record: COMP },
|
|
215
|
+
]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("each record ships exactly once across the poll passes and the final pass", () => {
|
|
219
|
+
const emitted: StepRecord[] = [];
|
|
220
|
+
const emit = stepRecordEmitter((r) => emitted.push(r));
|
|
221
|
+
|
|
222
|
+
emit(extractIndexedStepRecords(config, history)); // a poll while the workflow closes
|
|
223
|
+
emit(extractIndexedStepRecords(config, history)); // an identical re-poll adds nothing
|
|
224
|
+
emit(extractIndexedStepRecords(config, history, { final: true }));
|
|
225
|
+
|
|
226
|
+
expect(emitted).toEqual([MAIN, COMP, NEVER]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("an empty first poll does not shift what a later pass ships", () => {
|
|
230
|
+
const emitted: StepRecord[] = [];
|
|
231
|
+
const emit = stepRecordEmitter((r) => emitted.push(r));
|
|
232
|
+
|
|
233
|
+
emit(extractIndexedStepRecords(config, { events: [] }));
|
|
234
|
+
expect(emitted).toEqual([]);
|
|
235
|
+
emit(extractIndexedStepRecords(config, history, { final: true }));
|
|
236
|
+
expect(emitted).toEqual([MAIN, NEVER, COMP]);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
174
240
|
describe("countActivities", () => {
|
|
175
241
|
test("counts scheduled and completed events", () => {
|
|
176
242
|
const history: WorkflowHistoryRaw = {
|
|
@@ -113,34 +113,55 @@ function terminalOutcomes(events: HistoryEvent[]): Map<string, TerminalOutcome>
|
|
|
113
113
|
return out;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* A step record paired with the position of the declared step it came from.
|
|
118
|
+
*
|
|
119
|
+
* `index` is an offset into `declaredStepOrder(config)`, which depends only
|
|
120
|
+
* on the Op config — not on the history — so it is the record's identity
|
|
121
|
+
* and is stable across polls. The record *list* is not: a mid-run poll omits
|
|
122
|
+
* every step that hasn't settled yet, and the terminal `final: true` pass
|
|
123
|
+
* inserts a `"skipped"` record at each such step's declared position, which
|
|
124
|
+
* shifts every later record's list offset (chant #2032). Anything tracking
|
|
125
|
+
* what it has already seen must key on `index`, never on how many records
|
|
126
|
+
* the previous call returned.
|
|
127
|
+
*/
|
|
128
|
+
export interface IndexedStepRecord {
|
|
129
|
+
index: number;
|
|
130
|
+
record: StepRecord;
|
|
131
|
+
}
|
|
132
|
+
|
|
116
133
|
/**
|
|
117
134
|
* Reconstruct per-step progress records from a workflow's history, in
|
|
118
|
-
* declared order
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
135
|
+
* declared order, each tagged with its declared-step `index`. Only settled
|
|
136
|
+
* steps (matched to a scheduled event that has since completed or failed)
|
|
137
|
+
* produce a record — a step that hasn't been scheduled yet simply isn't in
|
|
138
|
+
* the result. Pass `final: true` once the workflow has reached a terminal
|
|
122
139
|
* status to also emit a `"skipped"` record for every declared step that
|
|
123
140
|
* never got a matching scheduled event (an untaken effect branch, or an
|
|
124
141
|
* `onFailure` phase on a run that succeeded) — never before, or a
|
|
125
142
|
* still-running Op would show its not-yet-reached steps as skipped.
|
|
126
143
|
*/
|
|
127
|
-
export function
|
|
144
|
+
export function extractIndexedStepRecords(
|
|
128
145
|
config: OpConfig,
|
|
129
146
|
history: WorkflowHistoryRaw,
|
|
130
147
|
opts: { final?: boolean } = {},
|
|
131
|
-
):
|
|
148
|
+
): IndexedStepRecord[] {
|
|
132
149
|
const events = history.events ?? [];
|
|
133
150
|
const queues = scheduledQueues(events);
|
|
134
151
|
const terminals = terminalOutcomes(events);
|
|
135
152
|
const nextIndex = new Map<string, number>();
|
|
136
153
|
|
|
137
|
-
const records:
|
|
138
|
-
|
|
154
|
+
const records: IndexedStepRecord[] = [];
|
|
155
|
+
const declared = declaredStepOrder(config);
|
|
156
|
+
for (let index = 0; index < declared.length; index++) {
|
|
157
|
+
const step = declared[index];
|
|
139
158
|
const queue = queues.get(step.fn);
|
|
140
159
|
const idx = nextIndex.get(step.fn) ?? 0;
|
|
141
160
|
const entry = queue?.[idx];
|
|
142
161
|
if (!entry) {
|
|
143
|
-
if (opts.final)
|
|
162
|
+
if (opts.final) {
|
|
163
|
+
records.push({ index, record: { phase: step.phase, fn: step.fn, status: "skipped", durationMs: 0 } });
|
|
164
|
+
}
|
|
144
165
|
continue;
|
|
145
166
|
}
|
|
146
167
|
nextIndex.set(step.fn, idx + 1);
|
|
@@ -151,16 +172,51 @@ export function extractStepRecords(
|
|
|
151
172
|
const durationMs =
|
|
152
173
|
entry.scheduledAt && terminal.at ? terminal.at.getTime() - entry.scheduledAt.getTime() : 0;
|
|
153
174
|
records.push({
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
175
|
+
index,
|
|
176
|
+
record: {
|
|
177
|
+
phase: step.phase,
|
|
178
|
+
fn: step.fn,
|
|
179
|
+
status: terminal.status,
|
|
180
|
+
durationMs,
|
|
181
|
+
...(terminal.error ? { error: terminal.error } : {}),
|
|
182
|
+
},
|
|
159
183
|
});
|
|
160
184
|
}
|
|
161
185
|
return records;
|
|
162
186
|
}
|
|
163
187
|
|
|
188
|
+
/** `extractIndexedStepRecords` without the indices — the plain declared-order record list. */
|
|
189
|
+
export function extractStepRecords(
|
|
190
|
+
config: OpConfig,
|
|
191
|
+
history: WorkflowHistoryRaw,
|
|
192
|
+
opts: { final?: boolean } = {},
|
|
193
|
+
): StepRecord[] {
|
|
194
|
+
return extractIndexedStepRecords(config, history, opts).map((r) => r.record);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A sink that forwards each declared step's record exactly once, however many
|
|
199
|
+
* times the poll loop re-derives the list.
|
|
200
|
+
*
|
|
201
|
+
* `--progress-json` calls this per poll with the whole list so far, plus once
|
|
202
|
+
* more with the terminal `final: true` list. Deduplicating by declared-step
|
|
203
|
+
* `index` is what makes that safe: an emitted-count watermark assumed the list
|
|
204
|
+
* was append-only, and the `final` pass's inserted `"skipped"` records break
|
|
205
|
+
* that assumption — with an unreached phase declared before an `onFailure`
|
|
206
|
+
* phase that ran, the insertion shifted the tail, so the last record shipped
|
|
207
|
+
* twice and the skipped one never shipped at all (chant #2032).
|
|
208
|
+
*/
|
|
209
|
+
export function stepRecordEmitter(emit: (record: StepRecord) => void): (records: IndexedStepRecord[]) => void {
|
|
210
|
+
const seen = new Set<number>();
|
|
211
|
+
return (records) => {
|
|
212
|
+
for (const { index, record } of records) {
|
|
213
|
+
if (seen.has(index)) continue;
|
|
214
|
+
seen.add(index);
|
|
215
|
+
emit(record);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
164
220
|
/** `{completed, scheduled}` activity counts for the summary line `chant run status`/`op-status` show alongside the full record list. */
|
|
165
221
|
export function countActivities(history: WorkflowHistoryRaw): { completed: number; scheduled: number } {
|
|
166
222
|
const events = history.events ?? [];
|