@intentius/chant 0.95.1 → 0.96.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/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +9 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.d.ts +2 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/index.d.ts +2 -0
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/run-live.d.ts +127 -0
- package/dist/op/run-live.d.ts.map +1 -0
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/workspace/checks/diagrams.d.ts +5 -0
- package/dist/workspace/checks/diagrams.d.ts.map +1 -1
- package/dist/workspace/declaration.d.ts +5 -5
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +50 -7
- package/dist/workspace/intent-cli.d.ts +6 -0
- package/dist/workspace/intent-cli.d.ts.map +1 -1
- package/dist/workspace/intent-record.d.ts +145 -0
- package/dist/workspace/intent-record.d.ts.map +1 -0
- package/dist/workspace/intent.d.ts +46 -1
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/ls.d.ts +2 -2
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/patch.d.ts +122 -0
- package/dist/workspace/patch.d.ts.map +1 -0
- package/dist/workspace/points-cli.d.ts +8 -0
- package/dist/workspace/points-cli.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +2 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-decided.d.ts +36 -0
- package/dist/workspace/record-decided.d.ts.map +1 -0
- package/dist/workspace/records-cli.d.ts +8 -0
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/status-stewards.d.ts +79 -12
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/main.test.ts +16 -0
- package/src/cli/main.ts +34 -3
- package/src/cli/registry.ts +9 -1
- package/src/lifecycle/run-ledger.ts +7 -1
- package/src/op/activities/shell.ts +4 -1
- package/src/op/index.ts +2 -0
- package/src/op/local-executor.ts +51 -3
- package/src/op/operator.ts +2 -11
- package/src/op/run-live.test.ts +111 -0
- package/src/op/run-live.ts +305 -0
- package/src/op/runtime.ts +2 -0
- package/src/op/steward-points.test.ts +81 -0
- package/src/workspace/checks/diagrams.test.ts +24 -0
- package/src/workspace/checks/diagrams.ts +10 -3
- package/src/workspace/declaration.schema.json +50 -7
- package/src/workspace/declaration.test.ts +18 -0
- package/src/workspace/declaration.ts +7 -7
- package/src/workspace/intent-cli.ts +47 -1
- package/src/workspace/intent-record.schema.json +529 -0
- package/src/workspace/intent-record.test.ts +257 -0
- package/src/workspace/intent-record.ts +371 -0
- package/src/workspace/intent.schema.json +33 -1
- package/src/workspace/intent.ts +29 -12
- package/src/workspace/ls-contract.test.ts +14 -0
- package/src/workspace/ls.schema.json +3 -3
- package/src/workspace/ls.ts +3 -3
- package/src/workspace/patch.schema.json +321 -0
- package/src/workspace/patch.test.ts +198 -0
- package/src/workspace/patch.ts +377 -0
- package/src/workspace/points-cli.ts +18 -0
- package/src/workspace/read-contract.test.ts +29 -1
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +3 -0
- package/src/workspace/record-decided.ts +107 -0
- package/src/workspace/records-cli.ts +12 -0
- package/src/workspace/records.schema.json +15 -0
- package/src/workspace/status-contract.test.ts +76 -2
- package/src/workspace/status-stewards.ts +149 -27
- package/src/workspace/status.schema.json +69 -6
|
@@ -150,6 +150,7 @@ export const REASONS = {
|
|
|
150
150
|
"since-session-open": "--since names a session that is still open, so the comparison runs to the working tree.",
|
|
151
151
|
// The intent graph (graph --intent, #2651): a read that fails.
|
|
152
152
|
"intent-region-invalid": "The region's path, or its line range, does not exist in the tree read.",
|
|
153
|
+
"intent-record-unknown": "graph --intent --record names an id that no record of a decision kind read has.",
|
|
153
154
|
// The intent graph: part of the walk that can't be read. The document is still printed.
|
|
154
155
|
"intent-history-shallow": "The repository is a shallow clone, so the region's history stops at the clone's boundary.",
|
|
155
156
|
"intent-plugin-failed": "A kind file's commitJoins, which joins commits to units, contracts and evidence, failed for a commit.",
|
|
@@ -174,6 +175,8 @@ export const REASONS = {
|
|
|
174
175
|
// The forward coverage check (check --changes, #2773): findings, one per changed path.
|
|
175
176
|
"change-uncovered": "A path the diff changes is covered by no current decided record and no open work item, by path or by its member.",
|
|
176
177
|
"change-out-of-scope": "A record in hand for the change, such as the work item it is for or a decision that item implements, lists a path the diff changes in its out_of_scope.",
|
|
178
|
+
// The patch read (workspace patch): a read that fails.
|
|
179
|
+
"patch-path-invalid": "workspace patch --path names a path that is not relative and inside the workspace.",
|
|
177
180
|
// Composite instances joined to components (graph --composites, #2662): why the list is empty or has no component.
|
|
178
181
|
"composites-no-chant-member": "No member of kind chant was read, so nothing declares a composite instance or a component.",
|
|
179
182
|
"composites-none-declared": "The members read declare no composite instance.",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The commit that decided a record: the commit reachable from a revision that
|
|
3
|
+
* last moved the record's file into an approved state and kept it there. A
|
|
4
|
+
* record added in an approved state was decided by the commit that added it.
|
|
5
|
+
*
|
|
6
|
+
* "Approved" is `isDecided` from `work.ts`: a state the kind's `approval`
|
|
7
|
+
* ranks above 0, or, for a kind with no `approval`, one of its
|
|
8
|
+
* `closedStates`. For the decision kind that is decided, ratified and
|
|
9
|
+
* superseded, and not proposed or withdrawn. Walking the file's history
|
|
10
|
+
* newest first, the commit is the oldest one in the unbroken run of approved
|
|
11
|
+
* versions that ends at the revision: a record decided, then ratified, keeps
|
|
12
|
+
* the commit that decided it, and one withdrawn and decided again gets the
|
|
13
|
+
* second decision.
|
|
14
|
+
*
|
|
15
|
+
* The intent graph's windows open here (`intent.ts`): a commit made while a
|
|
16
|
+
* record was only proposed is not in its window. A record that is not
|
|
17
|
+
* approved at the revision has no deciding commit, and its window opens at
|
|
18
|
+
* the commit that added it, as it always did.
|
|
19
|
+
*
|
|
20
|
+
* History is read with two git processes for any number of records: one
|
|
21
|
+
* `git log` over their paths and one `git cat-file --batch` for the versions.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { parseRecord, type LoadedRecordKind } from "./records";
|
|
26
|
+
import { isDecided } from "./work";
|
|
27
|
+
|
|
28
|
+
export interface DecidedIn {
|
|
29
|
+
sha: string;
|
|
30
|
+
/** The author date, ISO 8601. */
|
|
31
|
+
date: string;
|
|
32
|
+
subject: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function tryGit(top: string, args: string[], input?: string): Buffer | undefined {
|
|
36
|
+
try {
|
|
37
|
+
return execFileSync("git", args, { cwd: top, input, stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"], maxBuffer: 512 * 1024 * 1024 });
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Each `sha:path` blob's text, or undefined when git has none. */
|
|
44
|
+
function blobs(top: string, names: string[]): Map<string, string> {
|
|
45
|
+
const out = new Map<string, string>();
|
|
46
|
+
if (names.length === 0) return out;
|
|
47
|
+
const buf = tryGit(top, ["cat-file", "--batch"], `${names.join("\n")}\n`);
|
|
48
|
+
if (!buf) return out;
|
|
49
|
+
let at = 0;
|
|
50
|
+
for (const name of names) {
|
|
51
|
+
const nl = buf.indexOf(0x0a, at);
|
|
52
|
+
if (nl < 0) break;
|
|
53
|
+
const header = buf.subarray(at, nl).toString("utf-8");
|
|
54
|
+
at = nl + 1;
|
|
55
|
+
const m = header.match(/^[0-9a-f]+ (\w+) (\d+)$/);
|
|
56
|
+
if (!m) continue; // "<name> missing"
|
|
57
|
+
const size = Number(m[2]);
|
|
58
|
+
if (m[1] === "blob") out.set(name, buf.subarray(at, at + size).toString("utf-8"));
|
|
59
|
+
at += size + 1;
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* For each record path (from the repository root), the commit reachable from
|
|
66
|
+
* `rev` that decided it, or null when the record is not approved at `rev` or
|
|
67
|
+
* has no history there.
|
|
68
|
+
*/
|
|
69
|
+
export function decidedCommits(top: string, rev: string, paths: string[], kind: LoadedRecordKind["kind"]): Map<string, DecidedIn | null> {
|
|
70
|
+
const out = new Map<string, DecidedIn | null>(paths.map((p) => [p, null]));
|
|
71
|
+
const stateField = kind.stateField;
|
|
72
|
+
if (!stateField || paths.length === 0) return out;
|
|
73
|
+
const log = tryGit(top, ["log", "--no-renames", "--format=%x00%H%x1f%aI%x1f%s", "--name-only", rev, "--", ...paths])?.toString("utf-8");
|
|
74
|
+
if (log === undefined) return out;
|
|
75
|
+
const wanted = new Set(paths);
|
|
76
|
+
const commits = new Map<string, { date: string; subject: string }>();
|
|
77
|
+
/** Each path's commits, newest first. */
|
|
78
|
+
const history = new Map<string, string[]>();
|
|
79
|
+
for (const chunk of log.split("\0")) {
|
|
80
|
+
if (!chunk) continue;
|
|
81
|
+
const nl = chunk.indexOf("\n");
|
|
82
|
+
const [sha, date, subject] = (nl < 0 ? chunk : chunk.slice(0, nl)).split("\x1f");
|
|
83
|
+
commits.set(sha, { date, subject });
|
|
84
|
+
for (const f of (nl < 0 ? "" : chunk.slice(nl + 1)).split("\n")) {
|
|
85
|
+
if (!wanted.has(f)) continue;
|
|
86
|
+
const list = history.get(f) ?? [];
|
|
87
|
+
list.push(sha);
|
|
88
|
+
history.set(f, list);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const texts = blobs(
|
|
92
|
+
top,
|
|
93
|
+
[...history].flatMap(([p, shas]) => shas.map((s) => `${s}:${p}`)),
|
|
94
|
+
);
|
|
95
|
+
for (const [path, shas] of history) {
|
|
96
|
+
let decided: string | null = null;
|
|
97
|
+
for (const sha of shas) {
|
|
98
|
+
const text = texts.get(`${sha}:${path}`);
|
|
99
|
+
const fm = text === undefined ? undefined : parseRecord(kind.format, text);
|
|
100
|
+
const state = fm?.ok ? fm.value[stateField] : undefined;
|
|
101
|
+
if (typeof state !== "string" || !isDecided(kind, state)) break;
|
|
102
|
+
decided = sha;
|
|
103
|
+
}
|
|
104
|
+
if (decided) out.set(path, { sha: decided, ...commits.get(decided)! });
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
@@ -24,6 +24,7 @@ import { formatError } from "../cli/format";
|
|
|
24
24
|
import type { CommandContext } from "../cli/registry";
|
|
25
25
|
import { findWorkspaceRoot } from "../project-root";
|
|
26
26
|
import { fileDigest, isWorkspacePath } from "./record-assets";
|
|
27
|
+
import { decidedCommits, type DecidedIn } from "./record-decided";
|
|
27
28
|
import { gitRevisionSource, gitRoot, resolveRevision, workingTreeSource, type RecordSource } from "./record-source";
|
|
28
29
|
import { declaredRecordKinds, readDeclaration, WorkspaceReadError, type RecordKindDeclaration } from "./declaration";
|
|
29
30
|
import { declaredKindFile } from "./declared-kinds";
|
|
@@ -100,6 +101,13 @@ export type RecordView = RecordEntry & {
|
|
|
100
101
|
* lease is live state and not part of a revision.
|
|
101
102
|
*/
|
|
102
103
|
lease?: { holder: string; token: string; acquiredAt: string; expiresAt: string } | null;
|
|
104
|
+
/**
|
|
105
|
+
* For a kind with approval ranks that is not a work kind, read in git: the
|
|
106
|
+
* commit that last moved the record into an approved state and kept it
|
|
107
|
+
* there, or null when the record is not approved in the history read
|
|
108
|
+
* (`record-decided.ts`). The intent graph's windows open at this commit.
|
|
109
|
+
*/
|
|
110
|
+
decidedIn?: DecidedIn | null;
|
|
103
111
|
};
|
|
104
112
|
|
|
105
113
|
/** The role in the trust policy whose holders' verdicts the quorum does not count (#2671). */
|
|
@@ -327,6 +335,10 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
|
|
|
327
335
|
...(quorumOptions ? { quorum: computeQuorum(loaded.kind, r, quorumOptions) } : {}),
|
|
328
336
|
...(seals ? authorSeal(loaded.kind, r, policy, seals.checkRecordSeal) : {}),
|
|
329
337
|
}));
|
|
338
|
+
if (loaded.kind.approval && !loaded.kind.work && top) {
|
|
339
|
+
const decided = decidedCommits(top, at ?? "HEAD", records.map((r) => r.path), loaded.kind);
|
|
340
|
+
for (const r of records) r.decidedIn = decided.get(r.path) ?? null;
|
|
341
|
+
}
|
|
330
342
|
if (loaded.kind.work && query.workGaps !== false && top) await raiseWorkGaps(loaded, records, { root, workspaceRoot, at: query.at });
|
|
331
343
|
if (loaded.kind.work && at === null && top) {
|
|
332
344
|
const { activeWorkLeases } = await import("../lifecycle/work-lease");
|
|
@@ -5,6 +5,17 @@
|
|
|
5
5
|
"description": "What `chant workspace records --json` prints, version 1 of the read contract for records (#2536). This is the first-test slice (#2546): records are unsealed and there is no workspace declaration yet. Readers ignore fields they do not know; a field is only ever added within a version. The reason and error codes are closed lists: a new code is a new contract version. Contract version 1 is written by chant 0.81.0 and newer; a reader that needs it refuses output whose `contract` it doesn't know. Every code is in the one closed list of `reason-codes.ts`.",
|
|
6
6
|
"oneOf": [{ "$ref": "#/$defs/result" }, { "$ref": "#/$defs/failure" }, { "$ref": "#/$defs/set" }],
|
|
7
7
|
"$defs": {
|
|
8
|
+
"decidedIn": {
|
|
9
|
+
"description": "The commit that last moved the record into an approved state and kept it there: a state the kind's approval ranks above 0, or one of its closedStates when it has no approval. Read from the history of the record's file reachable from the revision read, or HEAD in the working tree. A record added approved was decided by the commit that added it. Null when the record is not approved in that history.",
|
|
10
|
+
"type": ["object", "null"],
|
|
11
|
+
"required": ["sha", "date", "subject"],
|
|
12
|
+
"additionalProperties": false,
|
|
13
|
+
"properties": {
|
|
14
|
+
"sha": { "type": "string", "pattern": "^[0-9a-f]{40,64}$" },
|
|
15
|
+
"date": { "type": "string", "description": "The author date, ISO 8601." },
|
|
16
|
+
"subject": { "type": "string" }
|
|
17
|
+
}
|
|
18
|
+
},
|
|
8
19
|
"set": {
|
|
9
20
|
"description": "Added in contract 1 by #2680. What records prints without --kind when the workspace declaration names record kinds: one result or failure per declared kind, in the declaration's order (the workspace's own kinds, then each member's in member order). Exit code 1 when any kind's read failed; the others are still read and listed.",
|
|
10
21
|
"type": "object",
|
|
@@ -132,6 +143,10 @@
|
|
|
132
143
|
"type": "array",
|
|
133
144
|
"items": { "type": "string" }
|
|
134
145
|
},
|
|
146
|
+
"decidedIn": {
|
|
147
|
+
"description": "Added in contract 1. Present for a kind with approval ranks that is not a work kind, read in a git repository.",
|
|
148
|
+
"$ref": "#/$defs/decidedIn"
|
|
149
|
+
},
|
|
135
150
|
"data": {
|
|
136
151
|
"type": ["object", "null"],
|
|
137
152
|
"description": "The record's structured core as JSON: the front matter, or for a kind of format json the whole file (#2664). It follows the kind's schema when valid is true. Null when it could not be parsed."
|
|
@@ -31,6 +31,9 @@ import { appendRunRecord } from "../lifecycle/run-ledger";
|
|
|
31
31
|
import { acquireStewardLease } from "../op/operator";
|
|
32
32
|
import { stewardWorkHolder } from "../op/work-lease-run";
|
|
33
33
|
import { claimWorkLease } from "../lifecycle/work-lease";
|
|
34
|
+
import { runOpLocally } from "../op/local-executor";
|
|
35
|
+
import { shellCmd } from "../op/activities/shell";
|
|
36
|
+
import type { OpConfig } from "../op/types";
|
|
34
37
|
|
|
35
38
|
const REPO = join(import.meta.dirname, "..", "..", "..", "..");
|
|
36
39
|
|
|
@@ -623,19 +626,21 @@ describe("stewards in chant workspace status --json (#2731)", () => {
|
|
|
623
626
|
name: "box-converge",
|
|
624
627
|
schedule: { cron: "* * * * *", overlap: "skip" },
|
|
625
628
|
env: "local",
|
|
626
|
-
lastRun: { id: expect.any(String), status: "ok", started: "2026-09-25T10:00:00.000Z", ended: "2026-09-25T10:00:05.000Z", gate: null, point: null },
|
|
629
|
+
lastRun: { id: expect.any(String), status: "ok", started: "2026-09-25T10:00:00.000Z", ended: "2026-09-25T10:00:05.000Z", gate: null, point: null, phases: [] },
|
|
630
|
+
inFlight: null,
|
|
627
631
|
// #2778: not a ConvergeOp (no Converge label), so no tick.
|
|
628
632
|
lastTick: null,
|
|
629
633
|
changesCheckout: false,
|
|
630
634
|
workLease: null,
|
|
631
635
|
beside: null,
|
|
632
636
|
},
|
|
633
|
-
{ name: "box-release", schedule: null, env: "local", lastRun: null, lastTick: null, changesCheckout: false, workLease: null, beside: null },
|
|
637
|
+
{ name: "box-release", schedule: null, env: "local", lastRun: null, inFlight: null, lastTick: null, changesCheckout: false, workLease: null, beside: null },
|
|
634
638
|
{
|
|
635
639
|
name: "box-dispatch",
|
|
636
640
|
schedule: { cron: "*/5 * * * *", overlap: "skip" },
|
|
637
641
|
env: "local",
|
|
638
642
|
lastRun: null,
|
|
643
|
+
inFlight: null,
|
|
639
644
|
lastTick: null,
|
|
640
645
|
changesCheckout: true,
|
|
641
646
|
workLease: {
|
|
@@ -663,6 +668,75 @@ describe("stewards in chant workspace status --json (#2731)", () => {
|
|
|
663
668
|
expect(k3d.members[0].stewards[0].lease!.live).toBe(false);
|
|
664
669
|
});
|
|
665
670
|
|
|
671
|
+
test("an Op's run in flight: its phase, step, finished phases and activity, then its phases' times once it settles", async () => {
|
|
672
|
+
const root = stewarded();
|
|
673
|
+
const box = join(root, "box");
|
|
674
|
+
let during: StatusDocument | undefined;
|
|
675
|
+
const release: OpConfig = {
|
|
676
|
+
name: "box-release",
|
|
677
|
+
overview: "release",
|
|
678
|
+
phases: [
|
|
679
|
+
{ name: "Prep", steps: [{ kind: "activity", fn: "shellCmd", args: { cmd: 'echo "Edit app/a.js" >> "$CHANT_RUN_ACTIVITY"; printf \'{"at":"2026-09-25T10:00:00.000Z","text":"Bash npm test"}\\n\' >> "$CHANT_RUN_ACTIVITY"' } }] },
|
|
680
|
+
{
|
|
681
|
+
name: "Build",
|
|
682
|
+
steps: [
|
|
683
|
+
{
|
|
684
|
+
kind: "activity",
|
|
685
|
+
fn: "probe",
|
|
686
|
+
id: "build",
|
|
687
|
+
args: {},
|
|
688
|
+
},
|
|
689
|
+
],
|
|
690
|
+
},
|
|
691
|
+
],
|
|
692
|
+
} as unknown as OpConfig;
|
|
693
|
+
const activities = new Map<string, (args: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown>>([
|
|
694
|
+
["shellCmd", (args, signal) => shellCmd(args as never, signal)],
|
|
695
|
+
[
|
|
696
|
+
"probe",
|
|
697
|
+
async () => {
|
|
698
|
+
during = await workspaceStatus({ cwd: root, env: "minimal" });
|
|
699
|
+
return {};
|
|
700
|
+
},
|
|
701
|
+
],
|
|
702
|
+
]);
|
|
703
|
+
const run = await runOpLocally(release, activities as never, {}, undefined, { runId: "run-live-1", ledger: { cwd: box }, cwd: box, steward: "box-steward" });
|
|
704
|
+
expect(run.status).toBe("ok");
|
|
705
|
+
|
|
706
|
+
const inRun = result(during!);
|
|
707
|
+
expectValid(inRun);
|
|
708
|
+
const flying = inRun.members[0].stewards[0].ops.find((o) => o.name === "box-release")!;
|
|
709
|
+
expect(flying.lastRun).toBeNull();
|
|
710
|
+
expect(flying.inFlight).toMatchObject({
|
|
711
|
+
id: "run-live-1",
|
|
712
|
+
steward: "box-steward",
|
|
713
|
+
item: null,
|
|
714
|
+
phase: { name: "Build" },
|
|
715
|
+
step: { name: "build", fn: "probe" },
|
|
716
|
+
phases: [{ name: "Prep", status: "ok", durationMs: expect.any(Number) }],
|
|
717
|
+
activity: {
|
|
718
|
+
total: 2,
|
|
719
|
+
lines: [
|
|
720
|
+
{ seq: 1, at: null, text: "Edit app/a.js" },
|
|
721
|
+
{ seq: 2, at: "2026-09-25T10:00:00.000Z", text: "Bash npm test" },
|
|
722
|
+
],
|
|
723
|
+
},
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
const after = result(await workspaceStatus({ cwd: root, env: "minimal" }));
|
|
727
|
+
expectValid(after);
|
|
728
|
+
const settled = after.members[0].stewards[0].ops.find((o) => o.name === "box-release")!;
|
|
729
|
+
expect(settled.inFlight).toBeNull();
|
|
730
|
+
expect(settled.lastRun).toMatchObject({
|
|
731
|
+
id: "run-live-1",
|
|
732
|
+
status: "ok",
|
|
733
|
+
phases: [
|
|
734
|
+
{ name: "Prep", status: "ok", durationMs: expect.any(Number) },
|
|
735
|
+
{ name: "Build", status: "ok", durationMs: expect.any(Number) },
|
|
736
|
+
],
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
|
|
666
740
|
test("an op file that can't be imported is a reason, and the read still succeeds", async () => {
|
|
667
741
|
const root = stewarded();
|
|
668
742
|
writeFileSync(join(root, "box", "ops", "broken.op.ts"), "export const x = ;\n");
|
|
@@ -14,13 +14,25 @@
|
|
|
14
14
|
* - each Op's last run is the newest record in its run ledger,
|
|
15
15
|
* `<env>/runs__<op>.jsonl` under the member's ledger prefix, where `<env>`
|
|
16
16
|
* is the Op's own `labels.Env` or `local`;
|
|
17
|
+
* - an Op's `inFlight` is the run of it in flight now, from the record a
|
|
18
|
+
* run keeps beside the checkout's git directory while it runs
|
|
19
|
+
* (`../op/run-live.ts`): its phase and step, the phases it has finished and
|
|
20
|
+
* its newest activity lines. Null once the run's ledger record is written,
|
|
21
|
+
* or when the process that wrote it is gone;
|
|
17
22
|
* - `waiting` lists the open decision points the steward waits on (#2749):
|
|
18
|
-
* each Op whose newest run is the steward's own and stopped on a question
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* run
|
|
23
|
+
* each Op whose newest run is the steward's own and stopped on a question
|
|
24
|
+
* that is still open. The question's state is read now, the way `points`
|
|
25
|
+
* reads it (`readQuestionStates`), not copied from the run ledger, which
|
|
26
|
+
* keeps the state the run stopped with. A question answered since, or
|
|
27
|
+
* whose record is gone, is not waited on; nor is one whose Op has a newer
|
|
28
|
+
* run in flight that has not written its record yet: the Op's lease or one
|
|
29
|
+
* of its work leases was taken after the waiting run ended
|
|
30
|
+
* (`newerRunInFlight`). When the questions can't be read, the ledger's
|
|
31
|
+
* state stands, as the operator's does. A run is the steward's when its
|
|
32
|
+
* record names it (`steward`, written for a run started in the steward's
|
|
33
|
+
* turn or under `CHANT_STEWARD`), so this also covers the member's other
|
|
34
|
+
* Ops, those no steward lists, that such a run belongs to, such as an Op
|
|
35
|
+
* the steward's process starts itself;
|
|
24
36
|
* - `lease` is the local steward's own lease ref
|
|
25
37
|
* (`refs/chant/lease/[<prefix>]_stewards/<name>`), present while a
|
|
26
38
|
* `chant operator --steward` holds it or until it expires;
|
|
@@ -46,6 +58,7 @@ import { listWorkLeases } from "../lifecycle/work-lease";
|
|
|
46
58
|
import { stewardWorkHolder } from "../op/work-lease-run";
|
|
47
59
|
import type { OpConfig } from "../op/types";
|
|
48
60
|
import type { OpRunRecord } from "../op/runtime";
|
|
61
|
+
import { readInFlightRun } from "../op/run-live";
|
|
49
62
|
import { approveCommand } from "./status-gates";
|
|
50
63
|
import type { ReasonCode } from "./reason-codes";
|
|
51
64
|
|
|
@@ -72,17 +85,53 @@ export interface StatusStewardRun {
|
|
|
72
85
|
* as `workspace-upgrade`), and the command that approves it.
|
|
73
86
|
*/
|
|
74
87
|
gate: { name: string; since: string; op: string; approve: string } | null;
|
|
75
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* The decision point the run stopped on, for a `waiting` run (#2749). Its
|
|
90
|
+
* state is the question's now, `answered` once a person has answered it.
|
|
91
|
+
*/
|
|
76
92
|
point: StatusStewardWait | null;
|
|
93
|
+
/** Each phase in execution order, with its wall-clock time, or the sum of its steps' for a record that kept none. */
|
|
94
|
+
phases: StatusStewardPhase[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A phase of a run: its verdict and how long it took. */
|
|
98
|
+
export interface StatusStewardPhase {
|
|
99
|
+
name: string;
|
|
100
|
+
status: "ok" | "fail" | "skipped";
|
|
101
|
+
durationMs: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The run of an Op in flight now (../op/run-live.ts): what it is doing while
|
|
106
|
+
* its ledger record is not yet written.
|
|
107
|
+
*/
|
|
108
|
+
export interface StatusStewardInFlight {
|
|
109
|
+
id: string;
|
|
110
|
+
started: string;
|
|
111
|
+
/** When the run last changed its record. */
|
|
112
|
+
updated: string;
|
|
113
|
+
steward: string | null;
|
|
114
|
+
/** The work item the run's lease holds, once it is claimed. */
|
|
115
|
+
item: string | null;
|
|
116
|
+
phase: { name: string; started: string } | null;
|
|
117
|
+
step: { name: string; fn: string; started: string } | null;
|
|
118
|
+
/** The phases it has finished, in order. */
|
|
119
|
+
phases: StatusStewardPhase[];
|
|
120
|
+
/** The newest activity lines its steps reported; `total` counts every line of the run. */
|
|
121
|
+
activity: { total: number; lines: { seq: number; at: string | null; text: string }[] };
|
|
77
122
|
}
|
|
78
123
|
|
|
79
|
-
/**
|
|
124
|
+
/** A decision point a steward's run stopped on (#2749). */
|
|
80
125
|
export interface StatusStewardWait {
|
|
81
126
|
/** The answer record's id: what `points --open` lists and `points answer` names. */
|
|
82
127
|
id: string;
|
|
83
128
|
point: string;
|
|
84
|
-
/**
|
|
85
|
-
|
|
129
|
+
/**
|
|
130
|
+
* The question's state now, as `points` reads it. The state the run
|
|
131
|
+
* stopped with when the questions can't be read or its record is gone.
|
|
132
|
+
* Never `answered` in a steward's `waiting`.
|
|
133
|
+
*/
|
|
134
|
+
state: "escalated" | "proposed" | "answered";
|
|
86
135
|
path: string;
|
|
87
136
|
subject: string | null;
|
|
88
137
|
since: string;
|
|
@@ -117,6 +166,8 @@ export interface StatusStewardOp {
|
|
|
117
166
|
env: string;
|
|
118
167
|
/** The newest run in its run ledger, or null when it has none. */
|
|
119
168
|
lastRun: StatusStewardRun | null;
|
|
169
|
+
/** The run in flight now, or null when none is. */
|
|
170
|
+
inFlight: StatusStewardInFlight | null;
|
|
120
171
|
/** For a ConvergeOp, its newest tick on the converge ledger (#2778); null for any other Op, or before its first tick. */
|
|
121
172
|
lastTick: StatusStewardTick | null;
|
|
122
173
|
/** Whether the Op changes the checkout, and so runs under a work lease on a branch of its own (#2748). */
|
|
@@ -175,7 +226,8 @@ export interface StatusSteward {
|
|
|
175
226
|
ops: StatusStewardOp[];
|
|
176
227
|
/**
|
|
177
228
|
* The open decision points the steward waits on (#2749): each Op whose
|
|
178
|
-
* newest run is the steward's own and stopped on a question
|
|
229
|
+
* newest run is the steward's own and stopped on a question still open,
|
|
230
|
+
* with no newer run of the Op in flight. Its declared
|
|
179
231
|
* Ops come first, in `ops` order, then any other Op of the member whose
|
|
180
232
|
* newest run names the steward, by Op name.
|
|
181
233
|
*/
|
|
@@ -187,17 +239,62 @@ export interface MemberStewards {
|
|
|
187
239
|
reasons: { code: StewardReasonCode; message: string }[];
|
|
188
240
|
}
|
|
189
241
|
|
|
190
|
-
|
|
242
|
+
/** Every question's state in the workspace by id, or null when they can't all be read. */
|
|
243
|
+
export type ReadQuestionStates = (cwd: string) => Promise<Map<string, string> | null>;
|
|
244
|
+
|
|
245
|
+
async function readQuestionStates(cwd: string): Promise<Map<string, string> | null> {
|
|
246
|
+
const { readQuestionStates: read } = await import("./points-cli");
|
|
247
|
+
return read(cwd);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The point a run stopped on, with the question's state now and whether it
|
|
252
|
+
* is still open. A question whose record is gone is not open, as the
|
|
253
|
+
* operator reads it (`stewardWaits` in `../op/operator.ts`); when the
|
|
254
|
+
* questions can't be read, the run ledger's state stands and it is open.
|
|
255
|
+
*/
|
|
256
|
+
function pointOf(p: NonNullable<OpRunRecord["point"]>, states: Map<string, string> | null): { wait: StatusStewardWait; open: boolean } {
|
|
257
|
+
const recorded = p.state === "proposed" ? "proposed" : "escalated";
|
|
258
|
+
const now = states?.get(p.id);
|
|
259
|
+
const state = now === "escalated" || now === "proposed" || now === "answered" ? now : recorded;
|
|
191
260
|
return {
|
|
192
|
-
id: p.id,
|
|
193
|
-
|
|
194
|
-
state: p.state === "proposed" ? "proposed" : "escalated",
|
|
195
|
-
path: p.path,
|
|
196
|
-
subject: p.subject ?? null,
|
|
197
|
-
since: p.since,
|
|
261
|
+
wait: { id: p.id, point: p.point, state, path: p.path, subject: p.subject ?? null, since: p.since },
|
|
262
|
+
open: states === null || (now !== undefined && now !== "answered"),
|
|
198
263
|
};
|
|
199
264
|
}
|
|
200
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Whether a newer run of the Op is in flight than the one that ended at
|
|
268
|
+
* `ended`: the Op's own lease is live, or one of its work leases is active,
|
|
269
|
+
* and was taken after that run ended. A run writes its record when it ends,
|
|
270
|
+
* so until then the ledger's newest record is the older run's.
|
|
271
|
+
*/
|
|
272
|
+
function newerRunInFlight(ended: string, lease: StatusStewardLease | null, held: StatusStewardWorkLease[]): boolean {
|
|
273
|
+
const after = (at: string) => Date.parse(at) >= Date.parse(ended);
|
|
274
|
+
return (lease?.live === true && after(lease.acquiredAt)) || held.some((l) => l.state === "active" && after(l.acquiredAt));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The Op's run in flight, unless its ledger record is already the newest. Never throws. */
|
|
278
|
+
async function inFlightOf(memberDir: string, env: string, op: string, lastRunId: string | null): Promise<StatusStewardInFlight | null> {
|
|
279
|
+
try {
|
|
280
|
+
const run = await readInFlightRun(memberDir, env, op);
|
|
281
|
+
if (!run || run.id === lastRunId) return null;
|
|
282
|
+
return {
|
|
283
|
+
id: run.id,
|
|
284
|
+
started: run.started,
|
|
285
|
+
updated: run.updated,
|
|
286
|
+
steward: run.steward,
|
|
287
|
+
item: run.item,
|
|
288
|
+
phase: run.phase,
|
|
289
|
+
step: run.step,
|
|
290
|
+
phases: run.phases.map((p) => ({ name: p.name, status: p.status, durationMs: p.durationMs })),
|
|
291
|
+
activity: run.activity,
|
|
292
|
+
};
|
|
293
|
+
} catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
201
298
|
function gateOf(g: NonNullable<OpRunRecord["gate"]>, opName: string): NonNullable<StatusStewardRun["gate"]> {
|
|
202
299
|
const op = g.op ?? opName;
|
|
203
300
|
return { name: g.name, since: g.since, op, approve: approveCommand(op, g.name, null) };
|
|
@@ -281,8 +378,12 @@ export async function readMemberStewards(
|
|
|
281
378
|
now: string,
|
|
282
379
|
kind = "chant",
|
|
283
380
|
box: { capabilities: { name: string; broker: string | null }[] } | null = null,
|
|
381
|
+
deps: { readQuestions?: ReadQuestionStates } = {},
|
|
284
382
|
): Promise<MemberStewards> {
|
|
285
383
|
const reasons: MemberStewards["reasons"] = [];
|
|
384
|
+
// Read once per member, and only when a run stopped on a question.
|
|
385
|
+
let states: Promise<Map<string, string> | null> | undefined;
|
|
386
|
+
const questionStates = () => (states ??= (deps.readQuestions ?? readQuestionStates)(memberDir));
|
|
286
387
|
if (kind !== "chant" || !isChantProject(memberDir)) return { stewards: [], reasons };
|
|
287
388
|
|
|
288
389
|
let discovered: Awaited<ReturnType<typeof discoverStewards>>;
|
|
@@ -302,20 +403,35 @@ export async function readMemberStewards(
|
|
|
302
403
|
const waiting: StatusSteward["waiting"] = [];
|
|
303
404
|
for (const op of declaration.ops) {
|
|
304
405
|
const opEnv = runEnvOf(op);
|
|
406
|
+
const workLease = op.workLease
|
|
407
|
+
? { kind: op.workLease.kind ?? null, held: await readHeldWorkLeases(declaration.name, op, memberDir, now) }
|
|
408
|
+
: null;
|
|
409
|
+
const beside = await besideOf(declaration, op.name, memberDir, now);
|
|
305
410
|
let lastRun: StatusStewardRun | null = null;
|
|
306
411
|
try {
|
|
307
412
|
const newest = (await readRunLedger(opEnv, op.name, { cwd: memberDir })).records.at(-1);
|
|
308
413
|
if (newest) {
|
|
414
|
+
const point = newest.point ? pointOf(newest.point, await questionStates()) : null;
|
|
309
415
|
lastRun = {
|
|
310
416
|
id: newest.id,
|
|
311
417
|
status: newest.status,
|
|
312
418
|
started: newest.started,
|
|
313
419
|
ended: newest.ended,
|
|
314
420
|
gate: newest.gate ? gateOf(newest.gate, op.name) : null,
|
|
315
|
-
point:
|
|
421
|
+
point: point?.wait ?? null,
|
|
422
|
+
phases: newest.phases.map((p) => ({
|
|
423
|
+
name: p.name,
|
|
424
|
+
status: p.status,
|
|
425
|
+
durationMs: p.durationMs ?? p.steps.reduce((sum, st) => sum + st.durationMs, 0),
|
|
426
|
+
})),
|
|
316
427
|
};
|
|
317
|
-
if (
|
|
318
|
-
|
|
428
|
+
if (
|
|
429
|
+
newest.status === "waiting" &&
|
|
430
|
+
point?.open &&
|
|
431
|
+
newest.steward === declaration.name &&
|
|
432
|
+
!newerRunInFlight(newest.ended, beside?.lease ?? null, workLease?.held ?? [])
|
|
433
|
+
) {
|
|
434
|
+
waiting.push({ op: op.name, run: newest.id, ...point.wait });
|
|
319
435
|
}
|
|
320
436
|
}
|
|
321
437
|
} catch (err) {
|
|
@@ -324,6 +440,7 @@ export async function readMemberStewards(
|
|
|
324
440
|
message: `${opEnv}/runs__${op.name}.jsonl: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
|
|
325
441
|
});
|
|
326
442
|
}
|
|
443
|
+
const inFlight = await inFlightOf(memberDir, opEnv, op.name, lastRun?.id ?? null);
|
|
327
444
|
let lastTick: StatusStewardTick | null = null;
|
|
328
445
|
if (op.labels?.Converge === "true") {
|
|
329
446
|
try {
|
|
@@ -341,12 +458,11 @@ export async function readMemberStewards(
|
|
|
341
458
|
schedule: op.schedule ? { cron: op.schedule.cron, overlap: "skip" } : null,
|
|
342
459
|
env: opEnv,
|
|
343
460
|
lastRun,
|
|
461
|
+
inFlight,
|
|
344
462
|
lastTick,
|
|
345
463
|
changesCheckout: op.changesCheckout === true,
|
|
346
|
-
workLease
|
|
347
|
-
|
|
348
|
-
: null,
|
|
349
|
-
beside: await besideOf(declaration, op.name, memberDir, now),
|
|
464
|
+
workLease,
|
|
465
|
+
beside,
|
|
350
466
|
});
|
|
351
467
|
}
|
|
352
468
|
stewards.push({
|
|
@@ -364,7 +480,7 @@ export async function readMemberStewards(
|
|
|
364
480
|
waiting,
|
|
365
481
|
});
|
|
366
482
|
}
|
|
367
|
-
if (stewards.length > 0) await addUndeclaredWaits(memberDir, stewards, found, reasons);
|
|
483
|
+
if (stewards.length > 0) await addUndeclaredWaits(memberDir, stewards, found, reasons, now, questionStates);
|
|
368
484
|
return { stewards, reasons };
|
|
369
485
|
}
|
|
370
486
|
|
|
@@ -381,6 +497,8 @@ async function addUndeclaredWaits(
|
|
|
381
497
|
stewards: StatusSteward[],
|
|
382
498
|
found: Awaited<ReturnType<typeof discoverStewards>>["stewards"],
|
|
383
499
|
reasons: MemberStewards["reasons"],
|
|
500
|
+
now: string,
|
|
501
|
+
questionStates: () => Promise<Map<string, string> | null>,
|
|
384
502
|
): Promise<void> {
|
|
385
503
|
const declared = new Set([...found.values()].flatMap(({ declaration }) => declaration.ops.map((op) => op.name)));
|
|
386
504
|
let ops: OpConfig[];
|
|
@@ -396,7 +514,11 @@ async function addUndeclaredWaits(
|
|
|
396
514
|
const newest = (await readRunLedger(opEnv, op.name, { cwd: memberDir })).records.at(-1);
|
|
397
515
|
const steward = newest?.steward ? byName.get(newest.steward) : undefined;
|
|
398
516
|
if (steward && newest?.status === "waiting" && newest.point) {
|
|
399
|
-
|
|
517
|
+
const point = pointOf(newest.point, await questionStates());
|
|
518
|
+
const held = op.workLease ? await readHeldWorkLeases(steward.name, op, memberDir, now) : [];
|
|
519
|
+
if (point.open && !newerRunInFlight(newest.ended, await readLeaseRef(op.name, memberDir, now), held)) {
|
|
520
|
+
steward.waiting.push({ op: op.name, run: newest.id, ...point.wait });
|
|
521
|
+
}
|
|
400
522
|
}
|
|
401
523
|
} catch (err) {
|
|
402
524
|
reasons.push({
|