@intentius/chant 0.89.0 → 0.91.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/cli/handlers/components.d.ts +8 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +14 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/serve.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +10 -5
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/types.d.ts +13 -5
- package/dist/cli/mcp/types.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.d.ts +55 -0
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -0
- package/dist/cli/registry.d.ts +31 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/verbs/vuln-scan.d.ts +72 -0
- package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +40 -5
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +72 -16
- package/dist/lifecycle/lease.d.ts.map +1 -1
- package/dist/lifecycle/member-ledger.d.ts +3 -2
- package/dist/lifecycle/member-ledger.d.ts.map +1 -1
- package/dist/lifecycle/plan-ledger.d.ts +114 -0
- package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
- package/dist/lifecycle/work-lease.d.ts +140 -0
- package/dist/lifecycle/work-lease.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +2 -2
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/discover.d.ts +25 -0
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/index.d.ts +9 -3
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/lifecycle-receipt-store.d.ts +34 -0
- package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
- package/dist/op/local-executor.d.ts +19 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +10 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/op-verb-class.d.ts.map +1 -1
- package/dist/op/operator.d.ts +29 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +9 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/step-output-ref.d.ts +2 -2
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/dist/op/steward.d.ts +140 -0
- package/dist/op/steward.d.ts.map +1 -0
- package/dist/op/types.d.ts +51 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/op/work-lease-decl.d.ts +18 -0
- package/dist/op/work-lease-decl.d.ts.map +1 -0
- package/dist/op/work-lease-run.d.ts +173 -0
- package/dist/op/work-lease-run.d.ts.map +1 -0
- package/dist/workspace/box-isolation.d.ts +99 -0
- package/dist/workspace/box-isolation.d.ts.map +1 -0
- package/dist/workspace/checks/box-isolation.d.ts +18 -0
- package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
- package/dist/workspace/checks/boxes.d.ts +71 -0
- package/dist/workspace/checks/boxes.d.ts.map +1 -0
- package/dist/workspace/checks/records.d.ts +1 -0
- package/dist/workspace/checks/records.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +10 -1
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/conformance/index.d.ts +42 -2
- package/dist/workspace/conformance/index.d.ts.map +1 -1
- package/dist/workspace/conformance/vitest.d.ts.map +1 -1
- package/dist/workspace/decide.d.ts +184 -0
- package/dist/workspace/decide.d.ts.map +1 -0
- package/dist/workspace/decision-points.schema.json +137 -0
- package/dist/workspace/declaration.d.ts +51 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +172 -0
- package/dist/workspace/declared-kinds.d.ts +12 -0
- package/dist/workspace/declared-kinds.d.ts.map +1 -1
- package/dist/workspace/points-cli.d.ts +113 -0
- package/dist/workspace/points-cli.d.ts.map +1 -0
- package/dist/workspace/points.d.ts +320 -0
- package/dist/workspace/points.d.ts.map +1 -0
- package/dist/workspace/reason-codes.d.ts +29 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-assets.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +12 -0
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +35 -3
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +12 -3
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/source-block.d.ts +85 -0
- package/dist/workspace/source-block.d.ts.map +1 -0
- package/dist/workspace/status-stewards.d.ts +121 -0
- package/dist/workspace/status-stewards.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +52 -1
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/work-cli.d.ts +78 -0
- package/dist/workspace/work-cli.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/handlers/components.test.ts +93 -0
- package/src/cli/handlers/components.ts +44 -3
- package/src/cli/handlers/operator.ts +107 -2
- package/src/cli/handlers/run.test.ts +19 -0
- package/src/cli/handlers/run.ts +53 -1
- package/src/cli/handlers/serve.ts +2 -1
- package/src/cli/main.test.ts +40 -0
- package/src/cli/main.ts +78 -2
- package/src/cli/mcp/docs-parity.test.ts +20 -2
- package/src/cli/mcp/server.ts +23 -6
- package/src/cli/mcp/types.ts +15 -2
- package/src/cli/mcp/workspace-tools.test.ts +211 -0
- package/src/cli/mcp/workspace-tools.ts +449 -0
- package/src/cli/registry.ts +31 -0
- package/src/components/verbs/vuln-scan.test.ts +124 -1
- package/src/components/verbs/vuln-scan.ts +142 -1
- package/src/lifecycle/git.ts +65 -15
- package/src/lifecycle/lease.test.ts +22 -0
- package/src/lifecycle/lease.ts +133 -29
- package/src/lifecycle/member-ledger.ts +3 -2
- package/src/lifecycle/plan-ledger.test.ts +148 -0
- package/src/lifecycle/plan-ledger.ts +158 -0
- package/src/lifecycle/work-lease.test.ts +236 -0
- package/src/lifecycle/work-lease.ts +426 -0
- package/src/op/builders.ts +5 -0
- package/src/op/discover.ts +71 -0
- package/src/op/index.ts +16 -3
- package/src/op/lifecycle-receipt-store.test.ts +60 -0
- package/src/op/lifecycle-receipt-store.ts +61 -0
- package/src/op/local-executor.ts +216 -18
- package/src/op/local-output.ts +13 -0
- package/src/op/op-ir.ts +14 -0
- package/src/op/op-verb-class.ts +6 -0
- package/src/op/operator.ts +75 -4
- package/src/op/runtime.ts +6 -0
- package/src/op/runtimes/local.ts +3 -0
- package/src/op/step-output-ref.ts +6 -2
- package/src/op/steward.test.ts +212 -0
- package/src/op/steward.ts +253 -0
- package/src/op/types.ts +53 -0
- package/src/op/work-lease-decl.ts +80 -0
- package/src/op/work-lease-run.test.ts +326 -0
- package/src/op/work-lease-run.ts +395 -0
- package/src/workspace/box-isolation.test.ts +261 -0
- package/src/workspace/box-isolation.ts +205 -0
- package/src/workspace/check-contract.test.ts +3 -1
- package/src/workspace/check.schema.json +15 -7
- package/src/workspace/checks/box-isolation.ts +68 -0
- package/src/workspace/checks/boxes.test.ts +197 -0
- package/src/workspace/checks/boxes.ts +307 -0
- package/src/workspace/checks/records.ts +25 -0
- package/src/workspace/checks.test.ts +7 -0
- package/src/workspace/checks.ts +19 -2
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +100 -6
- package/src/workspace/conformance/index.mjs +3 -0
- package/src/workspace/conformance/index.ts +185 -7
- package/src/workspace/conformance/vitest.ts +17 -8
- package/src/workspace/decide.test.ts +224 -0
- package/src/workspace/decide.ts +576 -0
- package/src/workspace/decision-points.schema.json +137 -0
- package/src/workspace/declaration.schema.json +172 -0
- package/src/workspace/declaration.ts +137 -0
- package/src/workspace/declared-kinds.ts +25 -2
- package/src/workspace/intent.schema.json +4 -1
- package/src/workspace/point-answer.schema.json +95 -0
- package/src/workspace/points-cli.ts +273 -0
- package/src/workspace/points-write.schema.json +489 -0
- package/src/workspace/points.schema.json +710 -0
- package/src/workspace/points.test.ts +264 -0
- package/src/workspace/points.ts +564 -0
- package/src/workspace/read-contract.test.ts +18 -1
- package/src/workspace/reason-codes.test.ts +14 -1
- package/src/workspace/reason-codes.ts +36 -0
- package/src/workspace/record-assets.test.ts +3 -2
- package/src/workspace/record-assets.ts +4 -1
- package/src/workspace/records-amend.schema.json +2 -1
- package/src/workspace/records-cli.ts +15 -2
- package/src/workspace/records-close.schema.json +2 -1
- package/src/workspace/records-contract.test.ts +3 -2
- package/src/workspace/records-new.schema.json +4 -1
- package/src/workspace/records-review.schema.json +2 -1
- package/src/workspace/records-write.ts +85 -7
- package/src/workspace/records.schema.json +18 -0
- package/src/workspace/records.ts +61 -3
- package/src/workspace/source-block.test.ts +167 -0
- package/src/workspace/source-block.ts +129 -0
- package/src/workspace/status-contract.test.ts +178 -0
- package/src/workspace/status-stewards.ts +225 -0
- package/src/workspace/status.schema.json +230 -4
- package/src/workspace/status.ts +106 -5
- package/src/workspace/work-cli.test.ts +180 -0
- package/src/workspace/work-cli.ts +246 -0
- package/src/workspace/work-lease.schema.json +233 -0
- package/src/workspace/work-readiness-chud.test.ts +145 -0
|
@@ -49,6 +49,16 @@ vi.mock("../../lifecycle/build-ledger-store", () => ({
|
|
|
49
49
|
readBuildManifest: (...args: unknown[]) => readBuildManifestMock(...args),
|
|
50
50
|
}));
|
|
51
51
|
|
|
52
|
+
const persistReleasePlanMock = vi.fn();
|
|
53
|
+
|
|
54
|
+
vi.mock("../../lifecycle/plan-ledger", async () => {
|
|
55
|
+
const actual = await vi.importActual<typeof import("../../lifecycle/plan-ledger")>("../../lifecycle/plan-ledger");
|
|
56
|
+
return {
|
|
57
|
+
...actual,
|
|
58
|
+
persistReleasePlan: (...args: unknown[]) => persistReleasePlanMock(...args),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
|
|
52
62
|
vi.mock("../../lifecycle/release-ledger", async () => {
|
|
53
63
|
const actual = await vi.importActual<typeof import("../../lifecycle/release-ledger")>("../../lifecycle/release-ledger");
|
|
54
64
|
return {
|
|
@@ -137,6 +147,7 @@ describe("components handlers", () => {
|
|
|
137
147
|
discoverComponentsMock.mockReset().mockResolvedValue({ components: new Map(), sourceFiles: [], errors: [] });
|
|
138
148
|
findBuildManifestByArtifactDigestMock.mockReset().mockResolvedValue(undefined);
|
|
139
149
|
readBuildManifestMock.mockReset().mockResolvedValue(null);
|
|
150
|
+
persistReleasePlanMock.mockReset().mockResolvedValue({ commit: "a".repeat(40), written: true });
|
|
140
151
|
|
|
141
152
|
delete process.env.GITHUB_RUN_ID;
|
|
142
153
|
delete process.env.CI_PIPELINE_ID;
|
|
@@ -222,6 +233,88 @@ describe("components handlers", () => {
|
|
|
222
233
|
await runComponentsReleaseRecord(ctx);
|
|
223
234
|
expect(JSON.parse(stdoutBuf.join(""))).toMatchObject(record);
|
|
224
235
|
});
|
|
236
|
+
|
|
237
|
+
describe("--plan (ws-055, #2733)", () => {
|
|
238
|
+
let planDir: string;
|
|
239
|
+
|
|
240
|
+
beforeEach(async () => {
|
|
241
|
+
planDir = await mkdtemp(join(tmpdir(), "chant-plan-"));
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
afterEach(async () => {
|
|
245
|
+
await rm(planDir, { recursive: true, force: true });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("persists the plan and records its own digest, when --digest is omitted", async () => {
|
|
249
|
+
const planPath = join(planDir, "plan.json");
|
|
250
|
+
await writeFile(planPath, JSON.stringify({ digest: "sha256:fromplan", release: "r-1", units: [] }));
|
|
251
|
+
appendReleaseRecordMock.mockResolvedValue({
|
|
252
|
+
commit: "a".repeat(40),
|
|
253
|
+
record: { version: 1, component: "svc", env: "prod", digest: "sha256:fromplan", gitSha: "abc123headsha", runId: "local-123", timestamp: "2026-01-01T00:00:00.000Z", actor: "alice" },
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const ctx = { args: makeArgs({ extraPositional: "prod", component: "svc", releasePlanFile: planPath, actor: "alice" }), plugins: [], serializers: [] };
|
|
257
|
+
const exit = await runComponentsReleaseRecord(ctx);
|
|
258
|
+
|
|
259
|
+
expect(exit).toBe(0);
|
|
260
|
+
expect(persistReleasePlanMock).toHaveBeenCalledTimes(1);
|
|
261
|
+
expect(persistReleasePlanMock.mock.calls[0][0]).toEqual({ digest: "sha256:fromplan", release: "r-1", units: [] });
|
|
262
|
+
const [input] = appendReleaseRecordMock.mock.calls[0];
|
|
263
|
+
expect(input).toMatchObject({ digest: "sha256:fromplan" });
|
|
264
|
+
// The plan is persisted before the release record is appended, so the
|
|
265
|
+
// record's digest always names a plan this checkout can resolve.
|
|
266
|
+
expect(persistReleasePlanMock.mock.invocationCallOrder[0]).toBeLessThan(appendReleaseRecordMock.mock.invocationCallOrder[0]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("--digest agreeing with the plan's own digest is accepted", async () => {
|
|
270
|
+
const planPath = join(planDir, "plan.json");
|
|
271
|
+
await writeFile(planPath, JSON.stringify({ digest: "sha256:agree", release: "r-1" }));
|
|
272
|
+
appendReleaseRecordMock.mockResolvedValue({ commit: "a".repeat(40), record: { version: 1, component: "svc", env: "prod", digest: "sha256:agree", gitSha: "x", runId: "y", timestamp: "z", actor: "alice" } });
|
|
273
|
+
|
|
274
|
+
const ctx = { args: makeArgs({ extraPositional: "prod", component: "svc", digest: "sha256:agree", releasePlanFile: planPath, actor: "alice" }), plugins: [], serializers: [] };
|
|
275
|
+
const exit = await runComponentsReleaseRecord(ctx);
|
|
276
|
+
expect(exit).toBe(0);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("--digest disagreeing with the plan's own digest is refused", async () => {
|
|
280
|
+
const planPath = join(planDir, "plan.json");
|
|
281
|
+
await writeFile(planPath, JSON.stringify({ digest: "sha256:plandigest" }));
|
|
282
|
+
|
|
283
|
+
const ctx = { args: makeArgs({ extraPositional: "prod", component: "svc", digest: "sha256:other", releasePlanFile: planPath, actor: "alice" }), plugins: [], serializers: [] };
|
|
284
|
+
const exit = await runComponentsReleaseRecord(ctx);
|
|
285
|
+
expect(exit).toBe(1);
|
|
286
|
+
expect(stderrBuf.join("\n")).toContain("does not match --release-plan");
|
|
287
|
+
expect(persistReleasePlanMock).not.toHaveBeenCalled();
|
|
288
|
+
expect(appendReleaseRecordMock).not.toHaveBeenCalled();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("a plan file with no digest field is refused", async () => {
|
|
292
|
+
const planPath = join(planDir, "plan.json");
|
|
293
|
+
await writeFile(planPath, JSON.stringify({ release: "r-1" }));
|
|
294
|
+
|
|
295
|
+
const ctx = { args: makeArgs({ extraPositional: "prod", component: "svc", releasePlanFile: planPath, actor: "alice" }), plugins: [], serializers: [] };
|
|
296
|
+
const exit = await runComponentsReleaseRecord(ctx);
|
|
297
|
+
expect(exit).toBe(1);
|
|
298
|
+
expect(stderrBuf.join("\n")).toContain("missing its own \"digest\" field");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("a plan file that is not valid JSON is refused", async () => {
|
|
302
|
+
const planPath = join(planDir, "plan.json");
|
|
303
|
+
await writeFile(planPath, "{not json");
|
|
304
|
+
|
|
305
|
+
const ctx = { args: makeArgs({ extraPositional: "prod", component: "svc", releasePlanFile: planPath, actor: "alice" }), plugins: [], serializers: [] };
|
|
306
|
+
const exit = await runComponentsReleaseRecord(ctx);
|
|
307
|
+
expect(exit).toBe(1);
|
|
308
|
+
expect(stderrBuf.join("\n")).toContain("is not valid JSON");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("a --plan path that can't be read is refused", async () => {
|
|
312
|
+
const ctx = { args: makeArgs({ extraPositional: "prod", component: "svc", releasePlanFile: join(planDir, "missing.json"), actor: "alice" }), plugins: [], serializers: [] };
|
|
313
|
+
const exit = await runComponentsReleaseRecord(ctx);
|
|
314
|
+
expect(exit).toBe(1);
|
|
315
|
+
expect(stderrBuf.join("\n")).toContain("Could not read --release-plan");
|
|
316
|
+
});
|
|
317
|
+
});
|
|
225
318
|
});
|
|
226
319
|
|
|
227
320
|
describe("runComponentsStatus", () => {
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
resolveRunId,
|
|
39
39
|
InvalidReleaseRecordError,
|
|
40
40
|
} from "../../lifecycle/release-ledger";
|
|
41
|
+
import { persistReleasePlan, InvalidReleasePlanError, type ReleasePlan } from "../../lifecycle/plan-ledger";
|
|
41
42
|
import { reconcileStatus, liveEvidenceFromChangeSet, compareAcrossEnvironments, mergeLiveEvidence, type LiveComponentEvidence, type EntityLiveRead } from "../../lifecycle/status";
|
|
42
43
|
import { commandBuildParams } from "../build-params-cli";
|
|
43
44
|
import { buildChangeSet } from "../../lifecycle/change-set";
|
|
@@ -75,6 +76,14 @@ import { withoutReadFlags } from "../../lifecycle/legacy-digest";
|
|
|
75
76
|
* never threaded in from elsewhere and never mocked in production code, per
|
|
76
77
|
* #568: "the session cannot call Date.now() in some contexts; take the
|
|
77
78
|
* timestamp from the environment/CLI at record time."
|
|
79
|
+
*
|
|
80
|
+
* `--release-plan <file>` (ws-055, #2733) takes `--digest`'s place: the file is a
|
|
81
|
+
* release plan JSON object naming its own `digest` field, persisted
|
|
82
|
+
* content-addressed to `_plans/<digest>.json` (../../lifecycle/plan-
|
|
83
|
+
* ledger.ts) before the release record is appended, so the record's digest
|
|
84
|
+
* always names a plan this checkout can resolve. Passing both requires them
|
|
85
|
+
* to agree — a plan is content-addressed by its own digest, so a caller
|
|
86
|
+
* naming a different one is a mistake, not an override.
|
|
78
87
|
*/
|
|
79
88
|
export async function runComponentsReleaseRecord(ctx: CommandContext): Promise<number> {
|
|
80
89
|
const { args } = ctx;
|
|
@@ -87,11 +96,42 @@ export async function runComponentsReleaseRecord(ctx: CommandContext): Promise<n
|
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
const component = args.component;
|
|
90
|
-
|
|
99
|
+
let digest = args.digest;
|
|
100
|
+
let plan: ReleasePlan | undefined;
|
|
101
|
+
if (args.releasePlanFile) {
|
|
102
|
+
let raw: string;
|
|
103
|
+
try {
|
|
104
|
+
raw = await readFile(args.releasePlanFile, "utf-8");
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error(formatError({ message: `Could not read --release-plan ${args.releasePlanFile}: ${err instanceof Error ? err.message : String(err)}` }));
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
let parsed: unknown;
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(raw);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error(formatError({ message: `--release-plan ${args.releasePlanFile} is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }));
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
117
|
+
console.error(formatError({ message: `--release-plan ${args.releasePlanFile} must be a JSON object` }));
|
|
118
|
+
return 1;
|
|
119
|
+
}
|
|
120
|
+
plan = parsed as ReleasePlan;
|
|
121
|
+
if (typeof plan.digest !== "string" || plan.digest.length === 0) {
|
|
122
|
+
console.error(formatError({ message: `--release-plan ${args.releasePlanFile} is missing its own "digest" field — a release plan is content-addressed by its own digest` }));
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
if (digest !== undefined && digest !== plan.digest) {
|
|
126
|
+
console.error(formatError({ message: `--digest ${digest} does not match --release-plan ${args.releasePlanFile}'s own digest ${plan.digest}` }));
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
digest = plan.digest;
|
|
130
|
+
}
|
|
91
131
|
if (!component || !digest) {
|
|
92
132
|
console.error(formatError({
|
|
93
133
|
message: "--component and --digest are required",
|
|
94
|
-
hint: "chant components release <env> --component <name> --digest <sha256:...> [--git-sha <sha>] [--run-id <id>] [--actor <name>] [--approver <name>]",
|
|
134
|
+
hint: "chant components release <env> --component <name> --digest <sha256:...> [--release-plan <file>] [--git-sha <sha>] [--run-id <id>] [--actor <name>] [--approver <name>]",
|
|
95
135
|
}));
|
|
96
136
|
return 1;
|
|
97
137
|
}
|
|
@@ -121,6 +161,7 @@ export async function runComponentsReleaseRecord(ctx: CommandContext): Promise<n
|
|
|
121
161
|
const timestamp = new Date().toISOString();
|
|
122
162
|
|
|
123
163
|
try {
|
|
164
|
+
if (plan) await persistReleasePlan(plan);
|
|
124
165
|
const { commit, record } = await appendReleaseRecord({
|
|
125
166
|
component,
|
|
126
167
|
env: environment,
|
|
@@ -142,7 +183,7 @@ export async function runComponentsReleaseRecord(ctx: CommandContext): Promise<n
|
|
|
142
183
|
}
|
|
143
184
|
return 0;
|
|
144
185
|
} catch (err) {
|
|
145
|
-
if (err instanceof InvalidReleaseRecordError) {
|
|
186
|
+
if (err instanceof InvalidReleaseRecordError || err instanceof InvalidReleasePlanError) {
|
|
146
187
|
console.error(formatError({ message: err.message }));
|
|
147
188
|
return 1;
|
|
148
189
|
}
|
|
@@ -11,7 +11,8 @@ import { loadChantConfig } from "../../config";
|
|
|
11
11
|
import { build } from "../../build";
|
|
12
12
|
import { isResourceDeclarable } from "../../declarable";
|
|
13
13
|
import { collectBuildRootContributors, collectChangeSubscribers } from "../plugins";
|
|
14
|
-
import { discoverOps } from "../../op/discover";
|
|
14
|
+
import { discoverOps, discoverStewards } from "../../op/discover";
|
|
15
|
+
import { pickSteward, stewardFormFor, stewardLeaseName, DEFAULT_STEWARD_ENV } from "../../op/steward";
|
|
15
16
|
import { loadActivities, loadProfiles } from "../../op/activity-registry";
|
|
16
17
|
import { parseDuration } from "../../op/local-executor";
|
|
17
18
|
import {
|
|
@@ -21,11 +22,12 @@ import {
|
|
|
21
22
|
formatRoundLine,
|
|
22
23
|
formatSignalLine,
|
|
23
24
|
DEFAULT_OPERATOR_INTERVAL_MS,
|
|
25
|
+
acquireStewardLease,
|
|
24
26
|
type ChangeSubscriber,
|
|
25
27
|
type OperatorSignalEvent,
|
|
26
28
|
type OperatorTickEvent,
|
|
27
29
|
} from "../../op/operator";
|
|
28
|
-
import { readLease, DEFAULT_LEASE_TTL_MS } from "../../lifecycle/lease";
|
|
30
|
+
import { readLease, releaseLease, currentHolderId, DEFAULT_LEASE_TTL_MS } from "../../lifecycle/lease";
|
|
29
31
|
import { readConvergeLedger, type ConvergeTickRecord } from "../../lifecycle/converge-ledger";
|
|
30
32
|
import { readRunLedger } from "../../lifecycle/run-ledger";
|
|
31
33
|
import type { OpRunRecord } from "../../op/runtime";
|
|
@@ -141,6 +143,7 @@ async function collectOperatorSubscribers(
|
|
|
141
143
|
* leaving the daemon running); omitted, the daemon loops until Ctrl-C.
|
|
142
144
|
*/
|
|
143
145
|
export async function runOperator(ctx: CommandContext): Promise<number> {
|
|
146
|
+
if (ctx.args.steward !== undefined) return runStewardOperator(ctx);
|
|
144
147
|
const { ops, errors } = await discoverConvergeOps({ env: ctx.args.env });
|
|
145
148
|
for (const err of errors) console.error(formatWarning({ message: err }));
|
|
146
149
|
|
|
@@ -205,6 +208,108 @@ export async function runOperator(ctx: CommandContext): Promise<number> {
|
|
|
205
208
|
}
|
|
206
209
|
}
|
|
207
210
|
|
|
211
|
+
// ── chant operator --steward ────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* `chant operator --steward [<name>] [--env <env>] [--interval] [--lease-ttl]
|
|
215
|
+
* [--once]` (#2731) — a steward's local form. The same Ops the steward runs on
|
|
216
|
+
* Fountain, on the same crons, one at a time, each under its operator lease,
|
|
217
|
+
* and the whole process under the steward's own lease.
|
|
218
|
+
*
|
|
219
|
+
* Refuses, before ticking anything:
|
|
220
|
+
* - an environment whose form is `fountain`, because there the composite's
|
|
221
|
+
* Agent and Teammate are the steward and this process would be a second
|
|
222
|
+
* writer beside them;
|
|
223
|
+
* - a steward whose lease another live process holds (a second local
|
|
224
|
+
* operator for it).
|
|
225
|
+
*/
|
|
226
|
+
export async function runStewardOperator(ctx: CommandContext): Promise<number> {
|
|
227
|
+
const { stewards, errors, conflicts } = await discoverStewards();
|
|
228
|
+
for (const err of errors) console.error(formatWarning({ message: err }));
|
|
229
|
+
for (const conflict of conflicts) console.error(formatError({ message: conflict }));
|
|
230
|
+
|
|
231
|
+
const picked = pickSteward(stewards, ctx.args.steward ?? "");
|
|
232
|
+
if (typeof picked === "string") {
|
|
233
|
+
console.error(formatError({ message: picked }));
|
|
234
|
+
return 1;
|
|
235
|
+
}
|
|
236
|
+
const steward = picked;
|
|
237
|
+
const env = ctx.args.env ?? DEFAULT_STEWARD_ENV;
|
|
238
|
+
const form = stewardFormFor(steward, env);
|
|
239
|
+
if (form !== "local") {
|
|
240
|
+
console.error(formatError({
|
|
241
|
+
message: `steward "${steward.name}" runs on ${form} in environment "${env}", so a local operator for it would be a second writer`,
|
|
242
|
+
hint: `Its turns run on the fountain teammate "${steward.name}". Run this in an environment the declaration's form makes local, or change the form.`,
|
|
243
|
+
}));
|
|
244
|
+
return 1;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let activities, profiles;
|
|
248
|
+
try {
|
|
249
|
+
[activities, profiles] = await loadOperatorActivities();
|
|
250
|
+
} catch (err) {
|
|
251
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
252
|
+
return 1;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const intervalMs = ctx.args.interval ? parseDuration(ctx.args.interval) : DEFAULT_OPERATOR_INTERVAL_MS;
|
|
256
|
+
const leaseTtlMs = ctx.args.leaseTtl ? parseDuration(ctx.args.leaseTtl) : DEFAULT_LEASE_TTL_MS;
|
|
257
|
+
const holder = currentHolderId();
|
|
258
|
+
|
|
259
|
+
// Refuse a second local operator up front rather than letting it loop on
|
|
260
|
+
// `steward-busy` rounds: one steward is one writer.
|
|
261
|
+
const first = await acquireStewardLease(steward.name, holder, { ttlMs: leaseTtlMs });
|
|
262
|
+
if (!first.acquired) {
|
|
263
|
+
console.error(formatError({
|
|
264
|
+
message: `steward "${steward.name}" is already running (lease held by ${first.heldBy?.holder ?? "another process"} until ${first.heldBy?.expiresAt ?? "?"})`,
|
|
265
|
+
hint: "One steward is one writer. Stop the other operator, or wait for its lease to expire.",
|
|
266
|
+
}));
|
|
267
|
+
return 1;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const controller = new AbortController();
|
|
271
|
+
const onSigint = () => {
|
|
272
|
+
console.error(formatWarning({ message: "interrupted — stopping steward" }));
|
|
273
|
+
controller.abort();
|
|
274
|
+
};
|
|
275
|
+
process.once("SIGINT", onSigint);
|
|
276
|
+
process.once("SIGTERM", onSigint);
|
|
277
|
+
|
|
278
|
+
const printRound = (events: OperatorTickEvent[]) => {
|
|
279
|
+
for (const event of events) console.error(formatInfo(formatRoundLine(event)));
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const scheduled = steward.ops.filter((op) => op.schedule !== undefined);
|
|
283
|
+
try {
|
|
284
|
+
if (ctx.args.once) {
|
|
285
|
+
const events = await runOperatorRound({ steward, holder, leaseTtlMs, activities, profiles, signal: controller.signal });
|
|
286
|
+
printRound(events);
|
|
287
|
+
return events.some((e) => e.kind === "tick-failed" || e.kind === "steward-busy") ? 1 : 0;
|
|
288
|
+
}
|
|
289
|
+
console.error(formatInfo(
|
|
290
|
+
`chant operator: steward ${steward.name} (local) runs ${scheduled.length} scheduled Op(s) of ${steward.ops.length}, ` +
|
|
291
|
+
`checking every ${intervalMs}ms (Ctrl-C to stop)`,
|
|
292
|
+
));
|
|
293
|
+
await runOperatorForever({
|
|
294
|
+
steward,
|
|
295
|
+
holder,
|
|
296
|
+
intervalMs,
|
|
297
|
+
leaseTtlMs,
|
|
298
|
+
activities,
|
|
299
|
+
profiles,
|
|
300
|
+
signal: controller.signal,
|
|
301
|
+
onRound: printRound,
|
|
302
|
+
});
|
|
303
|
+
return 0;
|
|
304
|
+
} finally {
|
|
305
|
+
process.removeListener("SIGINT", onSigint);
|
|
306
|
+
process.removeListener("SIGTERM", onSigint);
|
|
307
|
+
// A courtesy: the lease expires on its own if this never runs.
|
|
308
|
+
const { record } = await readLease(stewardLeaseName(steward.name)).catch(() => ({ record: undefined }));
|
|
309
|
+
if (record?.holder === holder) await releaseLease(stewardLeaseName(steward.name), holder, record.token).catch(() => false);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
208
313
|
// ── chant operator status ───────────────────────────────────────────────────
|
|
209
314
|
|
|
210
315
|
interface OpStatusLine {
|
|
@@ -199,6 +199,25 @@ describe("runOp dispatcher", () => {
|
|
|
199
199
|
stderrWrite.mockRestore();
|
|
200
200
|
});
|
|
201
201
|
|
|
202
|
+
test("--work on an Op with no work lease → exit 1, nothing run (#2748)", async () => {
|
|
203
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("hello")]), errors: [] });
|
|
204
|
+
const stderr = makeStderrSpy();
|
|
205
|
+
const exit = await runOp({ args: makeArgs({ path: "hello", work: "W-1" }), plugins: [], serializers: [] });
|
|
206
|
+
expect(exit).toBe(1);
|
|
207
|
+
expect(stderr.join("\n")).toContain('--work W-1: Op "hello" declares no work lease');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("an Op whose work lease leaves the item to the run, run without --work → exit 1 naming the flag (#2748)", async () => {
|
|
211
|
+
discoverOpsMock.mockResolvedValue({
|
|
212
|
+
ops: new Map([["leased", { config: { name: "leased", overview: "leased", phases: [], workLease: {} } }]]),
|
|
213
|
+
errors: [],
|
|
214
|
+
});
|
|
215
|
+
const stderr = makeStderrSpy();
|
|
216
|
+
const exit = await runOp({ args: makeArgs({ path: "leased", holder: "me" }), plugins: [], serializers: [] });
|
|
217
|
+
expect(exit).toBe(1);
|
|
218
|
+
expect(stderr.join("\n")).toContain("chant run leased --work <id>");
|
|
219
|
+
});
|
|
220
|
+
|
|
202
221
|
test("--report → exit 1 naming the removal, nothing run (#2116)", async () => {
|
|
203
222
|
discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("hello")]), errors: [] });
|
|
204
223
|
const stderr = makeStderrSpy();
|
package/src/cli/handlers/run.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { lexiconNames } from "../../lexicon-module";
|
|
|
2
2
|
import { resolve, dirname } from "node:path";
|
|
3
3
|
import { writeFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
4
4
|
import { loadChantConfig, resolveAutoReleaseDisabled, type ChantConfig } from "../../config";
|
|
5
|
-
import { discoverOps } from "../../op/discover";
|
|
5
|
+
import { discoverOps, discoverStewards } from "../../op/discover";
|
|
6
|
+
import { stewardWorkHolder } from "../../op/work-lease-run";
|
|
6
7
|
import type { OpConfig } from "../../op/types";
|
|
7
8
|
import { loadActivities, loadProfiles } from "../../op/activity-registry";
|
|
8
9
|
import { runOpLocally, findPolicyGateStep, OpRunFailure, type StepRecord } from "../../op/local-executor";
|
|
@@ -866,6 +867,12 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
|
|
|
866
867
|
const runtime = await resolveOpRuntime(ctx);
|
|
867
868
|
if (!runtime) return 1;
|
|
868
869
|
|
|
870
|
+
// The work lease (#2748). `--work` names the item an Op with a work lease
|
|
871
|
+
// runs under; it is taken where the run executes, which for a hosted
|
|
872
|
+
// runtime is not this process.
|
|
873
|
+
const work = await resolveRunWork(ctx, config, runtime.name);
|
|
874
|
+
if (work === null) return 1;
|
|
875
|
+
|
|
869
876
|
// `--progress-json` streams one NDJSON StepRecord per settled step, fed by
|
|
870
877
|
// whatever the runtime reports through `progress`.
|
|
871
878
|
const progress = ctx.args.progressJson ? ndjsonProgressSink<StepRecord>() : undefined;
|
|
@@ -885,6 +892,7 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
|
|
|
885
892
|
...(ctx.args.profile !== undefined ? { profile: ctx.args.profile } : {}),
|
|
886
893
|
progress,
|
|
887
894
|
signal: controller.signal,
|
|
895
|
+
...(work ? { work } : {}),
|
|
888
896
|
});
|
|
889
897
|
const status = await handle.result();
|
|
890
898
|
|
|
@@ -945,6 +953,50 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
|
|
|
945
953
|
}
|
|
946
954
|
}
|
|
947
955
|
|
|
956
|
+
/**
|
|
957
|
+
* The work item and holder a `chant run` passes to the runtime (#2748), or
|
|
958
|
+
* `null` after printing why the run is refused. The holder is `--holder`, or,
|
|
959
|
+
* for an Op a steward lists, `<steward>/<op>@<process>` so `workspace status`
|
|
960
|
+
* shows the lease beside the steward's Ops, or this process's id.
|
|
961
|
+
*/
|
|
962
|
+
async function resolveRunWork(
|
|
963
|
+
ctx: CommandContext,
|
|
964
|
+
config: OpConfig,
|
|
965
|
+
runtime: string,
|
|
966
|
+
): Promise<{ item?: string; holder?: string } | undefined | null> {
|
|
967
|
+
const item = ctx.args.work;
|
|
968
|
+
if (!config.workLease) {
|
|
969
|
+
if (item === undefined) return undefined;
|
|
970
|
+
console.error(formatError({
|
|
971
|
+
message: `--work ${item}: Op "${config.name}" declares no work lease`,
|
|
972
|
+
hint: "Declare workLease on the Op to run it under a work item's lease.",
|
|
973
|
+
}));
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
if (runtime !== "local" && (item !== undefined || ctx.args.holder !== undefined)) {
|
|
977
|
+
console.error(formatError({
|
|
978
|
+
message: `--work and --holder run on the local runtime; "${runtime}" takes the work lease where it executes the run`,
|
|
979
|
+
hint: "Omit --on, or name the item in the Op's workLease.",
|
|
980
|
+
}));
|
|
981
|
+
return null;
|
|
982
|
+
}
|
|
983
|
+
let holder = ctx.args.holder;
|
|
984
|
+
if (holder === undefined) {
|
|
985
|
+
try {
|
|
986
|
+
const { stewards } = await discoverStewards();
|
|
987
|
+
for (const { declaration } of stewards.values()) {
|
|
988
|
+
if (declaration.ops.some((op) => op.name === config.name)) {
|
|
989
|
+
holder = stewardWorkHolder(declaration.name, config.name);
|
|
990
|
+
break;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
} catch {
|
|
994
|
+
// No steward can be read: the run holds the lease as this process.
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
return { ...(item !== undefined ? { item } : {}), ...(holder !== undefined ? { holder } : {}) };
|
|
998
|
+
}
|
|
999
|
+
|
|
948
1000
|
// ── fallback ────────────────────────────────────────────────────────────────���─
|
|
949
1001
|
|
|
950
1002
|
export function runOpUnknown(ctx: CommandContext): Promise<number> {
|
|
@@ -20,7 +20,8 @@ export async function runServeMcp(ctx: CommandContext): Promise<number> {
|
|
|
20
20
|
const { loadWorkspacePlugins } = await import("../mcp/workspace-plugins");
|
|
21
21
|
({ plugins, instructions } = await loadWorkspacePlugins(process.cwd()));
|
|
22
22
|
}
|
|
23
|
-
|
|
23
|
+
// #2707 — at or inside a declared workspace, the workspace tools are served too.
|
|
24
|
+
const server = new McpServer(plugins, { instructions, workspace: { cwd: process.cwd() } });
|
|
24
25
|
await server.start();
|
|
25
26
|
await new Promise(() => {});
|
|
26
27
|
return 0; // unreachable
|
package/src/cli/main.test.ts
CHANGED
|
@@ -276,6 +276,18 @@ describe("parseArgs", () => {
|
|
|
276
276
|
expect(result.actor).toBe("alice");
|
|
277
277
|
});
|
|
278
278
|
|
|
279
|
+
test("parses --release-plan for components release, distinct from --plan (#2300)'s approval-digest flag (ws-055, #2733)", () => {
|
|
280
|
+
const result = parseArgs([
|
|
281
|
+
"components", "release", "prod",
|
|
282
|
+
"--component", "search-service",
|
|
283
|
+
"--release-plan", "plan.json",
|
|
284
|
+
"--actor", "alice",
|
|
285
|
+
]);
|
|
286
|
+
expect(result.releasePlanFile).toBe("plan.json");
|
|
287
|
+
expect(result.digest).toBeUndefined();
|
|
288
|
+
expect(result.plan).toBeUndefined();
|
|
289
|
+
});
|
|
290
|
+
|
|
279
291
|
test("--digest repeats for components promote, and --digest-file is parsed (#2602)", () => {
|
|
280
292
|
const promote = parseArgs([
|
|
281
293
|
"components", "promote", "--from", "staging", "--to", "prod",
|
|
@@ -734,13 +746,28 @@ describe("workspace init and ls (#2534)", () => {
|
|
|
734
746
|
"workspace lint",
|
|
735
747
|
"workspace ls",
|
|
736
748
|
"workspace member-run",
|
|
749
|
+
"workspace points",
|
|
737
750
|
"workspace records",
|
|
738
751
|
"workspace status",
|
|
739
752
|
"workspace upgrade",
|
|
740
753
|
"workspace verify",
|
|
754
|
+
"workspace work",
|
|
741
755
|
]);
|
|
742
756
|
});
|
|
743
757
|
|
|
758
|
+
test("workspace work takes its verb, id and lease flags (#2732)", () => {
|
|
759
|
+
const claim = parseArgs(["workspace", "work", "claim", "W-001", "--holder", "a", "--ttl", "60", "--json"]);
|
|
760
|
+
expect(claim).toMatchObject({ command: "workspace", path: "work", extraPositional: "claim", extraPositional2: "W-001", holder: "a", ttl: "60", json: true });
|
|
761
|
+
expect(resolveCommand(claim, commandRegistry)?.def.name).toBe("workspace work");
|
|
762
|
+
expect(parseArgs(["workspace", "work", "release", "W-001", "--holder=a", "--token", "t", "--outcome", "done"])).toMatchObject({ holder: "a", token: "t", outcome: "done" });
|
|
763
|
+
expect(() => parseArgs(["workspace", "work", "claim", "W-001", "--holder"])).toThrow(/--holder needs a value/);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
test("run takes --work <id> for an Op with a work lease (#2748)", () => {
|
|
767
|
+
expect(parseArgs(["run", "dispatch", "--work", "W-001", "--holder", "box/dispatch@h"])).toMatchObject({ command: "run", path: "dispatch", work: "W-001", holder: "box/dispatch@h" });
|
|
768
|
+
expect(() => parseArgs(["run", "dispatch", "--work"])).toThrow(/--work needs a work item id/);
|
|
769
|
+
});
|
|
770
|
+
|
|
744
771
|
test("load their modules only when they run", () => {
|
|
745
772
|
const source = readFileSync(join(import.meta.dirname, "main.ts"), "utf-8");
|
|
746
773
|
expect(source).toMatch(/await import\("\.\.\/workspace\/init"\)/);
|
|
@@ -748,6 +775,19 @@ describe("workspace init and ls (#2534)", () => {
|
|
|
748
775
|
});
|
|
749
776
|
});
|
|
750
777
|
|
|
778
|
+
describe("workspace points (ws-058, #2739)", () => {
|
|
779
|
+
test("ask and answer take their flags, and --by repeats", () => {
|
|
780
|
+
const ask = parseArgs(["workspace", "points", "ask", "slice-tier", "--inputs", "in.json", "--response", "r.json", "--subject", "W-001", "--dry-run"]);
|
|
781
|
+
expect(ask).toMatchObject({ path: "points", extraPositional: "ask", extraPositional2: "slice-tier", inputs: "in.json", response: "r.json", subject: "W-001", dryRun: true });
|
|
782
|
+
expect(resolveCommand(ask, commandRegistry)?.def.name).toBe("workspace points");
|
|
783
|
+
expect(parseArgs(["workspace", "points", "ask", "p", "--inputs", "-"]).inputs).toBe("-");
|
|
784
|
+
const answer = parseArgs(["workspace", "points", "answer", "slice-tier-0123456789ab", "--answer", "medium", "--by", "alice", "--by", "bob"]);
|
|
785
|
+
expect(answer).toMatchObject({ extraPositional: "answer", answer: "medium", bys: ["alice", "bob"] });
|
|
786
|
+
expect(parseArgs(["workspace", "points", "--open", "--json"])).toMatchObject({ open: true, json: true });
|
|
787
|
+
expect(() => parseArgs(["workspace", "points", "ask", "p", "--response", "--json"])).toThrow(/--response needs a JSON file/);
|
|
788
|
+
});
|
|
789
|
+
});
|
|
790
|
+
|
|
751
791
|
describe("workspace status (#2544)", () => {
|
|
752
792
|
test("takes the environment as extra positional, a directory after it, and --compare-to", () => {
|
|
753
793
|
const status = parseArgs(["workspace", "status", "staging", "apps", "--compare-to", "prod", "--json"]);
|