@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
package/src/cli/main.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { runCarveAdvise, runCarveUnknown } from "./handlers/carve";
|
|
|
23
23
|
import { runCarveEmit } from "./handlers/carve-emit";
|
|
24
24
|
import { runCarveBridge } from "./handlers/carve-bridge";
|
|
25
25
|
import { runCarveApply } from "./handlers/carve-apply";
|
|
26
|
+
import { runCarveStatus } from "./handlers/carve-status";
|
|
26
27
|
import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleTeardown, runLifecycleWhoami, runLifecycleUnknown } from "./handlers/lifecycle";
|
|
27
28
|
import { runComponentsStatus, runComponentsReleaseRecord, runComponentsExport, runComponentsUnknown } from "./handlers/components";
|
|
28
29
|
import { runScenarioCheck, runScenarioUnknown } from "./handlers/scenario";
|
|
@@ -30,7 +31,7 @@ import { runGraph } from "./handlers/graph";
|
|
|
30
31
|
import { runExplain } from "./handlers/explain";
|
|
31
32
|
import { runSearch } from "./handlers/search";
|
|
32
33
|
import { runOp, runOpList, runOpStatus, runOpSignal, runOpCancel, runOpLog } from "./handlers/run";
|
|
33
|
-
import { runOperator, runOperatorStatus, runApprove } from "./handlers/operator";
|
|
34
|
+
import { runOperator, runOperatorStatus, runOperatorLog, runApprove } from "./handlers/operator";
|
|
34
35
|
import { runEmulator } from "./handlers/emulator";
|
|
35
36
|
import { splitJoinedFlags, dispatchCommandGroup, collectCommandGroups, formatCommandGroupsHelp, type CommandGroup } from "./command-group";
|
|
36
37
|
import type { LexiconPlugin } from "../lexicon";
|
|
@@ -388,6 +389,16 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
388
389
|
result.once = true;
|
|
389
390
|
} else if (arg === "--note") {
|
|
390
391
|
result.note = args[++i];
|
|
392
|
+
} else if (arg === "--url") {
|
|
393
|
+
result.url = args[++i];
|
|
394
|
+
} else if (arg === "--op") {
|
|
395
|
+
result.op = args[++i];
|
|
396
|
+
} else if (arg === "--since") {
|
|
397
|
+
result.since = args[++i];
|
|
398
|
+
} else if (arg === "--limit") {
|
|
399
|
+
// Parsed here, validated by the handler — `parseArgs` reports shape, not
|
|
400
|
+
// policy, the same split every other value flag here uses.
|
|
401
|
+
result.limit = Number(args[++i]);
|
|
391
402
|
} else if (arg.startsWith("--")) {
|
|
392
403
|
// chant #1127 — every recognized flag is matched above; anything left
|
|
393
404
|
// starting with `--` is unrecognized, whether it arrived bare
|
|
@@ -488,6 +499,10 @@ Commands:
|
|
|
488
499
|
--env <env> is optional with a carve manifest present.
|
|
489
500
|
--write-source stamps the ownership marker
|
|
490
501
|
into the emitted chant source.
|
|
502
|
+
carve status Status read over a tree of carve manifests: every
|
|
503
|
+
[--from <dir>] *.carve.json under --from (default: cwd) with
|
|
504
|
+
(--json) its target, stage (planned/emitted/bridged/
|
|
505
|
+
applied) and path. Read-only.
|
|
491
506
|
|
|
492
507
|
Ops:
|
|
493
508
|
run <name> Start an Op workflow (spawns worker + submits to Temporal)
|
|
@@ -524,11 +539,21 @@ Ops:
|
|
|
524
539
|
ConvergeOp, read from the chant/lifecycle orphan
|
|
525
540
|
branch alone — no daemon needs to be running
|
|
526
541
|
(--env <env>, --json)
|
|
542
|
+
operator log Converge tick history and the gate resolutions
|
|
543
|
+
against it, merged into one timestamp-ordered
|
|
544
|
+
timeline, from the same orphan branch (--env <env>,
|
|
545
|
+
--op <name>, --since <iso>, --limit <n>, --json).
|
|
546
|
+
--json also carries the count of ledger lines that
|
|
547
|
+
were unreadable, so a short timeline is never
|
|
548
|
+
silently short
|
|
527
549
|
approve <op> <gate> Record a gate's out-of-band resolution fact
|
|
528
|
-
(--actor <name>, --note <text>) — the
|
|
529
|
-
counterpart to a converge tick's
|
|
530
|
-
outcome; see the pending-gates list in
|
|
531
|
-
status.
|
|
550
|
+
(--actor <name>, --note <text>, --url <url>) — the
|
|
551
|
+
durable counterpart to a converge tick's
|
|
552
|
+
gate-as-fact outcome; see the pending-gates list in
|
|
553
|
+
operator status. --url is the PR/MR the resolution
|
|
554
|
+
happened at, recorded typed rather than as free text,
|
|
555
|
+
and defaults to the PR/MR of the surrounding CI job.
|
|
556
|
+
Does not itself unblock the gated op's local
|
|
532
557
|
dispatch (re-run --temporal, or merge its PR)
|
|
533
558
|
|
|
534
559
|
graph Show Op dependency graph (--stacks for cross-stack order,
|
|
@@ -852,6 +877,9 @@ const registry: CommandDef[] = [
|
|
|
852
877
|
// Apply graduation (#197): ownership marker + finalized apply runbook.
|
|
853
878
|
// BYOL-honest — no cloud call; --write saves the graduation doc.
|
|
854
879
|
{ name: "carve apply", handler: runCarveApply },
|
|
880
|
+
// Status read over a tree of carve manifests (#2038): the contract a
|
|
881
|
+
// renderer replaces its own walk-and-guess discovery with. Read-only.
|
|
882
|
+
{ name: "carve status", handler: runCarveStatus },
|
|
855
883
|
{ name: "init", handler: runInit },
|
|
856
884
|
{ name: "init lexicon", handler: runInitLexicon },
|
|
857
885
|
{ name: "update", handler: runUpdate },
|
|
@@ -875,6 +903,7 @@ const registry: CommandDef[] = [
|
|
|
875
903
|
{ name: "run", handler: runOp },
|
|
876
904
|
|
|
877
905
|
{ name: "operator status", handler: runOperatorStatus },
|
|
906
|
+
{ name: "operator log", handler: runOperatorLog },
|
|
878
907
|
{ name: "operator", handler: runOperator },
|
|
879
908
|
{ name: "approve", handler: runApprove },
|
|
880
909
|
|
package/src/cli/registry.ts
CHANGED
|
@@ -285,8 +285,16 @@ export interface ParsedArgs {
|
|
|
285
285
|
leaseTtl?: string;
|
|
286
286
|
/** `chant operator --once` (#1485) — run a single round and exit, instead of looping until Ctrl-C. Also the offline test/cron-invoker story. */
|
|
287
287
|
once?: boolean;
|
|
288
|
-
/** `chant approve <op> <gate> --note <text>` (#1485) — optional free-text
|
|
288
|
+
/** `chant approve <op> <gate> --note <text>` (#1485) — optional free-text prose recorded on the gate-resolution fact. The PR link belongs in `--url` since #2028; this is for everything that isn't the link. */
|
|
289
289
|
note?: string;
|
|
290
|
+
/** `chant operator log --op <name>` (#2029) — restrict the tick history to one ConvergeOp by name. Omitted, every discovered ConvergeOp's ticks are merged into one timeline. */
|
|
291
|
+
op?: string;
|
|
292
|
+
/** `chant operator log --since <iso>` (#2029) — only entries at or after this ISO-8601 instant. */
|
|
293
|
+
since?: string;
|
|
294
|
+
/** `chant operator log --limit <n>` (#2029) — keep only the newest n entries (still printed oldest-first). */
|
|
295
|
+
limit?: number;
|
|
296
|
+
/** `chant approve <op> <gate> --url <url>` (#2028) — the address this resolution happened at (the PR/MR that carried the change), recorded typed on the gate-resolution fact so a reader is not sniffing `--note` for something link-shaped. Defaults to the PR/MR the surrounding CI job is for, when there is one. Must be an absolute http/https URL. */
|
|
297
|
+
url?: string;
|
|
290
298
|
}
|
|
291
299
|
|
|
292
300
|
/**
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* env vars, timestamp is a real `new Date()` taken at record time).
|
|
31
31
|
*/
|
|
32
32
|
import { getHeadCommit, pushLifecycle, StaleLifecycleBranchError } from "../lifecycle/git";
|
|
33
|
-
import { appendReleaseRecord, InvalidReleaseRecordError, type ReleaseRecord } from "../lifecycle/release-ledger";
|
|
33
|
+
import { appendReleaseRecord, InvalidReleaseRecordError, type ReleaseRecord, type RunOrigin } from "../lifecycle/release-ledger";
|
|
34
34
|
import type { DriverStepRecord } from "./driver";
|
|
35
35
|
|
|
36
36
|
/**
|
|
@@ -117,6 +117,8 @@ export interface AutoReleaseRunInfo {
|
|
|
117
117
|
digest?: string;
|
|
118
118
|
/** Orchestrator run identifier — a Temporal `runId`, or a locally generated id for the local executor (mirrors `runComponentsReleaseRecord`'s `--run-id` default). */
|
|
119
119
|
runId: string;
|
|
120
|
+
/** The id space `runId` lives in (#2045) — `{ forge: "op" }` for a Temporal run, `{ forge: "local" }` for a local-executor mint. Recorded verbatim as the release record's `runOrigin`. */
|
|
121
|
+
runOrigin?: RunOrigin;
|
|
120
122
|
/** The bypassed capability-profile divergences, when the caller deliberately overrode a deploy-time profile assertion (chant #1244) — recorded verbatim as the release record's `profileOverride`. */
|
|
121
123
|
profileOverride?: string;
|
|
122
124
|
/** The deploy's input-side digest, when `digest` is a rendered-content identity (a pinned helm deploy, chant #1242) — recorded as the release record's `inputDigest` so ledger queries can still join on inputs across clusters whose bytes legitimately differ. */
|
|
@@ -204,6 +206,7 @@ export async function maybeRecordAutoRelease(
|
|
|
204
206
|
digest,
|
|
205
207
|
gitSha,
|
|
206
208
|
runId: run.runId,
|
|
209
|
+
...(run.runOrigin ? { runOrigin: run.runOrigin } : {}),
|
|
207
210
|
timestamp,
|
|
208
211
|
actor,
|
|
209
212
|
...(run.profileOverride ? { profileOverride: run.profileOverride } : {}),
|
|
@@ -218,6 +218,8 @@ export interface GenerateComponentsResult {
|
|
|
218
218
|
stages?: string[];
|
|
219
219
|
/** Every generated job, for a machine-readable view (`--format json`). */
|
|
220
220
|
jobs?: Array<{ jobName: string; component: string; stage: string; needs: string[] }>;
|
|
221
|
+
/** The environment the pipeline deploys — the generator's own resolution (`ComponentPipelineResult.env`, #2046). */
|
|
222
|
+
env?: string;
|
|
221
223
|
error?: string;
|
|
222
224
|
/** This invocation's resolved build-time parameters (chant #1108) — the generate-mode counterpart of `../cli/commands/build.ts`'s `BuildResult.buildParams`. Empty when the project declares/supplies none. */
|
|
223
225
|
buildParams?: BuildParamProvenance[];
|
|
@@ -287,8 +289,8 @@ export async function generateComponentsPipeline(
|
|
|
287
289
|
}));
|
|
288
290
|
|
|
289
291
|
try {
|
|
290
|
-
const { yaml, stages, jobs } = plugin.generateComponentPipeline(driverComponents, options);
|
|
291
|
-
return { success: true, yaml, stages, jobs, buildParams };
|
|
292
|
+
const { yaml, stages, jobs, env } = plugin.generateComponentPipeline(driverComponents, options);
|
|
293
|
+
return { success: true, yaml, stages, jobs, ...(env ? { env } : {}), buildParams };
|
|
292
294
|
} catch (err) {
|
|
293
295
|
if (err instanceof UnknownDependencyError || err instanceof DependencyCycleError) {
|
|
294
296
|
return { success: false, error: err.message };
|
package/src/graph-ir.ts
CHANGED
|
@@ -190,6 +190,13 @@ export interface IRPipelineEdge {
|
|
|
190
190
|
export interface IRPipeline {
|
|
191
191
|
/** The CI-provider lexicon that produced this projection (e.g. "gitlab"). */
|
|
192
192
|
provider: string;
|
|
193
|
+
/**
|
|
194
|
+
* The environment the projected pipeline deploys (#2046) — copied from the
|
|
195
|
+
* generator's own resolution (`ComponentPipelineResult.env`), so the IR
|
|
196
|
+
* carries the identity instead of leaving a consumer to lex it out of a
|
|
197
|
+
* job's run line.
|
|
198
|
+
*/
|
|
199
|
+
env?: string;
|
|
193
200
|
/** Wave-ordered stage names — 1:1 with the component graph's `groups.byWave` keys. */
|
|
194
201
|
stages: string[];
|
|
195
202
|
nodes: IRPipelineNode[];
|
package/src/lexicon.ts
CHANGED
|
@@ -461,6 +461,14 @@ export interface ComponentPipelineResult {
|
|
|
461
461
|
stages: string[];
|
|
462
462
|
/** Every generated job, in emit order. */
|
|
463
463
|
jobs: ComponentPipelineJob[];
|
|
464
|
+
/**
|
|
465
|
+
* The environment this pipeline deploys — `options.env` with the
|
|
466
|
+
* generator's default applied (#2046). Generators set it so a consumer
|
|
467
|
+
* (the graph IR's `pipeline` projection, behold) can join a pipeline to
|
|
468
|
+
* its environment without lexing a job's `run:`/`script:` line, where the
|
|
469
|
+
* value otherwise survives only as a shell argument.
|
|
470
|
+
*/
|
|
471
|
+
env?: string;
|
|
464
472
|
}
|
|
465
473
|
|
|
466
474
|
/**
|
|
@@ -7,9 +7,11 @@ import {
|
|
|
7
7
|
appendConvergeRecord,
|
|
8
8
|
readConvergeLedger,
|
|
9
9
|
consecutiveRuleFires,
|
|
10
|
+
componentVerdicts,
|
|
10
11
|
type ConvergeTickRecordInput,
|
|
11
12
|
type ConvergeTickRecord,
|
|
12
13
|
} from "./converge-ledger";
|
|
14
|
+
import type { ComponentStatusRow } from "./status";
|
|
13
15
|
|
|
14
16
|
function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
|
|
15
17
|
const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
@@ -171,6 +173,114 @@ describe("converge-ledger", () => {
|
|
|
171
173
|
});
|
|
172
174
|
});
|
|
173
175
|
|
|
176
|
+
// ── Per-component verdicts and tick id (#2027) ─────────────────────────────
|
|
177
|
+
|
|
178
|
+
describe("tick id", () => {
|
|
179
|
+
test("mints one per record, and two ticks in the same ISO second are still distinguishable", async () => {
|
|
180
|
+
await withTestDir(async (dir) => {
|
|
181
|
+
await initRepo(dir);
|
|
182
|
+
const a = await appendConvergeRecord(makeInput(), { cwd: dir });
|
|
183
|
+
const b = await appendConvergeRecord(makeInput(), { cwd: dir });
|
|
184
|
+
|
|
185
|
+
expect(a.record.id).toMatch(/^[0-9a-f-]{36}$/);
|
|
186
|
+
expect(a.record.timestamp).toBe(b.record.timestamp);
|
|
187
|
+
expect(a.record.id).not.toBe(b.record.id);
|
|
188
|
+
|
|
189
|
+
const { records } = await readConvergeLedger("staging", { cwd: dir });
|
|
190
|
+
expect(records.map((r) => r.id)).toEqual([a.record.id, b.record.id]);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("an explicitly supplied id wins over the mint", async () => {
|
|
195
|
+
await withTestDir(async (dir) => {
|
|
196
|
+
await initRepo(dir);
|
|
197
|
+
const { record } = await appendConvergeRecord(makeInput({ id: "tick-fixed" }), { cwd: dir });
|
|
198
|
+
expect(record.id).toBe("tick-fixed");
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("a pre-#2027 record with no id still reads, rather than being counted malformed", async () => {
|
|
203
|
+
await withTestDir(async (dir) => {
|
|
204
|
+
await initRepo(dir);
|
|
205
|
+
await appendConvergeRecord(makeInput(), { cwd: dir });
|
|
206
|
+
// Hand-write the pre-#2027 shape (no `id`) onto the same ledger,
|
|
207
|
+
// through the same git plumbing a real writer used before the field
|
|
208
|
+
// existed.
|
|
209
|
+
const { readBlobFromPath, writeBlobToPath } = await import("./git");
|
|
210
|
+
const legacy = JSON.stringify({ version: 1, ...makeInput({ timestamp: "2025-12-31T00:00:00.000Z" }) });
|
|
211
|
+
const existing = await readBlobFromPath("staging", "converge.jsonl", { cwd: dir });
|
|
212
|
+
await writeBlobToPath("staging", "converge.jsonl", `${existing}\n${legacy}`, "legacy", { cwd: dir });
|
|
213
|
+
|
|
214
|
+
const { records, malformed } = await readConvergeLedger("staging", { cwd: dir });
|
|
215
|
+
expect(malformed).toBe(0);
|
|
216
|
+
expect(records).toHaveLength(2);
|
|
217
|
+
expect(records[1].id).toBeUndefined();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("componentVerdicts", () => {
|
|
223
|
+
const row = (over: Partial<ComponentStatusRow>): ComponentStatusRow => ({
|
|
224
|
+
component: "svc",
|
|
225
|
+
env: "staging",
|
|
226
|
+
reconciliation: "reconciled",
|
|
227
|
+
detail: "digest matches live",
|
|
228
|
+
...over,
|
|
229
|
+
} as ComponentStatusRow);
|
|
230
|
+
|
|
231
|
+
test("keeps the verdict-bearing subset and drops the heavy release/build fields", () => {
|
|
232
|
+
const rows = [
|
|
233
|
+
row({ component: "api", reconciliation: "drifted", detail: "live digest differs", live: true,
|
|
234
|
+
recorded: { version: 1 } as unknown as ComponentStatusRow["recorded"] }),
|
|
235
|
+
row({ component: "worker", reconciliation: "unknown", detail: "could not read live state",
|
|
236
|
+
unobserved: { reason: "no-credentials", detail: "no role assumed" } }),
|
|
237
|
+
];
|
|
238
|
+
expect(componentVerdicts(rows)).toEqual([
|
|
239
|
+
{ component: "api", reconciliation: "drifted", detail: "live digest differs", live: true },
|
|
240
|
+
{
|
|
241
|
+
component: "worker",
|
|
242
|
+
reconciliation: "unknown",
|
|
243
|
+
detail: "could not read live state",
|
|
244
|
+
unobserved: { reason: "no-credentials", detail: "no role assumed" },
|
|
245
|
+
},
|
|
246
|
+
]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("caps a multi-line or oversized detail to one line, so the record stays one line of JSON", () => {
|
|
250
|
+
const verdicts = componentVerdicts([
|
|
251
|
+
row({ detail: "first line\nsecond line" }),
|
|
252
|
+
row({ component: "big", detail: "x".repeat(500) }),
|
|
253
|
+
]);
|
|
254
|
+
expect(verdicts[0].detail).toBe("first line");
|
|
255
|
+
expect(verdicts[1].detail).toHaveLength(301); // 300 + the ellipsis
|
|
256
|
+
expect(JSON.stringify(verdicts)).not.toContain("\\n");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("round-trips on a record, naming the component that tripped the tick's aggregate unknown", async () => {
|
|
260
|
+
await withTestDir(async (dir) => {
|
|
261
|
+
await initRepo(dir);
|
|
262
|
+
const { record } = await appendConvergeRecord(
|
|
263
|
+
makeInput({
|
|
264
|
+
components: componentVerdicts([
|
|
265
|
+
row({ component: "api", reconciliation: "drifted", detail: "live digest differs", live: true }),
|
|
266
|
+
row({ component: "worker", reconciliation: "unknown", detail: "unreadable",
|
|
267
|
+
unobserved: { reason: "no-credentials" } }),
|
|
268
|
+
]),
|
|
269
|
+
summary: { drifted: 1, remediated: 0, reported: 1, skippedBudget: 0, skippedFlap: 0, unobserved: 1, adopted: 0 },
|
|
270
|
+
}),
|
|
271
|
+
{ cwd: dir },
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const { records } = await readConvergeLedger("staging", { cwd: dir });
|
|
275
|
+
expect(records).toEqual([record]);
|
|
276
|
+
// The count says "1 unobserved"; the verdicts say which one.
|
|
277
|
+
expect(records[0].summary.unobserved).toBe(1);
|
|
278
|
+
expect(records[0].components?.filter((c) => c.unobserved).map((c) => c.component)).toEqual(["worker"]);
|
|
279
|
+
expect(records[0].components?.find((c) => c.reconciliation === "drifted")?.component).toBe("api");
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
174
284
|
// ── Concurrent local writers (#1485) ────────────────────────────────────────
|
|
175
285
|
|
|
176
286
|
describe("appendConvergeRecord retries on RefCASConflictError", () => {
|
|
@@ -16,11 +16,29 @@
|
|
|
16
16
|
* row include a given rule id, stopping at the first tick where it didn't
|
|
17
17
|
* fire (the symptom cleared).
|
|
18
18
|
*/
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
19
20
|
import { sortedJsonReplacer } from "../utils";
|
|
21
|
+
import type { ComponentStatusRow } from "./status";
|
|
20
22
|
import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
|
|
21
23
|
|
|
22
24
|
const FILENAME = "converge.jsonl";
|
|
23
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Cap a piece of free text to one sanitized line before it goes into a
|
|
28
|
+
* record. A record here is one line of JSON, so a multi-line or unbounded
|
|
29
|
+
* string folded into any field would break the line the ledger is built out
|
|
30
|
+
* of. Every free-text field a tick writes goes through this: a dispatch
|
|
31
|
+
* failure's `stderr` (lexicons/temporal's `sanitizeOneLine`, which delegates
|
|
32
|
+
* here) and a component verdict's lexicon-authored `detail`.
|
|
33
|
+
*/
|
|
34
|
+
const MAX_LEDGER_TEXT_LEN = 300;
|
|
35
|
+
export function sanitizeLedgerText(raw: string, maxLen = MAX_LEDGER_TEXT_LEN): string {
|
|
36
|
+
const firstLine = raw.split(/\r?\n/, 1)[0] ?? "";
|
|
37
|
+
// Strips literal control bytes; it is not matching on a range boundary.
|
|
38
|
+
const stripped = firstLine.replace(/[\x00-\x1f\x7f]/g, " ").trim();
|
|
39
|
+
return stripped.length > maxLen ? `${stripped.slice(0, maxLen)}…` : stripped;
|
|
40
|
+
}
|
|
41
|
+
|
|
24
42
|
/**
|
|
25
43
|
* Read-modify-append retry budget for {@link appendConvergeRecord} (#1485).
|
|
26
44
|
* `writeBlobToPath`'s ref write is now CAS-guarded (./git.ts) — a conflict
|
|
@@ -50,14 +68,85 @@ export interface ConvergeRuleOutcome {
|
|
|
50
68
|
op?: string;
|
|
51
69
|
/** The gate's signal name, for `action: "gated"`. */
|
|
52
70
|
gateName?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Where this gate's approval happens (#2028), for `action: "gated"` — the
|
|
73
|
+
* PR carrying the change in a gate-as-PR flow, or whatever review surface
|
|
74
|
+
* the dispatching environment knows about.
|
|
75
|
+
*
|
|
76
|
+
* The pending fact is the one a human has to act on, and it used to carry
|
|
77
|
+
* no link at all: `chant operator status`'s pending row was
|
|
78
|
+
* `{rule, op, gate}` and the only affordance it could print was a shell
|
|
79
|
+
* command. Absent when there genuinely is no address — a local tick with no
|
|
80
|
+
* PR behind it — never a synthesized one. See
|
|
81
|
+
* `./gate-ledger.ts`'s `resolveApprovalUrl` for where it comes from, and
|
|
82
|
+
* `GateResolutionRecord.url` for the resolved counterpart.
|
|
83
|
+
*/
|
|
84
|
+
url?: string;
|
|
53
85
|
/** The report reason, for `action: "reported"` (including a flap-damped rule's forced report) — and the human-readable explanation for `action: "gated"`. */
|
|
54
86
|
reason?: string;
|
|
55
87
|
}
|
|
56
88
|
|
|
89
|
+
/**
|
|
90
|
+
* One component's verdict as the tick observed it (#2027) — the per-entity
|
|
91
|
+
* layer behind the tick's single aggregate `status`.
|
|
92
|
+
*
|
|
93
|
+
* A deliberate subset of `ComponentStatusRow`: the five verdict-bearing
|
|
94
|
+
* fields, joined on the same `component` key `chant components status --live
|
|
95
|
+
* --json` emits, and none of the heavy ones. `recorded`, `build` and
|
|
96
|
+
* `componentBom` are dropped on purpose — they are release/build-ledger
|
|
97
|
+
* content, already readable by that same key, and a tick record is one line
|
|
98
|
+
* of JSON appended every tick forever. `detail` goes through
|
|
99
|
+
* {@link sanitizeLedgerText} for the same reason.
|
|
100
|
+
*/
|
|
101
|
+
export interface ConvergeComponentVerdict {
|
|
102
|
+
component: string;
|
|
103
|
+
reconciliation: ComponentStatusRow["reconciliation"];
|
|
104
|
+
/** Human-readable detail backing the verdict, capped to one line. */
|
|
105
|
+
detail: string;
|
|
106
|
+
/** "Observed live", when live evidence was gathered. Absent means "did not look" or "could not look" — see `unobserved`. */
|
|
107
|
+
live?: boolean;
|
|
108
|
+
/** Why live state could not be read for this component (#1089). Mutually exclusive with `live`; this is the row that tripped an aggregate `status: "unknown"`. */
|
|
109
|
+
unobserved?: { reason: string; detail?: string };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Project the tick's `ComponentStatusRow[]` down to what the ledger keeps.
|
|
114
|
+
* Pure; row order is preserved, so a consumer sees components in the same
|
|
115
|
+
* order the status join produced them.
|
|
116
|
+
*/
|
|
117
|
+
export function componentVerdicts(rows: readonly ComponentStatusRow[]): ConvergeComponentVerdict[] {
|
|
118
|
+
return rows.map((row) => ({
|
|
119
|
+
component: row.component,
|
|
120
|
+
reconciliation: row.reconciliation,
|
|
121
|
+
detail: sanitizeLedgerText(row.detail ?? ""),
|
|
122
|
+
...(row.live !== undefined ? { live: row.live } : {}),
|
|
123
|
+
...(row.unobserved
|
|
124
|
+
? {
|
|
125
|
+
unobserved: {
|
|
126
|
+
reason: row.unobserved.reason,
|
|
127
|
+
...(row.unobserved.detail !== undefined ? { detail: sanitizeLedgerText(row.unobserved.detail) } : {}),
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
: {}),
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
|
|
57
134
|
/** One immutable converge-tick record. */
|
|
58
135
|
export interface ConvergeTickRecord {
|
|
59
136
|
/** Schema version, so an incompatible future shape is detected before being misread. */
|
|
60
137
|
version: 1;
|
|
138
|
+
/**
|
|
139
|
+
* Stable tick id (#2027) — the one thing an outcome, a gate fact or a
|
|
140
|
+
* remediation can point at. Before this, a tick's identity was the
|
|
141
|
+
* `(op, env, timestamp)` triple, so two ticks landing in the same ISO
|
|
142
|
+
* second were indistinguishable and nothing could reference one.
|
|
143
|
+
*
|
|
144
|
+
* Minted by {@link appendConvergeRecord} with `randomUUID()` (the same
|
|
145
|
+
* mint `./lease.ts` uses for a lease token) when the caller doesn't supply
|
|
146
|
+
* one. Optional on the type because `version: 1` records written before
|
|
147
|
+
* #2027 have none — a reader must handle its absence, not assume it.
|
|
148
|
+
*/
|
|
149
|
+
id?: string;
|
|
61
150
|
/** The ConvergeOp's name (`OpConfig.name`). */
|
|
62
151
|
op: string;
|
|
63
152
|
env: string;
|
|
@@ -67,6 +156,18 @@ export interface ConvergeTickRecord {
|
|
|
67
156
|
firedRuleIds: string[];
|
|
68
157
|
/** Per-rule outcome, for every fired rule. */
|
|
69
158
|
outcomes: ConvergeRuleOutcome[];
|
|
159
|
+
/**
|
|
160
|
+
* The per-component verdicts this tick observed (#2027), behind the
|
|
161
|
+
* aggregate counts in `summary`.
|
|
162
|
+
*
|
|
163
|
+
* The tick derives these to compute `summary.drifted` and the whole-tick
|
|
164
|
+
* `status`, and used to throw them away — so a reader could say "this
|
|
165
|
+
* environment drifted twice this hour" but could not colour the node that
|
|
166
|
+
* drifted, or name the single unobserved component whose `unknown` verdict
|
|
167
|
+
* refused remediation for everything else. Optional because `version: 1`
|
|
168
|
+
* records written before #2027 have none; absent is not "no components".
|
|
169
|
+
*/
|
|
170
|
+
components?: ConvergeComponentVerdict[];
|
|
70
171
|
/** Aggregate counts backing the tick's one log line. */
|
|
71
172
|
summary: {
|
|
72
173
|
drifted: number;
|
|
@@ -90,6 +191,13 @@ export type ConvergeTickRecordInput = Omit<ConvergeTickRecord, "version">;
|
|
|
90
191
|
* `pushLifecycle` (./git.ts) afterward, same two-step shape every other
|
|
91
192
|
* ledger write here uses.
|
|
92
193
|
*
|
|
194
|
+
* Mints `record.id` (#2027) when the input doesn't carry one, so every tick
|
|
195
|
+
* written from here on is referenceable. Unlike `timestamp`, which stays
|
|
196
|
+
* caller-supplied because library code never calls `Date.now()` internally,
|
|
197
|
+
* an id has nothing for a caller to decide — the mint lives here so no
|
|
198
|
+
* writer can forget it. An explicit `input.id` still wins, which is what a
|
|
199
|
+
* test asserting an exact record uses.
|
|
200
|
+
*
|
|
93
201
|
* Retries the whole read-modify-write cycle (#1485) on `RefCASConflictError`
|
|
94
202
|
* — `writeBlobToPath`'s ref write is CAS-guarded, so a concurrent writer to
|
|
95
203
|
* a *different* env's file on the same orphan branch (two operators ticking
|
|
@@ -108,8 +216,8 @@ export type ConvergeTickRecordInput = Omit<ConvergeTickRecord, "version">;
|
|
|
108
216
|
export async function appendConvergeRecord(
|
|
109
217
|
input: ConvergeTickRecordInput,
|
|
110
218
|
opts?: { cwd?: string },
|
|
111
|
-
): Promise<{ commit: string; record: ConvergeTickRecord }> {
|
|
112
|
-
const record: ConvergeTickRecord = { version: 1, ...input };
|
|
219
|
+
): Promise<{ commit: string; record: ConvergeTickRecord & { id: string } }> {
|
|
220
|
+
const record: ConvergeTickRecord & { id: string } = { version: 1, ...input, id: input.id ?? randomUUID() };
|
|
113
221
|
const json = JSON.stringify(record, sortedJsonReplacer);
|
|
114
222
|
|
|
115
223
|
let lastErr: unknown;
|
|
@@ -3,7 +3,7 @@ import { withTestDir } from "@intentius/chant-test-utils";
|
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { writeFileSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { appendGateResolution, readGateResolutions, latestResolutionSince } from "./gate-ledger";
|
|
6
|
+
import { appendGateResolution, readGateResolutions, latestResolutionSince, resolveApprovalUrl, isApprovalUrl } from "./gate-ledger";
|
|
7
7
|
import { readBlobFromPath } from "./git";
|
|
8
8
|
|
|
9
9
|
function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
|
|
@@ -100,4 +100,84 @@ describe("lifecycle/gate-ledger", () => {
|
|
|
100
100
|
expect(latestResolutionSince([], "g1", "2026-01-01T00:00:00.000Z")).toBeUndefined();
|
|
101
101
|
});
|
|
102
102
|
});
|
|
103
|
+
// ── Gate-as-fact carries an address (#2028) ──────────────────────────────
|
|
104
|
+
|
|
105
|
+
describe("resolveApprovalUrl", () => {
|
|
106
|
+
test("a GitHub Actions pull_request run resolves to that PR", () => {
|
|
107
|
+
expect(resolveApprovalUrl({
|
|
108
|
+
GITHUB_SERVER_URL: "https://github.com",
|
|
109
|
+
GITHUB_REPOSITORY: "INTENTIUS/chant",
|
|
110
|
+
GITHUB_REF_NAME: "2028/merge",
|
|
111
|
+
})).toBe("https://github.com/INTENTIUS/chant/pull/2028");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("honours a GitHub Enterprise server url, trailing slash and all", () => {
|
|
115
|
+
expect(resolveApprovalUrl({
|
|
116
|
+
GITHUB_SERVER_URL: "https://ghe.example.com/",
|
|
117
|
+
GITHUB_REPOSITORY: "org/repo",
|
|
118
|
+
GITHUB_REF_NAME: "7/head",
|
|
119
|
+
})).toBe("https://ghe.example.com/org/repo/pull/7");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a GitLab merge-request pipeline resolves to that MR", () => {
|
|
123
|
+
expect(resolveApprovalUrl({
|
|
124
|
+
CI_MERGE_REQUEST_PROJECT_URL: "https://gitlab.com/org/repo",
|
|
125
|
+
CI_MERGE_REQUEST_IID: "42",
|
|
126
|
+
})).toBe("https://gitlab.com/org/repo/-/merge_requests/42");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a push-event CI run, or no CI at all, has no address — undefined, never a guess", () => {
|
|
130
|
+
expect(resolveApprovalUrl({ GITHUB_REPOSITORY: "org/repo", GITHUB_REF_NAME: "main" })).toBeUndefined();
|
|
131
|
+
expect(resolveApprovalUrl({ GITHUB_REF_NAME: "3/merge" })).toBeUndefined();
|
|
132
|
+
expect(resolveApprovalUrl({ CI_MERGE_REQUEST_PROJECT_URL: "https://gitlab.com/org/repo" })).toBeUndefined();
|
|
133
|
+
expect(resolveApprovalUrl({})).toBeUndefined();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("isApprovalUrl", () => {
|
|
138
|
+
test("accepts absolute http/https", () => {
|
|
139
|
+
expect(isApprovalUrl("https://github.com/org/repo/pull/1")).toBe(true);
|
|
140
|
+
expect(isApprovalUrl("http://localhost:3000/pr/1")).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("refuses anything a reader could not follow as a link", () => {
|
|
144
|
+
for (const bad of ["", "org/repo/pull/1", "/pull/1", "file:///etc/passwd", "javascript:alert(1)", "not a url"]) {
|
|
145
|
+
expect(isApprovalUrl(bad)).toBe(false);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("a resolution round-trips its typed url alongside free-text note", async () => {
|
|
151
|
+
await withTestDir(async (dir) => {
|
|
152
|
+
await initRepo(dir);
|
|
153
|
+
const { record } = await appendGateResolution(
|
|
154
|
+
{
|
|
155
|
+
op: "fountain-apply",
|
|
156
|
+
gate: "rollout-gate",
|
|
157
|
+
resolvedBy: "alex",
|
|
158
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
159
|
+
note: "rolled staging first",
|
|
160
|
+
url: "https://github.com/INTENTIUS/chant/pull/2028",
|
|
161
|
+
},
|
|
162
|
+
{ cwd: dir },
|
|
163
|
+
);
|
|
164
|
+
const { records } = await readGateResolutions("fountain-apply", { cwd: dir });
|
|
165
|
+
expect(records).toEqual([record]);
|
|
166
|
+
expect(records[0].url).toBe("https://github.com/INTENTIUS/chant/pull/2028");
|
|
167
|
+
expect(records[0].note).toBe("rolled staging first");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a pre-#2028 resolution with no url still reads", async () => {
|
|
172
|
+
await withTestDir(async (dir) => {
|
|
173
|
+
await initRepo(dir);
|
|
174
|
+
await appendGateResolution(
|
|
175
|
+
{ op: "fountain-apply", gate: "g", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
176
|
+
{ cwd: dir },
|
|
177
|
+
);
|
|
178
|
+
const { records, malformed } = await readGateResolutions("fountain-apply", { cwd: dir });
|
|
179
|
+
expect(malformed).toBe(0);
|
|
180
|
+
expect(records[0].url).toBeUndefined();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
103
183
|
});
|
|
@@ -42,6 +42,58 @@ import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASCo
|
|
|
42
42
|
const DIR = "_gates";
|
|
43
43
|
const APPEND_RETRY_ATTEMPTS = 5;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The address of the approval surface for a gate, resolved from the CI
|
|
47
|
+
* environment (#2028).
|
|
48
|
+
*
|
|
49
|
+
* #1485's argument for gate-as-fact was that approval gets an address:
|
|
50
|
+
* "gate-as-PR gives approval a URL, a review surface, and CODEOWNERS as the
|
|
51
|
+
* authorization model." What shipped recorded the gate and not the address,
|
|
52
|
+
* so a pending-approval card had nothing to link to and gate-as-PR stayed a
|
|
53
|
+
* convention. This is the narrow, honest half of that: when a tick (or a
|
|
54
|
+
* `chant approve`) runs inside the PR/MR job that carries the change, the
|
|
55
|
+
* loop genuinely knows where approval happens, and says so. Anywhere else it
|
|
56
|
+
* returns `undefined` and the field is simply absent — never a guess, never a
|
|
57
|
+
* synthesized link.
|
|
58
|
+
*
|
|
59
|
+
* The env-var fallback chain is the same one `--actor` and `--run-id` already
|
|
60
|
+
* use (`../cli/handlers/components.ts`): GitHub Actions first, then GitLab CI.
|
|
61
|
+
*
|
|
62
|
+
* - GitHub Actions on a `pull_request` event: `GITHUB_SERVER_URL` +
|
|
63
|
+
* `GITHUB_REPOSITORY` + the PR number, which `GITHUB_REF_NAME` carries as
|
|
64
|
+
* `<n>/merge`. A push-event run has no PR, so it resolves to nothing.
|
|
65
|
+
* - GitLab CI on a merge-request pipeline: `CI_MERGE_REQUEST_PROJECT_URL` +
|
|
66
|
+
* `CI_MERGE_REQUEST_IID`.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveApprovalUrl(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
69
|
+
const prNumber = /^(\d+)\/(merge|head)$/.exec(env.GITHUB_REF_NAME ?? "")?.[1];
|
|
70
|
+
if (prNumber && env.GITHUB_REPOSITORY) {
|
|
71
|
+
const server = (env.GITHUB_SERVER_URL ?? "https://github.com").replace(/\/$/, "");
|
|
72
|
+
return `${server}/${env.GITHUB_REPOSITORY}/pull/${prNumber}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (env.CI_MERGE_REQUEST_PROJECT_URL && env.CI_MERGE_REQUEST_IID) {
|
|
76
|
+
return `${env.CI_MERGE_REQUEST_PROJECT_URL.replace(/\/$/, "")}/-/merge_requests/${env.CI_MERGE_REQUEST_IID}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether `raw` is an address worth recording as one: an absolute `http`/
|
|
84
|
+
* `https` URL. A gate's address is a link a reader is expected to follow, so
|
|
85
|
+
* a relative path or a `file:`/`javascript:` scheme is refused at the CLI
|
|
86
|
+
* boundary rather than written into an immutable record.
|
|
87
|
+
*/
|
|
88
|
+
export function isApprovalUrl(raw: string): boolean {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = new URL(raw);
|
|
91
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
45
97
|
/** One immutable gate-resolution record. */
|
|
46
98
|
export interface GateResolutionRecord {
|
|
47
99
|
/** Schema version, so an incompatible future shape is detected before being misread. */
|
|
@@ -54,8 +106,19 @@ export interface GateResolutionRecord {
|
|
|
54
106
|
resolvedBy: string;
|
|
55
107
|
/** ISO-8601 timestamp, caller-supplied (library code never calls `Date.now()` internally). */
|
|
56
108
|
timestamp: string;
|
|
57
|
-
/** Optional free-text context
|
|
109
|
+
/** Optional free-text context. Before #2028 this was also where a PR link went by convention; put the link in {@link GateResolutionRecord.url} instead and leave this for prose. */
|
|
58
110
|
note?: string;
|
|
111
|
+
/**
|
|
112
|
+
* The address this resolution happened at (#2028) — the PR that carried the
|
|
113
|
+
* change, the review thread, whatever the approval surface was. Typed, so
|
|
114
|
+
* "resolved by this PR" is machine-readable instead of a reader sniffing
|
|
115
|
+
* `note` for something that looks like a link.
|
|
116
|
+
*
|
|
117
|
+
* `chant approve --url` sets it; absent when the resolver genuinely had no
|
|
118
|
+
* address (a human at a terminal, no PR). Always an absolute `http`/`https`
|
|
119
|
+
* URL — see {@link isApprovalUrl}.
|
|
120
|
+
*/
|
|
121
|
+
url?: string;
|
|
59
122
|
}
|
|
60
123
|
|
|
61
124
|
export type GateResolutionInput = Omit<GateResolutionRecord, "version">;
|