@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.
Files changed (79) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/registry.d.ts +9 -1
  3. package/dist/cli/registry.d.ts.map +1 -1
  4. package/dist/lifecycle/run-ledger.d.ts +2 -0
  5. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  6. package/dist/op/activities/shell.d.ts.map +1 -1
  7. package/dist/op/index.d.ts +2 -0
  8. package/dist/op/index.d.ts.map +1 -1
  9. package/dist/op/local-executor.d.ts.map +1 -1
  10. package/dist/op/operator.d.ts.map +1 -1
  11. package/dist/op/run-live.d.ts +127 -0
  12. package/dist/op/run-live.d.ts.map +1 -0
  13. package/dist/op/runtime.d.ts +2 -0
  14. package/dist/op/runtime.d.ts.map +1 -1
  15. package/dist/workspace/checks/diagrams.d.ts +5 -0
  16. package/dist/workspace/checks/diagrams.d.ts.map +1 -1
  17. package/dist/workspace/declaration.d.ts +5 -5
  18. package/dist/workspace/declaration.d.ts.map +1 -1
  19. package/dist/workspace/declaration.schema.json +50 -7
  20. package/dist/workspace/intent-cli.d.ts +6 -0
  21. package/dist/workspace/intent-cli.d.ts.map +1 -1
  22. package/dist/workspace/intent-record.d.ts +145 -0
  23. package/dist/workspace/intent-record.d.ts.map +1 -0
  24. package/dist/workspace/intent.d.ts +46 -1
  25. package/dist/workspace/intent.d.ts.map +1 -1
  26. package/dist/workspace/ls.d.ts +2 -2
  27. package/dist/workspace/ls.d.ts.map +1 -1
  28. package/dist/workspace/patch.d.ts +122 -0
  29. package/dist/workspace/patch.d.ts.map +1 -0
  30. package/dist/workspace/points-cli.d.ts +8 -0
  31. package/dist/workspace/points-cli.d.ts.map +1 -1
  32. package/dist/workspace/reason-codes.d.ts +2 -0
  33. package/dist/workspace/reason-codes.d.ts.map +1 -1
  34. package/dist/workspace/record-decided.d.ts +36 -0
  35. package/dist/workspace/record-decided.d.ts.map +1 -0
  36. package/dist/workspace/records-cli.d.ts +8 -0
  37. package/dist/workspace/records-cli.d.ts.map +1 -1
  38. package/dist/workspace/status-stewards.d.ts +79 -12
  39. package/dist/workspace/status-stewards.d.ts.map +1 -1
  40. package/package.json +1 -1
  41. package/src/cli/main.test.ts +16 -0
  42. package/src/cli/main.ts +34 -3
  43. package/src/cli/registry.ts +9 -1
  44. package/src/lifecycle/run-ledger.ts +7 -1
  45. package/src/op/activities/shell.ts +4 -1
  46. package/src/op/index.ts +2 -0
  47. package/src/op/local-executor.ts +51 -3
  48. package/src/op/operator.ts +2 -11
  49. package/src/op/run-live.test.ts +111 -0
  50. package/src/op/run-live.ts +305 -0
  51. package/src/op/runtime.ts +2 -0
  52. package/src/op/steward-points.test.ts +81 -0
  53. package/src/workspace/checks/diagrams.test.ts +24 -0
  54. package/src/workspace/checks/diagrams.ts +10 -3
  55. package/src/workspace/declaration.schema.json +50 -7
  56. package/src/workspace/declaration.test.ts +18 -0
  57. package/src/workspace/declaration.ts +7 -7
  58. package/src/workspace/intent-cli.ts +47 -1
  59. package/src/workspace/intent-record.schema.json +529 -0
  60. package/src/workspace/intent-record.test.ts +257 -0
  61. package/src/workspace/intent-record.ts +371 -0
  62. package/src/workspace/intent.schema.json +33 -1
  63. package/src/workspace/intent.ts +29 -12
  64. package/src/workspace/ls-contract.test.ts +14 -0
  65. package/src/workspace/ls.schema.json +3 -3
  66. package/src/workspace/ls.ts +3 -3
  67. package/src/workspace/patch.schema.json +321 -0
  68. package/src/workspace/patch.test.ts +198 -0
  69. package/src/workspace/patch.ts +377 -0
  70. package/src/workspace/points-cli.ts +18 -0
  71. package/src/workspace/read-contract.test.ts +29 -1
  72. package/src/workspace/reason-codes.test.ts +2 -0
  73. package/src/workspace/reason-codes.ts +3 -0
  74. package/src/workspace/record-decided.ts +107 -0
  75. package/src/workspace/records-cli.ts +12 -0
  76. package/src/workspace/records.schema.json +15 -0
  77. package/src/workspace/status-contract.test.ts +76 -2
  78. package/src/workspace/status-stewards.ts +149 -27
  79. 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
- * as the run ledger records it. The question's state now is `points`'s.
20
- * A run is the steward's when its record names it (`steward`, written for
21
- * a run started in the steward's turn or under `CHANT_STEWARD`), so this
22
- * also covers the member's other Ops, those no steward lists, that such a
23
- * run belongs to, such as an Op the steward's process starts itself;
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
- /** The open decision point the run stopped on, for a `waiting` run (#2749). */
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
- /** An open decision point a steward's run stopped on (#2749), as the run ledger records it. */
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
- /** The question's state when the run stopped. */
85
- state: "escalated" | "proposed";
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. Its declared
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
- function waitOf(p: NonNullable<OpRunRecord["point"]>): StatusStewardWait {
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
- point: p.point,
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: newest.point ? waitOf(newest.point) : null,
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 (newest.status === "waiting" && lastRun.point && newest.steward === declaration.name) {
318
- waiting.push({ op: op.name, run: newest.id, ...lastRun.point });
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: op.workLease
347
- ? { kind: op.workLease.kind ?? null, held: await readHeldWorkLeases(declaration.name, op, memberDir, now) }
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
- steward.waiting.push({ op: op.name, run: newest.id, ...waitOf(newest.point) });
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({