@intentius/chant 0.105.1 → 0.106.3
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/change-set.d.ts +30 -8
- package/dist/change-set.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +11 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/pr.d.ts +16 -2
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/promote.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/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +7 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/changed-files.d.ts +8 -2
- package/dist/components/changed-files.d.ts.map +1 -1
- package/dist/components/fan-out-record.d.ts +24 -0
- package/dist/components/fan-out-record.d.ts.map +1 -1
- package/dist/components/pr-pipeline.d.ts +67 -4
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-run.d.ts +94 -3
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/gated-waves.d.ts +4 -1
- package/dist/gated-waves.d.ts.map +1 -1
- package/dist/lexicon.d.ts +13 -2
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +7 -0
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +2 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +10 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +2 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts +2 -2
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +29 -4
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/pr-loop.d.ts +32 -6
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
- package/dist/workspace/member-pipeline.d.ts +15 -1
- package/dist/workspace/member-pipeline.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__fixtures__/change-set/lifecycle.change-set.golden.json +1 -1
- package/src/__fixtures__/change-set/warden.change-set.golden.json +2 -2
- package/src/__fixtures__/change-set/warden.plan.json +47 -40
- package/src/__fixtures__/plan-summary/normalization-vectors.json +275 -2
- package/src/__fixtures__/pr-loop/pr-apply-applied.note.golden.md +1 -1
- package/src/__fixtures__/pr-loop/pr-apply-refused.note.golden.md +2 -2
- package/src/__fixtures__/pr-loop/pr-plan.golden.json +2 -2
- package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +2 -2
- package/src/build.test.ts +1 -1
- package/src/build.ts +1 -1
- package/src/change-set.test.ts +42 -12
- package/src/change-set.ts +50 -11
- package/src/cli/commands/build.ts +33 -14
- package/src/cli/commands/lint.ts +34 -1
- package/src/cli/handlers/build-lexicon-output.test.ts +102 -0
- package/src/cli/handlers/build.ts +68 -0
- package/src/cli/handlers/fan-out.test.ts +12 -1
- package/src/cli/handlers/fan-out.ts +7 -3
- package/src/cli/handlers/operator.ts +1 -2
- package/src/cli/handlers/pr.ts +223 -35
- package/src/cli/handlers/promote.test.ts +15 -1
- package/src/cli/handlers/promote.ts +5 -3
- package/src/cli/handlers/run.test.ts +215 -1
- package/src/cli/handlers/run.ts +36 -15
- package/src/cli/main.ts +13 -1
- package/src/cli/mcp/never-over-mcp.test.ts +120 -0
- package/src/cli/mcp/op-approve-origin.test.ts +1 -1
- package/src/cli/mcp/op-tools.ts +14 -2
- package/src/cli/mcp/server.test.ts +2 -1
- package/src/cli/registry.ts +7 -0
- package/src/components/changed-files.test.ts +58 -0
- package/src/components/changed-files.ts +19 -4
- package/src/components/fan-out-record.test.ts +47 -2
- package/src/components/fan-out-record.ts +53 -0
- package/src/components/pr-pipeline.ts +96 -6
- package/src/components/pr-run.test.ts +311 -4
- package/src/components/pr-run.ts +198 -5
- package/src/gated-waves.test.ts +11 -0
- package/src/gated-waves.ts +7 -2
- package/src/lexicon.ts +13 -1
- package/src/lifecycle/gate-ledger.ts +4 -3
- package/src/op/gate-name.ts +17 -0
- package/src/op/gate-summary.test.ts +12 -0
- package/src/op/gate-summary.ts +3 -1
- package/src/op/gate.test.ts +15 -1
- package/src/op/gate.ts +21 -4
- package/src/op/index.ts +2 -2
- package/src/op/local-executor.test.ts +35 -0
- package/src/op/local-executor.ts +2 -2
- package/src/op/local-output.test.ts +20 -0
- package/src/op/local-output.ts +4 -4
- package/src/op/runtime.ts +31 -4
- package/src/op/runtimes/local.test.ts +15 -0
- package/src/op/runtimes/local.ts +3 -1
- package/src/pr-loop.test.ts +41 -1
- package/src/pr-loop.ts +41 -10
- package/src/workspace/change-set.schema.json +1 -1
- package/src/workspace/lineage-upgrade-cli.ts +4 -3
- package/src/workspace/lineage-upgrade.test.ts +40 -0
- package/src/workspace/member-pipeline.test.ts +28 -0
- package/src/workspace/member-pipeline.ts +21 -3
- package/src/workspace/pr-report.schema.json +14 -1
|
@@ -75,8 +75,9 @@ vi.mock("./operator", () => ({
|
|
|
75
75
|
// gate handling, and a unit test must never write a fact to the real repo.
|
|
76
76
|
vi.mock("../../op/gate", async () => {
|
|
77
77
|
const actual = await vi.importActual<typeof import("../../op/gate")>("../../op/gate");
|
|
78
|
-
return { ...actual, gitGateLedgerPort: () => gateLedger };
|
|
78
|
+
return { ...actual, gitGateLedgerPort: () => gateLedger, gateIsSealed: async (gate: string) => sealedGates.has(gate) };
|
|
79
79
|
});
|
|
80
|
+
const sealedGates = new Set<string>();
|
|
80
81
|
// The local runtime appends each run's record to that same branch (#2118).
|
|
81
82
|
// Real behavior in a project, and exactly what this suite must not do in
|
|
82
83
|
// chant's own checkout, since the append is against `process.cwd()`. Only the
|
|
@@ -264,9 +265,28 @@ describe("runOp dispatcher", () => {
|
|
|
264
265
|
stderrWrite.mockRestore();
|
|
265
266
|
expect(out).toContain('is gated on "approve-prod"');
|
|
266
267
|
expect(out).toContain("chant approve gated approve-prod");
|
|
268
|
+
expect(out).not.toContain("--sign");
|
|
267
269
|
expect(gateLedger.appended).toHaveLength(1);
|
|
268
270
|
});
|
|
269
271
|
|
|
272
|
+
test("gate in local mode on a sealed gate: the approve line carries --sign (#3521)", async () => {
|
|
273
|
+
gateLedger = memoryGateLedgerPort();
|
|
274
|
+
sealedGates.add("approve-prod");
|
|
275
|
+
discoverOpsMock.mockResolvedValue({
|
|
276
|
+
ops: new Map([localOp("gated", [{ kind: "gate", gate: "approve-prod" }])]),
|
|
277
|
+
errors: [],
|
|
278
|
+
});
|
|
279
|
+
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
280
|
+
try {
|
|
281
|
+
expect(await runOp({ args: makeArgs({ path: "gated" }), plugins: [], serializers: [] })).toBe(3);
|
|
282
|
+
const out = stderrWrite.mock.calls.map((c) => String(c[0])).join("");
|
|
283
|
+
expect(out).toContain("approve : chant approve gated approve-prod --sign");
|
|
284
|
+
} finally {
|
|
285
|
+
stderrWrite.mockRestore();
|
|
286
|
+
sealedGates.clear();
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
270
290
|
test("an approved gate lets the run through and exits 0", async () => {
|
|
271
291
|
gateLedger = memoryGateLedgerPort({
|
|
272
292
|
pending: [{
|
|
@@ -448,6 +468,57 @@ describe("runOp: a steward's turn (#2750)", () => {
|
|
|
448
468
|
expect(acquireStewardTurnMock).not.toHaveBeenCalled();
|
|
449
469
|
});
|
|
450
470
|
|
|
471
|
+
// #3555: a runtime that carries the work item (fountain) is handed what the
|
|
472
|
+
// caller named, and nothing else; one that does not refuses --work by name.
|
|
473
|
+
function leasedOp(name: string) {
|
|
474
|
+
return [name, { config: { name, overview: `${name} overview`, phases: [], workLease: {} } }] as const;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
test("--work on a runtime that carries work: start gets the item and holder the caller named (#3555)", async () => {
|
|
478
|
+
const runtime = {
|
|
479
|
+
name: "fountain",
|
|
480
|
+
carriesWork: true,
|
|
481
|
+
start: vi.fn(async (op: { name: string }, _opts: { work?: { item?: string; holder?: string } }) => ({
|
|
482
|
+
op: op.name,
|
|
483
|
+
runId: "f-1",
|
|
484
|
+
result: async () => ({ op: op.name, runId: "f-1", state: "completed" as const, startedAt: "t", endedAt: "t" }),
|
|
485
|
+
})),
|
|
486
|
+
status: vi.fn(), log: vi.fn(), list: vi.fn(), cancel: vi.fn(),
|
|
487
|
+
};
|
|
488
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([leasedOp("leased")]), errors: [] });
|
|
489
|
+
discoverStewardsMock.mockResolvedValueOnce(stewardOwning("leased", "box-steward", "fountain"));
|
|
490
|
+
loadChantConfigMock.mockResolvedValue({ config: { lexicons: ["fountain"] } });
|
|
491
|
+
makeStdoutSpy();
|
|
492
|
+
const exit = await runOp({
|
|
493
|
+
args: makeArgs({ path: "leased", on: "fountain", work: "ISSUE-7" }),
|
|
494
|
+
plugins: [{ name: "fountain", opRuntime: runtime } as never], serializers: [],
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
expect(exit).toBe(0);
|
|
498
|
+
expect(runtime.start).toHaveBeenCalledTimes(1);
|
|
499
|
+
expect(runtime.start.mock.calls[0][1].work).toEqual({ item: "ISSUE-7" });
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test("--work on a runtime that does not carry work: exit 1 naming the runtime, nothing started (#3555)", async () => {
|
|
503
|
+
const runtime = {
|
|
504
|
+
name: "fountain",
|
|
505
|
+
start: vi.fn(),
|
|
506
|
+
status: vi.fn(), log: vi.fn(), list: vi.fn(), cancel: vi.fn(),
|
|
507
|
+
};
|
|
508
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([leasedOp("leased")]), errors: [] });
|
|
509
|
+
discoverStewardsMock.mockResolvedValueOnce(stewardOwning("leased", "box-steward", "fountain"));
|
|
510
|
+
loadChantConfigMock.mockResolvedValue({ config: { lexicons: ["fountain"] } });
|
|
511
|
+
const stderr = makeStderrSpy();
|
|
512
|
+
const exit = await runOp({
|
|
513
|
+
args: makeArgs({ path: "leased", on: "fountain", work: "ISSUE-7" }),
|
|
514
|
+
plugins: [{ name: "fountain", opRuntime: runtime } as never], serializers: [],
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
expect(exit).toBe(1);
|
|
518
|
+
expect(runtime.start).not.toHaveBeenCalled();
|
|
519
|
+
expect(stderr.join("\n")).toContain('--work and --holder run on the local runtime; "fountain" takes the work lease');
|
|
520
|
+
});
|
|
521
|
+
|
|
451
522
|
test("local form, the turn lease's own acquire throws (a stale .lock): refused as a lease error, not an uncaught exception, and nothing runs", async () => {
|
|
452
523
|
discoverOpsMock.mockResolvedValue({
|
|
453
524
|
ops: new Map([localOp("release", [{ kind: "activity", fn: "shellCmd", args: { cmd: "true" } }])]),
|
|
@@ -616,6 +687,25 @@ describe("runOp: --gated-exit (#2243)", () => {
|
|
|
616
687
|
expect(readFileSync(file, "utf8")).toContain("chant approve gated approve-prod");
|
|
617
688
|
});
|
|
618
689
|
|
|
690
|
+
test("a sealed gate: the --json approve field and the summary carry --sign (#3521)", async () => {
|
|
691
|
+
const file = summaryFile();
|
|
692
|
+
process.env.GITHUB_STEP_SUMMARY = file;
|
|
693
|
+
sealedGates.add("approve-prod");
|
|
694
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([localOp("gated", [gateStep])]), errors: [] });
|
|
695
|
+
const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
696
|
+
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
697
|
+
makeStderrSpy();
|
|
698
|
+
|
|
699
|
+
try {
|
|
700
|
+
await runOp({ args: makeArgs({ path: "gated", gatedExit: 0, json: true }), plugins: [], serializers: [] });
|
|
701
|
+
const parsed = JSON.parse(stdoutWrite.mock.calls.map((c) => String(c[0])).join("").trim());
|
|
702
|
+
expect(parsed.approve).toBe("chant approve gated approve-prod --sign");
|
|
703
|
+
expect(readFileSync(file, "utf8")).toContain("chant approve gated approve-prod --sign --approver <you>");
|
|
704
|
+
} finally {
|
|
705
|
+
sealedGates.clear();
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
|
|
619
709
|
test("a run that fails for any other reason is still red under the flag", async () => {
|
|
620
710
|
discoverOpsMock.mockResolvedValue({
|
|
621
711
|
// atMostOnce: one attempt. The default profile retries twice with 5s and
|
|
@@ -776,6 +866,45 @@ describe("runOp: --sandbox with a policyGate step (chant #2003)", () => {
|
|
|
776
866
|
expect(seen).not.toHaveProperty("profile");
|
|
777
867
|
});
|
|
778
868
|
|
|
869
|
+
// #3521: a hosted runtime that reports only a gate name gets a hint whose
|
|
870
|
+
// approve command names `--sign` when the workspace seals that gate.
|
|
871
|
+
describe("a runtime that reports a gated state with only the gate name (#3521)", () => {
|
|
872
|
+
async function runGatedOnStub(): Promise<string> {
|
|
873
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("prod-apply")]), errors: [] });
|
|
874
|
+
const start = vi.fn(async () => ({
|
|
875
|
+
op: "prod-apply",
|
|
876
|
+
runId: "stub-1",
|
|
877
|
+
result: async () => ({
|
|
878
|
+
op: "prod-apply", runId: "stub-1", state: "gated", startedAt: "2026-01-01T00:00:00.000Z",
|
|
879
|
+
gate: { name: "approve-prod", since: "2026-01-01T00:00:00.000Z" },
|
|
880
|
+
}),
|
|
881
|
+
}));
|
|
882
|
+
makeStdoutSpy();
|
|
883
|
+
const stderr = makeStderrSpy();
|
|
884
|
+
const exit = await runOp({
|
|
885
|
+
args: makeArgs({ path: "prod-apply", on: "stub" }),
|
|
886
|
+
plugins: [{ name: "stub", opRuntime: { name: "stub", start } } as never], serializers: [],
|
|
887
|
+
});
|
|
888
|
+
expect(exit).toBe(3);
|
|
889
|
+
return stderr.join("\n");
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
test("unsealed gate: the hint's approve command has no --sign", async () => {
|
|
893
|
+
const out = await runGatedOnStub();
|
|
894
|
+
expect(out).toContain("Record the resolution with: chant approve prod-apply approve-prod");
|
|
895
|
+
expect(out).not.toContain("--sign");
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
test("sealed gate: the hint's approve command ends in --sign", async () => {
|
|
899
|
+
sealedGates.add("approve-prod");
|
|
900
|
+
try {
|
|
901
|
+
expect(await runGatedOnStub()).toContain("Record the resolution with: chant approve prod-apply approve-prod --sign");
|
|
902
|
+
} finally {
|
|
903
|
+
sealedGates.clear();
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
});
|
|
907
|
+
|
|
779
908
|
test("--sandbox on an Op with no policyGate step is untouched", async () => {
|
|
780
909
|
discoverOpsMock.mockResolvedValue({
|
|
781
910
|
ops: new Map([localOp("hello", [{ kind: "activity", fn: "shellCmd", args: { cmd: "true" } }])]),
|
|
@@ -878,6 +1007,29 @@ describe("run subcommands on the resolved runtime", () => {
|
|
|
878
1007
|
expect((runtime.start.mock.calls[0][0] as { name: string }).name).toBe("hello");
|
|
879
1008
|
});
|
|
880
1009
|
|
|
1010
|
+
test("--param reaches the runtime's start as params (#3539)", async () => {
|
|
1011
|
+
const runtime = makeStubRuntime();
|
|
1012
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("hello")]), errors: [] });
|
|
1013
|
+
makeStdoutSpy();
|
|
1014
|
+
const exit = await runOp({
|
|
1015
|
+
args: makeArgs({ path: "hello", on: "stub", env: "staging", param: ["agent=ops-bot", "tier=gold plus"] }),
|
|
1016
|
+
plugins: [stubPlugin(runtime)], serializers: [],
|
|
1017
|
+
});
|
|
1018
|
+
expect(exit).toBe(0);
|
|
1019
|
+
expect((runtime.start.mock.calls[0] as unknown[])[1]).toMatchObject({
|
|
1020
|
+
env: "staging",
|
|
1021
|
+
params: { agent: "ops-bot", tier: "gold plus" },
|
|
1022
|
+
});
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
test("no --param hands start no params", async () => {
|
|
1026
|
+
const runtime = makeStubRuntime();
|
|
1027
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("hello")]), errors: [] });
|
|
1028
|
+
makeStdoutSpy();
|
|
1029
|
+
await runOp({ args: makeArgs({ path: "hello", on: "stub" }), plugins: [stubPlugin(runtime)], serializers: [] });
|
|
1030
|
+
expect((runtime.start.mock.calls[0] as unknown[])[1]).not.toHaveProperty("params");
|
|
1031
|
+
});
|
|
1032
|
+
|
|
881
1033
|
test("run status/log/list/cancel --on stub reach their methods", async () => {
|
|
882
1034
|
const runtime = makeStubRuntime();
|
|
883
1035
|
discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("hello")]), errors: [] });
|
|
@@ -1060,6 +1212,21 @@ describe("runOpApprove", () => {
|
|
|
1060
1212
|
expect(stderr.join("\n")).toContain('Runtime "stub" was notified');
|
|
1061
1213
|
});
|
|
1062
1214
|
|
|
1215
|
+
test("--env on the approve line reaches resolveGate (#3539)", async () => {
|
|
1216
|
+
const record = { version: 1, op: "alb-deploy", gate: "release", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" };
|
|
1217
|
+
recordGateApprovalMock.mockResolvedValue({ ok: true, record });
|
|
1218
|
+
const resolveGate = vi.fn(async () => undefined);
|
|
1219
|
+
makeStderrSpy();
|
|
1220
|
+
|
|
1221
|
+
const exit = await runOpApprove({
|
|
1222
|
+
args: makeArgs({ on: "stub", extraPositional: "alb-deploy", extraPositional2: "release", approver: "alex", env: "prod" }),
|
|
1223
|
+
plugins: [{ name: "stub", opRuntime: { name: "stub", resolveGate } } as never], serializers: [],
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
expect(exit).toBe(0);
|
|
1227
|
+
expect(resolveGate).toHaveBeenCalledWith("alb-deploy", "release", record, { env: "prod" });
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1063
1230
|
test("a provider with no resolveGate still records the fact and exits 0", async () => {
|
|
1064
1231
|
const record = { version: 1, op: "alb-deploy", gate: "release", resolvedBy: "ci", timestamp: "2026-01-01T00:00:00.000Z" };
|
|
1065
1232
|
recordGateApprovalMock.mockResolvedValue({ ok: true, record });
|
|
@@ -1141,6 +1308,53 @@ describe("runOp dispatcher: --components routes to runOpComponents", () => {
|
|
|
1141
1308
|
});
|
|
1142
1309
|
});
|
|
1143
1310
|
|
|
1311
|
+
// #3521: a gated component's approve line and mismatch line name `--sign` on
|
|
1312
|
+
// a sealed gate and leave it off otherwise.
|
|
1313
|
+
describe("runOp --components gate hints (#3521)", () => {
|
|
1314
|
+
beforeEach(() => {
|
|
1315
|
+
runComponentsMock.mockReset();
|
|
1316
|
+
loadChantConfigMock.mockReset().mockResolvedValue({ config: {} });
|
|
1317
|
+
maybeRecordAutoReleaseMock.mockReset().mockResolvedValue({ recorded: false, reason: "no-digest" });
|
|
1318
|
+
maybePersistBuildManifestMock.mockReset().mockResolvedValue({ persisted: false, reason: "no-manifest" });
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
async function runGatedComponent(): Promise<string> {
|
|
1322
|
+
runComponentsMock.mockResolvedValue({
|
|
1323
|
+
success: false,
|
|
1324
|
+
selected: ["svc"],
|
|
1325
|
+
gated: {
|
|
1326
|
+
component: "svc",
|
|
1327
|
+
gate: {
|
|
1328
|
+
version: 1, kind: "pending", op: "svc", gate: "approve-prod", environment: "prod",
|
|
1329
|
+
timestamp: "2026-09-05T10:00:00.000Z", expiresAt: "2099-01-01T00:00:00.000Z",
|
|
1330
|
+
},
|
|
1331
|
+
mismatch: { approved: "sha256:aaa", planned: "sha256:bbb", resolvedBy: "alex", timestamp: "2026-09-05T11:00:00.000Z", environment: "prod" },
|
|
1332
|
+
},
|
|
1333
|
+
});
|
|
1334
|
+
const stderr = makeStderrSpy();
|
|
1335
|
+
expect(await runOp({ args: makeArgs({ path: "svc", components: true }), plugins: [], serializers: [] })).toBe(3);
|
|
1336
|
+
return stderr.join("\n");
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
test("unsealed gate: the approve line and the mismatch line carry no --sign", async () => {
|
|
1340
|
+
const out = await runGatedComponent();
|
|
1341
|
+
expect(out).toContain("approve : chant approve svc approve-prod --env prod");
|
|
1342
|
+
expect(out).toContain("needs a fresh one: chant approve svc approve-prod --env prod");
|
|
1343
|
+
expect(out).not.toContain("--sign");
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
test("sealed gate: the approve line and the mismatch line end in --sign", async () => {
|
|
1347
|
+
sealedGates.add("approve-prod");
|
|
1348
|
+
try {
|
|
1349
|
+
const out = await runGatedComponent();
|
|
1350
|
+
expect(out).toContain("approve : chant approve svc approve-prod --env prod --sign");
|
|
1351
|
+
expect(out).toContain("needs a fresh one: chant approve svc approve-prod --env prod --sign");
|
|
1352
|
+
} finally {
|
|
1353
|
+
sealedGates.clear();
|
|
1354
|
+
}
|
|
1355
|
+
});
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1144
1358
|
describe("runOpComponents", () => {
|
|
1145
1359
|
beforeEach(() => {
|
|
1146
1360
|
runComponentsMock.mockReset();
|
package/src/cli/handlers/run.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { stewardWorkHolder } from "../../op/work-lease-run";
|
|
|
7
7
|
import type { OpConfig } from "../../op/types";
|
|
8
8
|
import { loadActivities, loadProfiles } from "../../op/activity-registry";
|
|
9
9
|
import { runOpLocally, findPolicyGateStep, OpRunFailure, type StepRecord } from "../../op/local-executor";
|
|
10
|
-
import { approveCommand, describeGateMismatch } from "../../op/gate";
|
|
10
|
+
import { approveCommand, describeGateMismatch, gateIsSealed } from "../../op/gate";
|
|
11
11
|
import { summaryLedgerPrefix, writeGatedRunSummary, type GatedRunSummary } from "../../op/gate-summary";
|
|
12
12
|
import { createLocalOpRuntime } from "../../op/runtimes/local";
|
|
13
13
|
import type { OpRuntimeProvider, OpRunStatus } from "../../op/runtime";
|
|
@@ -77,8 +77,8 @@ function resolveGatedExitCode(ctx: CommandContext): number | undefined {
|
|
|
77
77
|
* passes `--gated-exit 0` reports success, and the one line that explains why
|
|
78
78
|
* a zero-exit run applied nothing belongs next to the gate itself.
|
|
79
79
|
*/
|
|
80
|
-
function reportGatedRun(summary: GatedRunSummary, exitCode: number): void {
|
|
81
|
-
writeGatedRunSummary(summary);
|
|
80
|
+
async function reportGatedRun(summary: GatedRunSummary, exitCode: number): Promise<void> {
|
|
81
|
+
writeGatedRunSummary({ ...summary, sealed: await gateIsSealed(summary.gate) });
|
|
82
82
|
if (exitCode !== GATED_EXIT_CODE) {
|
|
83
83
|
console.error(formatInfo(
|
|
84
84
|
`gated: exiting ${exitCode} because --gated-exit asked for it. Nothing after the gate ran.`,
|
|
@@ -466,7 +466,10 @@ export async function runOpApprove(ctx: CommandContext): Promise<number> {
|
|
|
466
466
|
}
|
|
467
467
|
|
|
468
468
|
try {
|
|
469
|
-
|
|
469
|
+
// `--env` on the approve line names the environment the woken run runs
|
|
470
|
+
// in (#3539); without it the runtime keeps the gated run's.
|
|
471
|
+
if (ctx.args.env !== undefined) await runtime.resolveGate(opName, gate, outcome.record, { env: ctx.args.env });
|
|
472
|
+
else await runtime.resolveGate(opName, gate, outcome.record);
|
|
470
473
|
} catch (err) {
|
|
471
474
|
console.error(formatWarning({
|
|
472
475
|
message:
|
|
@@ -962,10 +965,11 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
|
|
|
962
965
|
}));
|
|
963
966
|
// #2574: an approval stands, but for another plan or for none. Say so,
|
|
964
967
|
// with the command that replaces it.
|
|
968
|
+
const sealed = await gateIsSealed(gate.gate);
|
|
965
969
|
if (result.gated.mismatch) {
|
|
966
|
-
console.error(formatWarning({ message: describeGateMismatch(gate.op, gate.gate, result.gated.mismatch) }));
|
|
970
|
+
console.error(formatWarning({ message: describeGateMismatch(gate.op, gate.gate, result.gated.mismatch, sealed) }));
|
|
967
971
|
}
|
|
968
|
-
console.error(formatInfo(`approve : ${approveCommand(gate.op, gate.gate, gate.environment)}`));
|
|
972
|
+
console.error(formatInfo(`approve : ${approveCommand(gate.op, gate.gate, gate.environment, undefined, sealed)}`));
|
|
969
973
|
if (gate.url) console.error(formatInfo(`approve at: ${gate.url}`));
|
|
970
974
|
console.error(formatInfo(`expires : ${gate.expiresAt}`));
|
|
971
975
|
// #2310: this run's own append reached only the local chant/lifecycle
|
|
@@ -978,7 +982,7 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
|
|
|
978
982
|
hint: "an operator working from a clone of the remote cannot approve it until it does",
|
|
979
983
|
}));
|
|
980
984
|
}
|
|
981
|
-
reportGatedRun(
|
|
985
|
+
await reportGatedRun(
|
|
982
986
|
{
|
|
983
987
|
op: gate.op,
|
|
984
988
|
gate: gate.gate,
|
|
@@ -1074,8 +1078,8 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
|
|
|
1074
1078
|
|
|
1075
1079
|
// The work lease (#2748). `--work` names the item an Op with a work lease
|
|
1076
1080
|
// runs under; it is taken where the run executes, which for a hosted
|
|
1077
|
-
// runtime is not this process.
|
|
1078
|
-
const work = await resolveRunWork(ctx, config, runtime
|
|
1081
|
+
// runtime that carries it (fountain, #3555) is not this process.
|
|
1082
|
+
const work = await resolveRunWork(ctx, config, runtime);
|
|
1079
1083
|
if (work === null) {
|
|
1080
1084
|
await stewardGate?.release?.();
|
|
1081
1085
|
return 1;
|
|
@@ -1107,8 +1111,13 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
|
|
|
1107
1111
|
process.on("SIGTERM", onSigint);
|
|
1108
1112
|
|
|
1109
1113
|
try {
|
|
1114
|
+
// `--param name=value` reaches the runtime (#3539). The local runtime
|
|
1115
|
+
// has no use for it; fountain reads `agent` to pick the steward and posts
|
|
1116
|
+
// the rest on the command line it hands the sandbox.
|
|
1117
|
+
const params = parseParamFlags(ctx.args.param);
|
|
1110
1118
|
const handle = await runtime.start(config, {
|
|
1111
1119
|
env: ctx.args.env,
|
|
1120
|
+
...(params ? { params } : {}),
|
|
1112
1121
|
...(ctx.args.profile !== undefined ? { profile: ctx.args.profile } : {}),
|
|
1113
1122
|
progress,
|
|
1114
1123
|
signal: controller.signal,
|
|
@@ -1119,13 +1128,14 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
|
|
|
1119
1128
|
if (status.result) {
|
|
1120
1129
|
// The executor's own render already prints a gate, its approve line
|
|
1121
1130
|
// and its expiry (#2119).
|
|
1122
|
-
|
|
1131
|
+
const sealed = status.result.gate ? await gateIsSealed(status.result.gate.gate) : false;
|
|
1132
|
+
if (ctx.args.json) renderJson(status.result, undefined, sealed); else renderHuman(status.result, undefined, sealed);
|
|
1123
1133
|
} else {
|
|
1124
1134
|
renderRuntimeStatus("Op", opName, runtime.name, status);
|
|
1125
1135
|
if (status.state === "gated" && status.gate) {
|
|
1126
1136
|
console.error(formatWarning({
|
|
1127
1137
|
message: `Op "${opName}" is waiting on gate "${status.gate.name}"`,
|
|
1128
|
-
hint: `Record the resolution with: ${approveCommand(opName, status.gate.name)}`,
|
|
1138
|
+
hint: `Record the resolution with: ${approveCommand(opName, status.gate.name, undefined, undefined, await gateIsSealed(status.gate.name))}`,
|
|
1129
1139
|
}));
|
|
1130
1140
|
}
|
|
1131
1141
|
}
|
|
@@ -1140,7 +1150,7 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
|
|
|
1140
1150
|
const pending = status.result?.gate;
|
|
1141
1151
|
const gate = pending?.gate ?? status.gate?.name;
|
|
1142
1152
|
if (gate) {
|
|
1143
|
-
reportGatedRun(
|
|
1153
|
+
await reportGatedRun(
|
|
1144
1154
|
{
|
|
1145
1155
|
op: opName,
|
|
1146
1156
|
gate,
|
|
@@ -1175,7 +1185,8 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
|
|
|
1175
1185
|
return status.state === "completed" ? 0 : 1;
|
|
1176
1186
|
} catch (err) {
|
|
1177
1187
|
if (err instanceof OpRunFailure) {
|
|
1178
|
-
|
|
1188
|
+
const sealed = err.result.gate ? await gateIsSealed(err.result.gate.gate) : false;
|
|
1189
|
+
if (ctx.args.json) renderJson(err.result, undefined, sealed); else renderHuman(err.result, undefined, sealed);
|
|
1179
1190
|
return 1;
|
|
1180
1191
|
}
|
|
1181
1192
|
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
@@ -1197,12 +1208,17 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
|
|
|
1197
1208
|
* `null` after printing why the run is refused. The holder is `--holder`, or,
|
|
1198
1209
|
* for an Op a steward lists, `<steward>/<op>@<process>` so `workspace status`
|
|
1199
1210
|
* shows the lease beside the steward's Ops, or this process's id.
|
|
1211
|
+
*
|
|
1212
|
+
* A non-local runtime takes `--work` only when it declares `carriesWork`
|
|
1213
|
+
* (#3555), and then gets just what the caller named: the holder is chosen
|
|
1214
|
+
* where the run executes, not after this process.
|
|
1200
1215
|
*/
|
|
1201
1216
|
async function resolveRunWork(
|
|
1202
1217
|
ctx: CommandContext,
|
|
1203
1218
|
config: OpConfig,
|
|
1204
|
-
|
|
1219
|
+
provider: Pick<OpRuntimeProvider, "name" | "carriesWork">,
|
|
1205
1220
|
): Promise<{ item?: string; holder?: string } | undefined | null> {
|
|
1221
|
+
const runtime = provider.name;
|
|
1206
1222
|
const item = ctx.args.work;
|
|
1207
1223
|
if (!config.workLease) {
|
|
1208
1224
|
if (item === undefined) return undefined;
|
|
@@ -1212,13 +1228,18 @@ async function resolveRunWork(
|
|
|
1212
1228
|
}));
|
|
1213
1229
|
return null;
|
|
1214
1230
|
}
|
|
1215
|
-
if (runtime !== "local" && (item !== undefined || ctx.args.holder !== undefined)) {
|
|
1231
|
+
if (runtime !== "local" && provider.carriesWork !== true && (item !== undefined || ctx.args.holder !== undefined)) {
|
|
1216
1232
|
console.error(formatError({
|
|
1217
1233
|
message: `--work and --holder run on the local runtime; "${runtime}" takes the work lease where it executes the run`,
|
|
1218
1234
|
hint: "Omit --on, or name the item in the Op's workLease.",
|
|
1219
1235
|
}));
|
|
1220
1236
|
return null;
|
|
1221
1237
|
}
|
|
1238
|
+
if (runtime !== "local" && provider.carriesWork === true) {
|
|
1239
|
+
const holderArg = ctx.args.holder;
|
|
1240
|
+
if (item === undefined && holderArg === undefined) return undefined;
|
|
1241
|
+
return { ...(item !== undefined ? { item } : {}), ...(holderArg !== undefined ? { holder: holderArg } : {}) };
|
|
1242
|
+
}
|
|
1222
1243
|
let holder = ctx.args.holder;
|
|
1223
1244
|
if (holder === undefined) {
|
|
1224
1245
|
try {
|
package/src/cli/main.ts
CHANGED
|
@@ -606,6 +606,10 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
606
606
|
// didn't support joined forms at all — now that it does, the joined
|
|
607
607
|
// form is just as valid as the space-separated one).
|
|
608
608
|
(result.param ??= []).push(args[++i]);
|
|
609
|
+
} else if (arg === "--lexicon-output") {
|
|
610
|
+
// `--lexicon-output <lexicon>=<path>` (repeatable); the joined form
|
|
611
|
+
// `--lexicon-output=otel=a.yml` arrives here already split at the first `=`.
|
|
612
|
+
(result.lexiconOutput ??= []).push(args[++i]);
|
|
609
613
|
} else if (arg === "--params-file") {
|
|
610
614
|
result.paramsFile = args[++i];
|
|
611
615
|
} else if (arg === "--projection") {
|
|
@@ -1308,7 +1312,11 @@ Component release ledger + status:
|
|
|
1308
1312
|
(chant approve pr-<n> pr-apply --plan <digest>);
|
|
1309
1313
|
a moved plan refuses with exit 3
|
|
1310
1314
|
(--require-review: the approver must have
|
|
1311
|
-
approved the pull request on the forge
|
|
1315
|
+
approved the pull request on the forge;
|
|
1316
|
+
--member <name>, both commands: a workspace
|
|
1317
|
+
member's loop, gate op pr-<n>-<member>;
|
|
1318
|
+
--resume <file> finishes an apply that failed
|
|
1319
|
+
partway under the same approval)
|
|
1312
1320
|
components release <env> Append one immutable release record
|
|
1313
1321
|
(--component <name> --digest <sha256:...>
|
|
1314
1322
|
[--git-sha <sha>] [--run-id <id>] [--actor <name>]);
|
|
@@ -1376,6 +1384,10 @@ Options:
|
|
|
1376
1384
|
- build: json (default) or yaml
|
|
1377
1385
|
- list: text (default) or json
|
|
1378
1386
|
- lint: stylish (default), json, or sarif
|
|
1387
|
+
--lexicon-output <lexicon>=<path>
|
|
1388
|
+
build: write that lexicon's output to its own file
|
|
1389
|
+
(repeatable). Lexicons not named go to --output when
|
|
1390
|
+
given, and are skipped otherwise
|
|
1379
1391
|
-d, --lexicon <name> Build only the specified lexicon (e.g. aws, gitlab);
|
|
1380
1392
|
import: use this lexicon instead of detecting one
|
|
1381
1393
|
--env <name> Active environment: sets CHANT_ENV so env-aware source
|
|
@@ -100,6 +100,52 @@ describe("neverOverMcp (chant#3447)", () => {
|
|
|
100
100
|
await expect(createOpReportTool().handler({ name: "tf-apply" })).resolves.toContain("# tf-apply");
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
// chant#3555: op-approve hands the runtime the env it was given, as `chant
|
|
104
|
+
// run approve --env` does, and nothing when it was given none.
|
|
105
|
+
async function repoWithPlainGate(dir: string): Promise<void> {
|
|
106
|
+
git(["init", "-q", "-b", "main"], dir);
|
|
107
|
+
git(["config", "user.email", "t@chant.dev"], dir);
|
|
108
|
+
git(["config", "user.name", "T"], dir);
|
|
109
|
+
writeFileSync(join(dir, "README.md"), "x\n");
|
|
110
|
+
git(["add", "."], dir);
|
|
111
|
+
git(["commit", "-q", "-m", "init"], dir);
|
|
112
|
+
await appendPendingGate(
|
|
113
|
+
{
|
|
114
|
+
op: "plain-op",
|
|
115
|
+
gate: "release",
|
|
116
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
117
|
+
origin: "cli",
|
|
118
|
+
expiresAt: "2999-01-01T00:00:00.000Z",
|
|
119
|
+
planDigest: `sha256:${"d".repeat(64)}`,
|
|
120
|
+
},
|
|
121
|
+
{ cwd: dir },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
test("op-approve passes env to the runtime's resolveGate (chant#3555)", async () => {
|
|
126
|
+
await withTestDir(async (dir) => {
|
|
127
|
+
await repoWithPlainGate(dir);
|
|
128
|
+
process.chdir(dir);
|
|
129
|
+
const result = (await createOpApproveTool().handler({ name: "plain-op", gate: "release", env: "prod" })) as {
|
|
130
|
+
runtimeNotified: boolean;
|
|
131
|
+
};
|
|
132
|
+
expect(result.runtimeNotified).toBe(true);
|
|
133
|
+
expect(resolveGate).toHaveBeenCalledWith("plain-op", "release", expect.objectContaining({ gate: "release" }), {
|
|
134
|
+
env: "prod",
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("op-approve with no env leaves the runtime to keep the gated run's (chant#3555)", async () => {
|
|
140
|
+
await withTestDir(async (dir) => {
|
|
141
|
+
await repoWithPlainGate(dir);
|
|
142
|
+
process.chdir(dir);
|
|
143
|
+
await createOpApproveTool().handler({ name: "plain-op", gate: "release" });
|
|
144
|
+
expect(resolveGate).toHaveBeenCalledTimes(1);
|
|
145
|
+
expect(resolveGate.mock.calls[0]).toHaveLength(3);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
103
149
|
async function repoWithFanOutGate(dir: string): Promise<void> {
|
|
104
150
|
git(["init", "-q", "-b", "main"], dir);
|
|
105
151
|
git(["config", "user.email", "t@chant.dev"], dir);
|
|
@@ -166,4 +212,78 @@ describe("neverOverMcp (chant#3447)", () => {
|
|
|
166
212
|
expect(outcome.ok).toBe(true);
|
|
167
213
|
});
|
|
168
214
|
});
|
|
215
|
+
|
|
216
|
+
// chant#3513: workspace-upgrade and the pull-request gate are undiscovered too.
|
|
217
|
+
const gateOnlyOps: Array<{ op: string; gate: string }> = [
|
|
218
|
+
{ op: "workspace-upgrade", gate: "." },
|
|
219
|
+
{ op: "pr-12", gate: "pr-apply" },
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
for (const { op, gate } of gateOnlyOps) {
|
|
223
|
+
async function repoWithGate(dir: string): Promise<void> {
|
|
224
|
+
git(["init", "-q", "-b", "main"], dir);
|
|
225
|
+
git(["config", "user.email", "t@chant.dev"], dir);
|
|
226
|
+
git(["config", "user.name", "T"], dir);
|
|
227
|
+
writeFileSync(join(dir, "README.md"), "x\n");
|
|
228
|
+
git(["add", "."], dir);
|
|
229
|
+
git(["commit", "-q", "-m", "init"], dir);
|
|
230
|
+
await appendPendingGate(
|
|
231
|
+
{
|
|
232
|
+
op,
|
|
233
|
+
gate,
|
|
234
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
235
|
+
origin: "cli",
|
|
236
|
+
expiresAt: "2999-01-01T00:00:00.000Z",
|
|
237
|
+
planDigest: `sha256:${"c".repeat(64)}`,
|
|
238
|
+
},
|
|
239
|
+
{ cwd: dir },
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
test(`${op}: the gate record carries neverOverMcp (chant#3513)`, async () => {
|
|
244
|
+
await withTestDir(async (dir) => {
|
|
245
|
+
await repoWithGate(dir);
|
|
246
|
+
const { pending } = await readGateLedger(op, { cwd: dir });
|
|
247
|
+
expect(pending[0].neverOverMcp).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test(`${op}: op-approve over MCP is refused and the ledger is unchanged (chant#3513)`, async () => {
|
|
252
|
+
await withTestDir(async (dir) => {
|
|
253
|
+
await repoWithGate(dir);
|
|
254
|
+
const before = await readGateLedger(op, { cwd: dir });
|
|
255
|
+
process.chdir(dir);
|
|
256
|
+
await expect(createOpApproveTool().handler({ name: op, gate })).rejects.toThrow(
|
|
257
|
+
/neverOverMcp.*whichever channel/s,
|
|
258
|
+
);
|
|
259
|
+
expect(await readGateLedger(op, { cwd: dir })).toEqual(before);
|
|
260
|
+
expect(resolveGate).not.toHaveBeenCalled();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test(`${op}: ACP is refused even with the same-origin override (chant#3513)`, async () => {
|
|
265
|
+
await withTestDir(async (dir) => {
|
|
266
|
+
await repoWithGate(dir);
|
|
267
|
+
const before = await readGateLedger(op, { cwd: dir });
|
|
268
|
+
process.chdir(dir);
|
|
269
|
+
const outcome = await recordGateApproval(op, gate, {
|
|
270
|
+
origin: "acp",
|
|
271
|
+
actor: "agent",
|
|
272
|
+
allowSameOrigin: true,
|
|
273
|
+
cwd: dir,
|
|
274
|
+
});
|
|
275
|
+
expect(outcome.ok).toBe(false);
|
|
276
|
+
expect(await readGateLedger(op, { cwd: dir })).toEqual(before);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test(`${op}: a person at a shell can still approve (chant#3513)`, async () => {
|
|
281
|
+
await withTestDir(async (dir) => {
|
|
282
|
+
await repoWithGate(dir);
|
|
283
|
+
process.chdir(dir);
|
|
284
|
+
const outcome = await recordGateApproval(op, gate, { origin: "cli", actor: "alex", cwd: dir });
|
|
285
|
+
expect(outcome.ok).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
}
|
|
169
289
|
});
|
|
@@ -37,7 +37,7 @@ describe("op-approve on the MCP channel (chant#2384)", () => {
|
|
|
37
37
|
// The rest of the tool is unchanged — this narrows one field, it does not
|
|
38
38
|
// remove the tool. The narrower alternative in the issue was to drop
|
|
39
39
|
// op-approve entirely; this keeps it useful for the cross-channel case.
|
|
40
|
-
expect(Object.keys(schema.properties).sort()).toEqual(["gate", "name", "note", "runtime", "url"]);
|
|
40
|
+
expect(Object.keys(schema.properties).sort()).toEqual(["env", "gate", "name", "note", "runtime", "url"]);
|
|
41
41
|
expect(schema.required.sort()).toEqual(["gate", "name"]);
|
|
42
42
|
});
|
|
43
43
|
|
package/src/cli/mcp/op-tools.ts
CHANGED
|
@@ -136,6 +136,9 @@ export function createOpRunTool(): ToolRegistration {
|
|
|
136
136
|
type: "object",
|
|
137
137
|
properties: {
|
|
138
138
|
name: { type: "string", description: "Op name (e.g. alb-deploy)" },
|
|
139
|
+
// chant#3560 — what `chant run --env` passes: the environment the
|
|
140
|
+
// run targets. Omitted, the runtime uses its own default.
|
|
141
|
+
env: { type: "string", description: "Environment the run targets (defaults to the runtime's own)" },
|
|
139
142
|
runtime: RUNTIME_PARAM,
|
|
140
143
|
},
|
|
141
144
|
required: ["name"],
|
|
@@ -153,7 +156,8 @@ export function createOpRunTool(): ToolRegistration {
|
|
|
153
156
|
}
|
|
154
157
|
|
|
155
158
|
const runtime = await runtimeFor(params.runtime);
|
|
156
|
-
const
|
|
159
|
+
const env = typeof params.env === "string" && params.env !== "" ? params.env : undefined;
|
|
160
|
+
const handle = await runtime.start(ops.get(name)!.config, env !== undefined ? { env } : {});
|
|
157
161
|
const status = await handle.result();
|
|
158
162
|
|
|
159
163
|
return {
|
|
@@ -227,6 +231,10 @@ export function createOpApproveTool(): ToolRegistration {
|
|
|
227
231
|
// the ledger recorded it indistinguishably from a name a person gave.
|
|
228
232
|
note: { type: "string", description: "Free-text context recorded on the resolution" },
|
|
229
233
|
url: { type: "string", description: "Absolute http/https URL this resolution happened at" },
|
|
234
|
+
// chant#3555 — what `chant run approve --env` passes: the
|
|
235
|
+
// environment the woken run runs in. Omitted, the runtime keeps the
|
|
236
|
+
// gated run's.
|
|
237
|
+
env: { type: "string", description: "Environment the woken run runs in (defaults to the gated run's)" },
|
|
230
238
|
runtime: RUNTIME_PARAM,
|
|
231
239
|
},
|
|
232
240
|
required: ["name", "gate"],
|
|
@@ -256,7 +264,11 @@ export function createOpApproveTool(): ToolRegistration {
|
|
|
256
264
|
);
|
|
257
265
|
}
|
|
258
266
|
|
|
259
|
-
if (runtime.resolveGate)
|
|
267
|
+
if (runtime.resolveGate) {
|
|
268
|
+
const env = typeof params.env === "string" && params.env !== "" ? params.env : undefined;
|
|
269
|
+
if (env !== undefined) await runtime.resolveGate(name, gate, outcome.record, { env });
|
|
270
|
+
else await runtime.resolveGate(name, gate, outcome.record);
|
|
271
|
+
}
|
|
260
272
|
|
|
261
273
|
return {
|
|
262
274
|
op: name,
|
|
@@ -499,12 +499,13 @@ describe("McpServer", () => {
|
|
|
499
499
|
expect(props.runtime).toBeDefined();
|
|
500
500
|
});
|
|
501
501
|
|
|
502
|
-
test("op-run has name (required) and runtime", async () => {
|
|
502
|
+
test("op-run has name (required), env and runtime", async () => {
|
|
503
503
|
const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
|
|
504
504
|
const result = response.result as { tools: Array<{ name: string; inputSchema: Record<string, unknown> }> };
|
|
505
505
|
const tool = result.tools.find((t) => t.name === "op-run")!;
|
|
506
506
|
const props = tool.inputSchema.properties as Record<string, unknown>;
|
|
507
507
|
expect(props.name).toBeDefined();
|
|
508
|
+
expect(props.env).toBeDefined();
|
|
508
509
|
expect(props.runtime).toBeDefined();
|
|
509
510
|
expect(tool.inputSchema.required).toContain("name");
|
|
510
511
|
});
|