@intentius/chant 0.86.0 → 0.88.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/misc.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +5 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/version.d.ts +8 -0
- package/dist/cli/version.d.ts.map +1 -0
- package/dist/workspace/__fixtures__/sessions.d.ts +9 -0
- package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +13 -0
- package/dist/workspace/composites.d.ts.map +1 -1
- package/dist/workspace/environments.d.ts +80 -0
- package/dist/workspace/environments.d.ts.map +1 -0
- package/dist/workspace/intent.d.ts +6 -1
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +20 -4
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +12 -2
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-close.d.ts +41 -0
- package/dist/workspace/records-close.d.ts.map +1 -0
- package/dist/workspace/records-since.d.ts +40 -1
- package/dist/workspace/records-since.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +117 -12
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +84 -14
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/runtimes.d.ts +6 -0
- package/dist/workspace/runtimes.d.ts.map +1 -1
- package/dist/workspace/session-kinds.d.ts +28 -0
- package/dist/workspace/session-kinds.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +2 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +127 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -0
- package/dist/workspace/trust/ssh-commit.d.ts +7 -0
- package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
- package/dist/workspace/work.d.ts +3 -3
- package/package.json +1 -1
- package/src/cli/handlers/misc.ts +1 -9
- package/src/cli/main.ts +29 -10
- package/src/cli/mcp/server.test.ts +14 -1
- package/src/cli/mcp/server.ts +3 -1
- package/src/cli/registry.ts +5 -0
- package/src/cli/version.ts +15 -0
- package/src/workspace/__fixtures__/sessions.ts +41 -0
- package/src/workspace/composites.schema.json +68 -3
- package/src/workspace/composites.test.ts +119 -5
- package/src/workspace/composites.ts +26 -7
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +23 -0
- package/src/workspace/environments.ts +165 -0
- package/src/workspace/intent-gaps.test.ts +217 -0
- package/src/workspace/intent.schema.json +23 -1
- package/src/workspace/intent.test.ts +2 -0
- package/src/workspace/intent.ts +35 -3
- package/src/workspace/read-contract.test.ts +3 -0
- package/src/workspace/reason-codes.test.ts +9 -3
- package/src/workspace/reason-codes.ts +24 -4
- package/src/workspace/record-assets.test.ts +4 -3
- package/src/workspace/records-amend.schema.json +30 -1
- package/src/workspace/records-cli.ts +113 -14
- package/src/workspace/records-close.schema.json +192 -0
- package/src/workspace/records-close.ts +129 -0
- package/src/workspace/records-contract.test.ts +4 -3
- package/src/workspace/records-new.schema.json +25 -0
- package/src/workspace/records-review.schema.json +81 -3
- package/src/workspace/records-sessions-write.test.ts +274 -0
- package/src/workspace/records-since.schema.json +27 -2
- package/src/workspace/records-since.ts +120 -6
- package/src/workspace/records-write-contract.test.ts +5 -1
- package/src/workspace/records-write.test.ts +4 -2
- package/src/workspace/records-write.ts +336 -43
- package/src/workspace/records.schema.json +37 -3
- package/src/workspace/records.ts +145 -25
- package/src/workspace/runtimes.ts +12 -3
- package/src/workspace/session-kinds.ts +79 -0
- package/src/workspace/status.ts +1 -1
- package/src/workspace/trust/record-seal.test.ts +315 -0
- package/src/workspace/trust/seal.test.ts +222 -0
- package/src/workspace/trust/seal.ts +289 -0
- package/src/workspace/trust/ssh-commit.ts +2 -2
- package/src/workspace/work.test.ts +3 -1
- package/src/workspace/work.ts +4 -4
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* (`read-contract.test.ts`).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
11
12
|
import { join } from "node:path";
|
|
12
13
|
import { afterAll, describe, expect, test } from "vitest";
|
|
13
14
|
import type { GraphIR, IRNode } from "../graph-ir";
|
|
@@ -15,6 +16,7 @@ import { cleanScratch, commitAll, contract, declaration, FAKE_FILE_GRAPH_CHANT,
|
|
|
15
16
|
import { MEMBER_RUN_REASON_CODES } from "./compose-graph";
|
|
16
17
|
import {
|
|
17
18
|
COMPOSITES_CONTRACT_VERSION,
|
|
19
|
+
COMPOSITES_ENVIRONMENT_REASON_CODES,
|
|
18
20
|
COMPOSITES_ERROR_CODES,
|
|
19
21
|
COMPOSITES_OUTPUT_SCHEMA_ID,
|
|
20
22
|
COMPOSITES_REASON_CODES,
|
|
@@ -58,6 +60,9 @@ const componentIr = (components: { name: string; composites?: string[]; archetyp
|
|
|
58
60
|
/** The built-in runtime every component has. */
|
|
59
61
|
const local = (name: string) => ({ name: "local", lexicon: null, default: true, command: `chant run --components ${name}` });
|
|
60
62
|
|
|
63
|
+
/** The default environment, named by nothing but chant itself. */
|
|
64
|
+
const localEnv = (name: string) => ({ name: "local", default: true, source: "builtin", command: `chant run --components ${name}` });
|
|
65
|
+
|
|
61
66
|
/**
|
|
62
67
|
* app declares three instances and exports ImageUri; delivery links to it and
|
|
63
68
|
* declares two components; jobs declares an instance and a component of its
|
|
@@ -111,6 +116,7 @@ describe("composites output schema", () => {
|
|
|
111
116
|
expect(COMPOSITES_ERROR_CODES).toEqual(GRAPH_ERROR_CODES);
|
|
112
117
|
expect(schema.$defs.member.properties.reason.oneOf[1].properties!.code.enum).toEqual([...MEMBER_RUN_REASON_CODES]);
|
|
113
118
|
expect(schema.$defs.member.properties.runtimeReasons.items.properties.code.enum).toEqual([...COMPOSITES_RUNTIME_REASON_CODES]);
|
|
119
|
+
expect(schema.$defs.member.properties.environmentReasons.items.properties.code.enum).toEqual([...COMPOSITES_ENVIRONMENT_REASON_CODES]);
|
|
114
120
|
});
|
|
115
121
|
});
|
|
116
122
|
|
|
@@ -122,7 +128,7 @@ describe("the join", () => {
|
|
|
122
128
|
{ ...composite("a/billingTable", "Table", "a/billing"), member: "a" },
|
|
123
129
|
],
|
|
124
130
|
});
|
|
125
|
-
const component = (member: string, name: string, composites: string[] | null = null): ComponentEntry => ({ id: `${member}/${name}`, name, member, archetype: null, composites, file: null, runtimes: [] });
|
|
131
|
+
const component = (member: string, name: string, composites: string[] | null = null): ComponentEntry => ({ id: `${member}/${name}`, name, member, archetype: null, composites, file: null, runtimes: [], environments: [] });
|
|
126
132
|
|
|
127
133
|
test("an instance carries every kind and lexicon of its nodes", () => {
|
|
128
134
|
expect(instances.map((i) => [i.id, i.instance, i.kinds, i.lexicons])).toEqual([
|
|
@@ -165,9 +171,9 @@ describe("chant workspace graph --composites on a built workspace", () => {
|
|
|
165
171
|
["docs", "skipped", "kind-not-run"],
|
|
166
172
|
]);
|
|
167
173
|
expect(g.components).toEqual([
|
|
168
|
-
{ id: "delivery/edge", name: "edge", member: "delivery", archetype: "infra", composites: ["StaticSite"], file: "delivery/src/edge.component.ts", runtimes: [local("edge")] },
|
|
169
|
-
{ id: "delivery/loom-backend", name: "loom-backend", member: "delivery", archetype: "service", composites: null, file: "delivery/src/loom-backend.component.ts", runtimes: [local("loom-backend")] },
|
|
170
|
-
{ id: "jobs/queue-runner", name: "queue-runner", member: "jobs", archetype: null, composites: ["WorkQueue", "CacheCluster"], file: "jobs/src/queue-runner.component.ts", runtimes: [local("queue-runner")] },
|
|
174
|
+
{ id: "delivery/edge", name: "edge", member: "delivery", archetype: "infra", composites: ["StaticSite"], file: "delivery/src/edge.component.ts", runtimes: [local("edge")], environments: [localEnv("edge")] },
|
|
175
|
+
{ id: "delivery/loom-backend", name: "loom-backend", member: "delivery", archetype: "service", composites: null, file: "delivery/src/loom-backend.component.ts", runtimes: [local("loom-backend")], environments: [localEnv("loom-backend")] },
|
|
176
|
+
{ id: "jobs/queue-runner", name: "queue-runner", member: "jobs", archetype: null, composites: ["WorkQueue", "CacheCluster"], file: "jobs/src/queue-runner.component.ts", runtimes: [local("queue-runner")], environments: [localEnv("queue-runner")] },
|
|
171
177
|
]);
|
|
172
178
|
const rows = Object.fromEntries(g.composites.map((c) => [c.id, c]));
|
|
173
179
|
expect(Object.keys(rows)).toEqual(["app/backend", "app/cache", "app/site", "jobs/queue"]);
|
|
@@ -332,3 +338,111 @@ describe("the runtimes each component can deploy on (#2674)", () => {
|
|
|
332
338
|
]);
|
|
333
339
|
});
|
|
334
340
|
});
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Write `files` as the whole tree of a `chant/lifecycle` commit, the way the
|
|
344
|
+
* lifecycle code stores ledgers, without touching the working tree or index.
|
|
345
|
+
*/
|
|
346
|
+
function lifecycle(root: string, files: Record<string, string>): void {
|
|
347
|
+
const index = join(root, ".git", "composites-test-index");
|
|
348
|
+
const run = (args: string[], input?: string) =>
|
|
349
|
+
execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", ...args], {
|
|
350
|
+
cwd: root,
|
|
351
|
+
encoding: "utf-8",
|
|
352
|
+
input,
|
|
353
|
+
env: { ...process.env, GIT_INDEX_FILE: index },
|
|
354
|
+
}).trim();
|
|
355
|
+
rmSync(index, { force: true });
|
|
356
|
+
for (const [path, text] of Object.entries(files)) run(["update-index", "--add", "--cacheinfo", `100644,${run(["hash-object", "-w", "--stdin"], text)},${path}`]);
|
|
357
|
+
run(["update-ref", "refs/heads/chant/lifecycle", run(["commit-tree", run(["write-tree"]), "-m", "ledger"])]);
|
|
358
|
+
rmSync(index, { force: true });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** One release line; the environment list reads only which ledgers exist. */
|
|
362
|
+
const release = (component: string, env: string) => `${JSON.stringify({ version: 1, component, env, digest: `sha256:${"a".repeat(64)}`, gitSha: "b".repeat(40), runId: "r1", timestamp: "2026-09-24T00:00:00.000Z", actor: "t" })}\n`;
|
|
363
|
+
|
|
364
|
+
describe("the environments each component may deploy to (#2695)", () => {
|
|
365
|
+
/** jobs declares staging, prod and the pattern pr-*; its ledger has releases in staging, pr-42 and retired. */
|
|
366
|
+
function declared(): string {
|
|
367
|
+
const root = fixture();
|
|
368
|
+
writeFileSync(join(root, "jobs", "chant.config.ts"), 'export default { environments: ["staging", { name: "prod", endpoint: "https://prod.example" }, "pr-*"] };\n');
|
|
369
|
+
lifecycle(root, {
|
|
370
|
+
"_members/jobs/staging/releases.jsonl": release("queue-runner", "staging"),
|
|
371
|
+
"_members/jobs/pr-42/releases.jsonl": release("queue-runner", "pr-42"),
|
|
372
|
+
"_members/jobs/retired/releases.jsonl": release("queue-runner", "retired"),
|
|
373
|
+
"_members/jobs/_gates/queue-runner.jsonl": "",
|
|
374
|
+
"_members/jobs/notes/README.md": "not a ledger\n",
|
|
375
|
+
"staging/releases.jsonl": release("edge", "staging"),
|
|
376
|
+
});
|
|
377
|
+
return root;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
test("local is the default, then the config's names in order, then the ledger's, each with its --env line", async () => {
|
|
381
|
+
const g = result((await workspaceComposites({ cwd: declared() })).doc);
|
|
382
|
+
expectValid(g);
|
|
383
|
+
expect(g.components.find((c) => c.id === "jobs/queue-runner")!.environments).toEqual([
|
|
384
|
+
{ name: "local", default: true, source: "builtin", command: "chant run --components queue-runner" },
|
|
385
|
+
{ name: "staging", default: false, source: "config", command: "chant run --components queue-runner --env staging" },
|
|
386
|
+
{ name: "prod", default: false, source: "config", command: "chant run --components queue-runner --env prod" },
|
|
387
|
+
// The pattern pr-* is no environment by itself, and it covers the ledger's pr-42.
|
|
388
|
+
{ name: "pr-42", default: false, source: "ledger", command: "chant run --components queue-runner --env pr-42" },
|
|
389
|
+
]);
|
|
390
|
+
const jobs = g.members.find((m) => m.name === "jobs")!;
|
|
391
|
+
expect(jobs.environmentReasons).toEqual([{ code: "environments-ledger-undeclared", message: expect.stringContaining("retired") }]);
|
|
392
|
+
expect(jobs.runtimeReasons).toEqual([]);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("a member with no _members directory reads the flat ledger, and a config that declares none says so", async () => {
|
|
396
|
+
const g = result((await workspaceComposites({ cwd: declared() })).doc);
|
|
397
|
+
// delivery has no _members/delivery, so the flat staging ledger is its; its config declares nothing, so anything goes.
|
|
398
|
+
expect(g.components.find((c) => c.id === "delivery/edge")!.environments).toEqual([
|
|
399
|
+
localEnv("edge"),
|
|
400
|
+
{ name: "staging", default: false, source: "ledger", command: "chant run --components edge --env staging" },
|
|
401
|
+
]);
|
|
402
|
+
expect(g.members.find((m) => m.name === "delivery")!.environmentReasons.map((r) => r.code)).toEqual(["environments-none-declared"]);
|
|
403
|
+
expect(g.members.find((m) => m.name === "docs")!.environmentReasons).toEqual([]);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("with no chant/lifecycle branch, the config's names are the list", async () => {
|
|
407
|
+
const root = fixture();
|
|
408
|
+
writeFileSync(join(root, "jobs", "chant.config.ts"), 'export default { environments: ["local", "prod"] };\n');
|
|
409
|
+
const g = result((await workspaceComposites({ cwd: root })).doc);
|
|
410
|
+
expectValid(g);
|
|
411
|
+
expect(g.components.find((c) => c.id === "jobs/queue-runner")!.environments).toEqual([
|
|
412
|
+
{ name: "local", default: true, source: "config", command: "chant run --components queue-runner" },
|
|
413
|
+
{ name: "prod", default: false, source: "config", command: "chant run --components queue-runner --env prod" },
|
|
414
|
+
]);
|
|
415
|
+
expect(g.members.find((m) => m.name === "jobs")!.environmentReasons).toEqual([]);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("a config that can't be read lists local and the ledger's, with the runtimes' code", async () => {
|
|
419
|
+
const root = declared();
|
|
420
|
+
writeFileSync(join(root, "jobs", "chant.config.ts"), 'throw new Error("no config here");\nexport default {};\n');
|
|
421
|
+
const g = result((await workspaceComposites({ cwd: root })).doc);
|
|
422
|
+
expectValid(g);
|
|
423
|
+
const jobs = g.members.find((m) => m.name === "jobs")!;
|
|
424
|
+
expect(jobs.environmentReasons).toEqual([{ code: "runtimes-config-unreadable", message: expect.stringContaining("no config here") }]);
|
|
425
|
+
expect(g.components.find((c) => c.id === "jobs/queue-runner")!.environments.map((e) => [e.name, e.source])).toEqual([
|
|
426
|
+
["local", "builtin"],
|
|
427
|
+
["pr-42", "ledger"],
|
|
428
|
+
["retired", "ledger"],
|
|
429
|
+
["staging", "ledger"],
|
|
430
|
+
]);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("a ledger that can't be listed says so, and the config's names are still listed", async () => {
|
|
434
|
+
const root = fixture();
|
|
435
|
+
writeFileSync(join(root, "jobs", "chant.config.ts"), 'export default { environments: ["prod"] };\n');
|
|
436
|
+
const g = result(
|
|
437
|
+
(
|
|
438
|
+
await workspaceComposites({
|
|
439
|
+
cwd: root,
|
|
440
|
+
readLedgerEnvironments: (m) => (m.name === "jobs" ? { envs: [], reason: { code: "environments-ledger-unreadable", message: "boom" } } : { envs: [], reason: null }),
|
|
441
|
+
})
|
|
442
|
+
).doc,
|
|
443
|
+
);
|
|
444
|
+
expectValid(g);
|
|
445
|
+
expect(g.members.find((m) => m.name === "jobs")!.environmentReasons).toEqual([{ code: "environments-ledger-unreadable", message: "boom" }]);
|
|
446
|
+
expect(g.components.find((c) => c.id === "jobs/queue-runner")!.environments.map((e) => e.name)).toEqual(["local", "prod"]);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
@@ -32,6 +32,11 @@
|
|
|
32
32
|
* Each component also lists the runtimes it can deploy on (#2674,
|
|
33
33
|
* `runtimes.ts`): `local`, and each lexicon its member configures that hosts
|
|
34
34
|
* `chant run --components`, with the command line for each.
|
|
35
|
+
*
|
|
36
|
+
* And each component lists the environments it may deploy to (#2695,
|
|
37
|
+
* `environments.ts`): `local`, the environments its member's config declares,
|
|
38
|
+
* and those with a release in the member's ledger on `chant/lifecycle`, with
|
|
39
|
+
* the command line for each.
|
|
35
40
|
*/
|
|
36
41
|
|
|
37
42
|
import { formatError } from "../cli/format";
|
|
@@ -44,6 +49,7 @@ import type { LinkRow } from "./links";
|
|
|
44
49
|
import { emitDocument, type UnitResult } from "./member-commands";
|
|
45
50
|
import { readerVersion, type ErrorLocation, type WorkspaceErrorCode } from "./declaration";
|
|
46
51
|
import type { ReasonCode } from "./reason-codes";
|
|
52
|
+
import { componentEnvironments, ENVIRONMENT_REASON_CODES, memberEnvironments, readLedgerEnvironments, type ComponentEnvironment, type EnvironmentReason, type LedgerEnvironmentReader, type MemberEnvironments } from "./environments";
|
|
47
53
|
import { componentRuntimes, readRuntimesIn, RUNTIME_REASON_CODES, type ComponentRuntime, type MemberRuntimes, type PluginLoader, type RuntimeReason } from "./runtimes";
|
|
48
54
|
|
|
49
55
|
/** `$id` of the JSON Schema for the document, shipped beside this file. */
|
|
@@ -69,6 +75,9 @@ export type CompositesReasonCode = (typeof COMPOSITES_REASON_CODES)[number];
|
|
|
69
75
|
/** Why a member's runtimes are only `local`, or leave out a lexicon its config lists (#2674). Closed. */
|
|
70
76
|
export const COMPOSITES_RUNTIME_REASON_CODES = RUNTIME_REASON_CODES;
|
|
71
77
|
|
|
78
|
+
/** Why a member's environments are only `local`, or leave out one its ledger has (#2695). Closed. */
|
|
79
|
+
export const COMPOSITES_ENVIRONMENT_REASON_CODES = ENVIRONMENT_REASON_CODES;
|
|
80
|
+
|
|
72
81
|
export interface CompositesMember {
|
|
73
82
|
name: string;
|
|
74
83
|
dir: string;
|
|
@@ -79,6 +88,8 @@ export interface CompositesMember {
|
|
|
79
88
|
chant: string | null;
|
|
80
89
|
/** Why its components' runtimes leave something out: its config, or a lexicon, couldn't be read. Empty for a member not of kind chant. */
|
|
81
90
|
runtimeReasons: RuntimeReason[];
|
|
91
|
+
/** Why its components' environments are only `local`, or leave one out. Empty for a member not of kind chant. */
|
|
92
|
+
environmentReasons: EnvironmentReason[];
|
|
82
93
|
}
|
|
83
94
|
|
|
84
95
|
export interface ComponentEntry {
|
|
@@ -94,6 +105,8 @@ export interface ComponentEntry {
|
|
|
94
105
|
file: string | null;
|
|
95
106
|
/** The runtimes it can deploy on: `local` first, then each hosting lexicon its member configures. */
|
|
96
107
|
runtimes: ComponentRuntime[];
|
|
108
|
+
/** The environments it may deploy to: `local` first, then the config's, then the ledger's. */
|
|
109
|
+
environments: ComponentEnvironment[];
|
|
97
110
|
}
|
|
98
111
|
|
|
99
112
|
export interface ComponentMatch {
|
|
@@ -145,7 +158,7 @@ export interface CompositesResult {
|
|
|
145
158
|
}
|
|
146
159
|
|
|
147
160
|
/** Read one member's component graph run into components, or the reason it can't be read. */
|
|
148
|
-
function readComponents(member: string, dir: string, run: UnitResult, runtimes: MemberRuntimes | undefined): { components: ComponentEntry[] } | { reason: MemberReason } {
|
|
161
|
+
function readComponents(member: string, dir: string, run: UnitResult, runtimes: MemberRuntimes | undefined, environments: MemberEnvironments | undefined): { components: ComponentEntry[] } | { reason: MemberReason } {
|
|
149
162
|
if (run.exitCode !== 0) {
|
|
150
163
|
const tail = run.stderr.trim().split("\n").slice(-5).join("\n");
|
|
151
164
|
return { reason: { code: "command-failed", message: `chant graph --components exited ${run.exitCode}${tail ? `: ${tail}` : ""}` } };
|
|
@@ -166,6 +179,7 @@ function readComponents(member: string, dir: string, run: UnitResult, runtimes:
|
|
|
166
179
|
composites: composites && composites.length > 0 ? composites : null,
|
|
167
180
|
file: file ? (dir === "." ? file : `${dir}/${file}`) : null,
|
|
168
181
|
runtimes: componentRuntimes(n.id, runtimes),
|
|
182
|
+
environments: componentEnvironments(n.id, environments, runtimes?.default),
|
|
169
183
|
});
|
|
170
184
|
}
|
|
171
185
|
return { components };
|
|
@@ -233,17 +247,19 @@ export function joinComponents(instances: CompositeInstanceRow[], components: Co
|
|
|
233
247
|
});
|
|
234
248
|
}
|
|
235
249
|
|
|
236
|
-
function memberEntry(m: ComposedMember, componentReason: MemberReason | null, runtimes: MemberRuntimes | undefined): CompositesMember {
|
|
250
|
+
function memberEntry(m: ComposedMember, componentReason: MemberReason | null, runtimes: MemberRuntimes | undefined, environments: MemberEnvironments | undefined): CompositesMember {
|
|
237
251
|
const reason = m.reason ?? componentReason;
|
|
238
252
|
const status = m.status === "composed" ? (componentReason ? "failed" : "read") : m.status;
|
|
239
|
-
return { name: m.name, dir: m.dir, kind: m.kind, status, reason, chant: m.chant, runtimeReasons: runtimes?.reasons ?? [] };
|
|
253
|
+
return { name: m.name, dir: m.dir, kind: m.kind, status, reason, chant: m.chant, runtimeReasons: runtimes?.reasons ?? [], environmentReasons: environments?.reasons ?? [] };
|
|
240
254
|
}
|
|
241
255
|
|
|
242
256
|
/** Read the workspace's composites and components, and build the document. Never throws a `WorkspaceReadError`. */
|
|
243
|
-
export async function workspaceComposites(
|
|
257
|
+
export async function workspaceComposites(
|
|
258
|
+
query: Omit<GraphQuery, "kind" | "components" | "inTree"> & { loadPlugin?: PluginLoader; readLedgerEnvironments?: LedgerEnvironmentReader },
|
|
259
|
+
): Promise<CompositesResult> {
|
|
244
260
|
const head: Head = { $schema: COMPOSITES_OUTPUT_SCHEMA_ID, contract: COMPOSITES_CONTRACT_VERSION, chant: readerVersion() };
|
|
245
261
|
let runtimes = new Map<string, MemberRuntimes>();
|
|
246
|
-
const { loadPlugin, ...graphQuery } = query;
|
|
262
|
+
const { loadPlugin, readLedgerEnvironments: readLedger = readLedgerEnvironments, ...graphQuery } = query;
|
|
247
263
|
const { doc: graph, failed, components: runs } = await workspaceGraph({
|
|
248
264
|
...graphQuery,
|
|
249
265
|
components: true,
|
|
@@ -259,15 +275,18 @@ export async function workspaceComposites(query: Omit<GraphQuery, "kind" | "comp
|
|
|
259
275
|
let componentsFailed = false;
|
|
260
276
|
for (const m of graph.members) {
|
|
261
277
|
const run = m.status === "composed" ? byMember.get(m.name) : undefined;
|
|
278
|
+
// The ledger is the local chant/lifecycle branch, read from the working tree even with --at, as workspace status reads it.
|
|
279
|
+
const memberRuntimes = runtimes.get(m.name);
|
|
280
|
+
const environments = memberRuntimes ? memberEnvironments(memberRuntimes, readLedger(m, query.cwd)) : undefined;
|
|
262
281
|
let reason: MemberReason | null = null;
|
|
263
282
|
if (run) {
|
|
264
|
-
const read = readComponents(m.name, m.dir, run,
|
|
283
|
+
const read = readComponents(m.name, m.dir, run, memberRuntimes, environments);
|
|
265
284
|
if ("reason" in read) {
|
|
266
285
|
reason = read.reason;
|
|
267
286
|
componentsFailed = true;
|
|
268
287
|
} else components.push(...read.components);
|
|
269
288
|
}
|
|
270
|
-
members.push(memberEntry(m, reason,
|
|
289
|
+
members.push(memberEntry(m, reason, memberRuntimes, environments));
|
|
271
290
|
}
|
|
272
291
|
components.sort((a, b) => a.id.localeCompare(b.id));
|
|
273
292
|
|
|
@@ -154,6 +154,17 @@
|
|
|
154
154
|
"type": "string",
|
|
155
155
|
"minLength": 1
|
|
156
156
|
},
|
|
157
|
+
"seal": {
|
|
158
|
+
"description": "The reviewer's ssh signature over the verdict (#2687): the bytes <id>\\n<digest>\\n<verdict>\\n<reviewer>\\n<on>, in the ssh-keygen namespace chant-review. chant workspace records review --sign writes it. Once a signers file is active at base, a verdict counts toward the quorum only when its seal verifies for its reviewer against that file. A seal needs a digest.",
|
|
159
|
+
"type": "object",
|
|
160
|
+
"additionalProperties": false,
|
|
161
|
+
"required": ["signer", "key", "signature"],
|
|
162
|
+
"properties": {
|
|
163
|
+
"signer": { "description": "Who signed: the reviewer, as the signers file names them.", "type": "string", "minLength": 1 },
|
|
164
|
+
"key": { "description": "The signing key's fingerprint. Reported, never trusted: the signature is the proof.", "type": "string", "pattern": "^SHA256:[A-Za-z0-9+/]+=*$" },
|
|
165
|
+
"signature": { "description": "The armored ssh signature, as ssh-keygen -Y sign prints it.", "type": "string", "pattern": "^-----BEGIN SSH SIGNATURE-----\\n" }
|
|
166
|
+
}
|
|
167
|
+
},
|
|
157
168
|
"proposes": {
|
|
158
169
|
"description": "With a dissent, the id of a new proposed decision the reviewer opened.",
|
|
159
170
|
"$ref": "#/definitions/decisionId"
|
|
@@ -172,6 +183,7 @@
|
|
|
172
183
|
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
|
|
173
184
|
}
|
|
174
185
|
},
|
|
186
|
+
"dependencies": { "seal": ["digest"] },
|
|
175
187
|
"if": {
|
|
176
188
|
"required": ["verdict"],
|
|
177
189
|
"properties": { "verdict": { "const": "dissent" } }
|
|
@@ -187,6 +199,17 @@
|
|
|
187
199
|
}
|
|
188
200
|
}
|
|
189
201
|
},
|
|
202
|
+
"seal": {
|
|
203
|
+
"description": "The author's ssh signature over the record (#2688): the bytes <id>\\n<digest>\\n<decided_by>\\n<state>, in the ssh-keygen namespace chant-record, where digest is the record's digest, which leaves this field and reviews out. chant workspace records new --sign and amend --sign write it, and an amendment without --sign removes it. Optional: under a signers file at base, a record with decided_by and no seal that verifies is read with the warning record-unattested.",
|
|
204
|
+
"type": "object",
|
|
205
|
+
"additionalProperties": false,
|
|
206
|
+
"required": ["signer", "key", "signature"],
|
|
207
|
+
"properties": {
|
|
208
|
+
"signer": { "description": "Who signed: decided_by, as the signers file names them.", "type": "string", "minLength": 1 },
|
|
209
|
+
"key": { "description": "The signing key's fingerprint. Reported, never trusted: the signature is the proof.", "type": "string", "pattern": "^SHA256:[A-Za-z0-9+/]+=*$" },
|
|
210
|
+
"signature": { "description": "The armored ssh signature, as ssh-keygen -Y sign prints it.", "type": "string", "pattern": "^-----BEGIN SSH SIGNATURE-----\\n" }
|
|
211
|
+
}
|
|
212
|
+
},
|
|
190
213
|
"constrains": {
|
|
191
214
|
"description": "What this decision governs: issues (owner/repo#n), other decisions (their ids), workspace members (member:<name>) or files and directories in the workspace (path:<path>, the grammar of an evidence path, #2549). At least one entry: a decision that governs nothing is refused.",
|
|
192
215
|
"type": "array",
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environments a member's components may deploy to, for `chant workspace
|
|
3
|
+
* graph --composites` (#2695), so a reader such as hud can offer staging or
|
|
4
|
+
* prod beside the default without guessing a name.
|
|
5
|
+
*
|
|
6
|
+
* Each environment comes from one of three places, and says which:
|
|
7
|
+
*
|
|
8
|
+
* - `config`: a name the member's `chant.config.ts` declares in
|
|
9
|
+
* `environments`. A pattern such as `pr-*` names no environment by itself
|
|
10
|
+
* and is not listed, but it legalizes the ledger's `pr-42`.
|
|
11
|
+
* - `ledger`: an environment with a release ledger for the member on
|
|
12
|
+
* `chant/lifecycle` (`_members/<member>/<env>/releases.jsonl`, or the flat
|
|
13
|
+
* `<env>/releases.jsonl`, read the way `workspace status` reads it).
|
|
14
|
+
* - `builtin`: `local`, which `chant run --components` uses without `--env`
|
|
15
|
+
* (`../cli/handlers/run.ts`), when neither the config nor the ledger names
|
|
16
|
+
* it.
|
|
17
|
+
*
|
|
18
|
+
* The component contract declares no environments, so every component of a
|
|
19
|
+
* member lists the member's environments.
|
|
20
|
+
*
|
|
21
|
+
* `chant run --env` refuses a name the config's `environments` doesn't cover
|
|
22
|
+
* whenever it declares any (`unknownEnvError` in `../env.ts`), so a ledger
|
|
23
|
+
* environment the config no longer covers is left out, with a reason. The
|
|
24
|
+
* default is `local`: `chant.config.ts` has no default environment, and
|
|
25
|
+
* `chant run` without `--env` deploys to `local` even when the config's
|
|
26
|
+
* `environments` leaves it out.
|
|
27
|
+
*
|
|
28
|
+
* The config is the one `runtimes.ts` loads, in the working tree or the tree
|
|
29
|
+
* `--at` exported. The ledger is always the local `chant/lifecycle` branch,
|
|
30
|
+
* as for `workspace status`, and is never fetched.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { execFileSync } from "node:child_process";
|
|
34
|
+
import { environmentName, isEnvironmentPattern, matchesDeclaredEnvironment } from "../config";
|
|
35
|
+
import type { ReasonCode } from "./reason-codes";
|
|
36
|
+
import { LOCAL_RUNTIME, type MemberRuntimes } from "./runtimes";
|
|
37
|
+
import { ENV_PATTERN, LIFECYCLE_REF, MEMBERS_DIR } from "./status";
|
|
38
|
+
|
|
39
|
+
/** The environment `chant run --components` deploys to without `--env`. */
|
|
40
|
+
export const LOCAL_ENVIRONMENT = "local";
|
|
41
|
+
|
|
42
|
+
/** Why a member's environments are only `local`, or leave out one the ledger has. Closed: part of the read contract. */
|
|
43
|
+
export const ENVIRONMENT_REASON_CODES = [
|
|
44
|
+
/** The member's chant.config.ts could not be read: the same read and the same code as its runtimes. */
|
|
45
|
+
"runtimes-config-unreadable",
|
|
46
|
+
/** The config declares no environments, so only local and the ledger's environments are listed. */
|
|
47
|
+
"environments-none-declared",
|
|
48
|
+
/** The ledger has releases in an environment the config's environments no longer cover, so chant run --env would refuse it. */
|
|
49
|
+
"environments-ledger-undeclared",
|
|
50
|
+
/** The chant/lifecycle branch exists and the member's ledger directories could not be listed. */
|
|
51
|
+
"environments-ledger-unreadable",
|
|
52
|
+
] as const satisfies readonly ReasonCode[];
|
|
53
|
+
export type EnvironmentReasonCode = (typeof ENVIRONMENT_REASON_CODES)[number];
|
|
54
|
+
|
|
55
|
+
export interface EnvironmentReason {
|
|
56
|
+
code: EnvironmentReasonCode;
|
|
57
|
+
message: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One environment a component may deploy to. */
|
|
61
|
+
export interface ComponentEnvironment {
|
|
62
|
+
/** What `--env` takes. */
|
|
63
|
+
name: string;
|
|
64
|
+
/** True for the environment `chant run --components` deploys to without `--env`. */
|
|
65
|
+
default: boolean;
|
|
66
|
+
/** Where the name came from; the first of config, ledger and builtin that names it. */
|
|
67
|
+
source: "config" | "ledger" | "builtin";
|
|
68
|
+
/** The command that deploys the component there on the default runtime, run in the member's directory. */
|
|
69
|
+
command: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The environments a member's ledger has, or why they couldn't be listed. */
|
|
73
|
+
export interface LedgerEnvironments {
|
|
74
|
+
envs: string[];
|
|
75
|
+
reason: EnvironmentReason | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Lists one member's ledger environments; git unless a test swaps it. */
|
|
79
|
+
export type LedgerEnvironmentReader = (member: { name: string; dir: string }, cwd: string) => LedgerEnvironments;
|
|
80
|
+
|
|
81
|
+
/** What one member's config and ledger say about environments. */
|
|
82
|
+
export interface MemberEnvironments {
|
|
83
|
+
/** Each environment in list order, without the per-component command. */
|
|
84
|
+
environments: Omit<ComponentEnvironment, "command">[];
|
|
85
|
+
reasons: EnvironmentReason[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function git(cwd: string, args: string[], input?: string): string {
|
|
89
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", input, stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"] });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function exists(cwd: string, spec: string): boolean {
|
|
93
|
+
try {
|
|
94
|
+
git(cwd, ["rev-parse", "--verify", "--quiet", spec]);
|
|
95
|
+
return true;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The environments with a release ledger for `member` on the local
|
|
103
|
+
* `chant/lifecycle` branch: the directories under `_members/<member>/` when it
|
|
104
|
+
* exists (the root member never has one), else the flat layout's top-level
|
|
105
|
+
* directories, that hold a `releases.jsonl`.
|
|
106
|
+
*/
|
|
107
|
+
export const readLedgerEnvironments: LedgerEnvironmentReader = (member, cwd) => {
|
|
108
|
+
if (!exists(cwd, `refs/heads/${LIFECYCLE_REF}`)) return { envs: [], reason: null };
|
|
109
|
+
const own = member.dir !== "." && exists(cwd, `refs/heads/${LIFECYCLE_REF}:${MEMBERS_DIR}/${member.name}`);
|
|
110
|
+
const base = own ? `${MEMBERS_DIR}/${member.name}/` : "";
|
|
111
|
+
try {
|
|
112
|
+
const dirs = git(cwd, ["ls-tree", "--full-tree", `refs/heads/${LIFECYCLE_REF}:${base}`])
|
|
113
|
+
.split("\n")
|
|
114
|
+
.map((line) => line.match(/^\d+ tree [0-9a-f]+\t(.+)$/)?.[1])
|
|
115
|
+
.filter((name): name is string => !!name && ENV_PATTERN.test(name));
|
|
116
|
+
if (dirs.length === 0) return { envs: [], reason: null };
|
|
117
|
+
const checked = git(cwd, ["cat-file", "--batch-check"], dirs.map((d) => `refs/heads/${LIFECYCLE_REF}:${base}${d}/releases.jsonl\n`).join(""))
|
|
118
|
+
.split("\n")
|
|
119
|
+
.filter(Boolean);
|
|
120
|
+
return { envs: dirs.filter((_, i) => checked[i] !== undefined && !checked[i].endsWith(" missing")).sort(), reason: null };
|
|
121
|
+
} catch (err) {
|
|
122
|
+
const message = (err instanceof Error ? err.message : String(err)).split("\n")[0];
|
|
123
|
+
return { envs: [], reason: { code: "environments-ledger-unreadable", message: `${LIFECYCLE_REF}:${base || "."}: ${message}` } };
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Join a member's config and ledger into its environment list, `local` first, then config order, then the ledger's. */
|
|
128
|
+
export function memberEnvironments(runtimes: MemberRuntimes | undefined, ledger: LedgerEnvironments | undefined): MemberEnvironments {
|
|
129
|
+
const reasons: EnvironmentReason[] = [];
|
|
130
|
+
const declared = runtimes?.environments ?? null;
|
|
131
|
+
if (runtimes && declared === null) {
|
|
132
|
+
const unreadable = runtimes.reasons.find((r) => r.code === "runtimes-config-unreadable");
|
|
133
|
+
reasons.push({ code: "runtimes-config-unreadable", message: unreadable?.message ?? "chant.config.ts could not be read" });
|
|
134
|
+
} else if (declared && declared.length === 0) {
|
|
135
|
+
reasons.push({ code: "environments-none-declared", message: "chant.config.ts declares no environments, so only local and the environments in the ledger are listed" });
|
|
136
|
+
}
|
|
137
|
+
if (ledger?.reason) reasons.push(ledger.reason);
|
|
138
|
+
|
|
139
|
+
const literals = (declared ?? []).map(environmentName).filter((n) => !isEnvironmentPattern(n));
|
|
140
|
+
const ledgerEnvs = ledger?.envs ?? [];
|
|
141
|
+
const covered = (env: string): boolean => !declared || declared.length === 0 || matchesDeclaredEnvironment(declared, env);
|
|
142
|
+
const undeclared = ledgerEnvs.filter((e) => e !== LOCAL_ENVIRONMENT && !covered(e));
|
|
143
|
+
if (undeclared.length > 0) {
|
|
144
|
+
reasons.push({
|
|
145
|
+
code: "environments-ledger-undeclared",
|
|
146
|
+
message: `the ledger has releases in ${undeclared.join(", ")}, which chant.config.ts's environments don't cover, so chant run --env refuses ${undeclared.length === 1 ? "it" : "them"}`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const out: MemberEnvironments["environments"] = [];
|
|
151
|
+
const add = (name: string, source: ComponentEnvironment["source"]) => {
|
|
152
|
+
if (!out.some((e) => e.name === name)) out.push({ name, default: name === LOCAL_ENVIRONMENT, source });
|
|
153
|
+
};
|
|
154
|
+
add(LOCAL_ENVIRONMENT, literals.includes(LOCAL_ENVIRONMENT) ? "config" : ledgerEnvs.includes(LOCAL_ENVIRONMENT) ? "ledger" : "builtin");
|
|
155
|
+
for (const name of literals) add(name, "config");
|
|
156
|
+
for (const name of ledgerEnvs) if (covered(name)) add(name, "ledger");
|
|
157
|
+
return { environments: out, reasons };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The environments one component may deploy to, each with its command line on the member's default runtime. */
|
|
161
|
+
export function componentEnvironments(component: string, member: MemberEnvironments | undefined, runtime = LOCAL_RUNTIME): ComponentEnvironment[] {
|
|
162
|
+
const list = member?.environments ?? [{ name: LOCAL_ENVIRONMENT, default: true, source: "builtin" as const }];
|
|
163
|
+
const on = runtime === LOCAL_RUNTIME ? "" : ` --on ${runtime}`;
|
|
164
|
+
return list.map((e) => ({ ...e, command: `chant run --components ${component}${on}${e.default ? "" : ` --env ${e.name}`}` }));
|
|
165
|
+
}
|