@intentius/chant 0.89.0 → 0.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/dist/cli/handlers/components.d.ts +8 -0
  2. package/dist/cli/handlers/components.d.ts.map +1 -1
  3. package/dist/cli/handlers/operator.d.ts +14 -0
  4. package/dist/cli/handlers/operator.d.ts.map +1 -1
  5. package/dist/cli/handlers/run.d.ts.map +1 -1
  6. package/dist/cli/handlers/serve.d.ts.map +1 -1
  7. package/dist/cli/main.d.ts.map +1 -1
  8. package/dist/cli/mcp/server.d.ts +10 -5
  9. package/dist/cli/mcp/server.d.ts.map +1 -1
  10. package/dist/cli/mcp/types.d.ts +13 -5
  11. package/dist/cli/mcp/types.d.ts.map +1 -1
  12. package/dist/cli/mcp/workspace-tools.d.ts +55 -0
  13. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -0
  14. package/dist/cli/registry.d.ts +31 -0
  15. package/dist/cli/registry.d.ts.map +1 -1
  16. package/dist/components/verbs/vuln-scan.d.ts +72 -0
  17. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  18. package/dist/lifecycle/git.d.ts +40 -5
  19. package/dist/lifecycle/git.d.ts.map +1 -1
  20. package/dist/lifecycle/lease.d.ts +72 -16
  21. package/dist/lifecycle/lease.d.ts.map +1 -1
  22. package/dist/lifecycle/member-ledger.d.ts +3 -2
  23. package/dist/lifecycle/member-ledger.d.ts.map +1 -1
  24. package/dist/lifecycle/plan-ledger.d.ts +114 -0
  25. package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
  26. package/dist/lifecycle/work-lease.d.ts +140 -0
  27. package/dist/lifecycle/work-lease.d.ts.map +1 -0
  28. package/dist/op/activities/activity-contracts.d.ts +2 -2
  29. package/dist/op/builders.d.ts.map +1 -1
  30. package/dist/op/discover.d.ts +25 -0
  31. package/dist/op/discover.d.ts.map +1 -1
  32. package/dist/op/index.d.ts +9 -3
  33. package/dist/op/index.d.ts.map +1 -1
  34. package/dist/op/lifecycle-receipt-store.d.ts +34 -0
  35. package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
  36. package/dist/op/local-executor.d.ts +19 -0
  37. package/dist/op/local-executor.d.ts.map +1 -1
  38. package/dist/op/local-output.d.ts.map +1 -1
  39. package/dist/op/op-ir.d.ts +10 -1
  40. package/dist/op/op-ir.d.ts.map +1 -1
  41. package/dist/op/op-verb-class.d.ts.map +1 -1
  42. package/dist/op/operator.d.ts +29 -0
  43. package/dist/op/operator.d.ts.map +1 -1
  44. package/dist/op/runtime.d.ts +9 -0
  45. package/dist/op/runtime.d.ts.map +1 -1
  46. package/dist/op/runtimes/local.d.ts.map +1 -1
  47. package/dist/op/step-output-ref.d.ts +2 -2
  48. package/dist/op/step-output-ref.d.ts.map +1 -1
  49. package/dist/op/steward.d.ts +140 -0
  50. package/dist/op/steward.d.ts.map +1 -0
  51. package/dist/op/types.d.ts +51 -0
  52. package/dist/op/types.d.ts.map +1 -1
  53. package/dist/op/work-lease-decl.d.ts +18 -0
  54. package/dist/op/work-lease-decl.d.ts.map +1 -0
  55. package/dist/op/work-lease-run.d.ts +173 -0
  56. package/dist/op/work-lease-run.d.ts.map +1 -0
  57. package/dist/workspace/box-isolation.d.ts +99 -0
  58. package/dist/workspace/box-isolation.d.ts.map +1 -0
  59. package/dist/workspace/checks/box-isolation.d.ts +18 -0
  60. package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
  61. package/dist/workspace/checks/boxes.d.ts +71 -0
  62. package/dist/workspace/checks/boxes.d.ts.map +1 -0
  63. package/dist/workspace/checks/records.d.ts +1 -0
  64. package/dist/workspace/checks/records.d.ts.map +1 -1
  65. package/dist/workspace/checks.d.ts +10 -1
  66. package/dist/workspace/checks.d.ts.map +1 -1
  67. package/dist/workspace/conformance/index.d.ts +42 -2
  68. package/dist/workspace/conformance/index.d.ts.map +1 -1
  69. package/dist/workspace/conformance/vitest.d.ts.map +1 -1
  70. package/dist/workspace/decide.d.ts +184 -0
  71. package/dist/workspace/decide.d.ts.map +1 -0
  72. package/dist/workspace/decision-points.schema.json +137 -0
  73. package/dist/workspace/declaration.d.ts +51 -0
  74. package/dist/workspace/declaration.d.ts.map +1 -1
  75. package/dist/workspace/declaration.schema.json +172 -0
  76. package/dist/workspace/declared-kinds.d.ts +12 -0
  77. package/dist/workspace/declared-kinds.d.ts.map +1 -1
  78. package/dist/workspace/points-cli.d.ts +113 -0
  79. package/dist/workspace/points-cli.d.ts.map +1 -0
  80. package/dist/workspace/points.d.ts +320 -0
  81. package/dist/workspace/points.d.ts.map +1 -0
  82. package/dist/workspace/reason-codes.d.ts +29 -0
  83. package/dist/workspace/reason-codes.d.ts.map +1 -1
  84. package/dist/workspace/record-assets.d.ts.map +1 -1
  85. package/dist/workspace/records-cli.d.ts +12 -0
  86. package/dist/workspace/records-cli.d.ts.map +1 -1
  87. package/dist/workspace/records-write.d.ts +35 -3
  88. package/dist/workspace/records-write.d.ts.map +1 -1
  89. package/dist/workspace/records.d.ts +12 -3
  90. package/dist/workspace/records.d.ts.map +1 -1
  91. package/dist/workspace/source-block.d.ts +85 -0
  92. package/dist/workspace/source-block.d.ts.map +1 -0
  93. package/dist/workspace/status-stewards.d.ts +121 -0
  94. package/dist/workspace/status-stewards.d.ts.map +1 -0
  95. package/dist/workspace/status.d.ts +52 -1
  96. package/dist/workspace/status.d.ts.map +1 -1
  97. package/dist/workspace/work-cli.d.ts +78 -0
  98. package/dist/workspace/work-cli.d.ts.map +1 -0
  99. package/package.json +1 -1
  100. package/src/cli/handlers/components.test.ts +93 -0
  101. package/src/cli/handlers/components.ts +44 -3
  102. package/src/cli/handlers/operator.ts +107 -2
  103. package/src/cli/handlers/run.test.ts +19 -0
  104. package/src/cli/handlers/run.ts +53 -1
  105. package/src/cli/handlers/serve.ts +2 -1
  106. package/src/cli/main.test.ts +40 -0
  107. package/src/cli/main.ts +78 -2
  108. package/src/cli/mcp/docs-parity.test.ts +20 -2
  109. package/src/cli/mcp/server.ts +23 -6
  110. package/src/cli/mcp/types.ts +15 -2
  111. package/src/cli/mcp/workspace-tools.test.ts +211 -0
  112. package/src/cli/mcp/workspace-tools.ts +449 -0
  113. package/src/cli/registry.ts +31 -0
  114. package/src/components/verbs/vuln-scan.test.ts +124 -1
  115. package/src/components/verbs/vuln-scan.ts +142 -1
  116. package/src/lifecycle/git.ts +65 -15
  117. package/src/lifecycle/lease.test.ts +22 -0
  118. package/src/lifecycle/lease.ts +133 -29
  119. package/src/lifecycle/member-ledger.ts +3 -2
  120. package/src/lifecycle/plan-ledger.test.ts +148 -0
  121. package/src/lifecycle/plan-ledger.ts +158 -0
  122. package/src/lifecycle/work-lease.test.ts +236 -0
  123. package/src/lifecycle/work-lease.ts +426 -0
  124. package/src/op/builders.ts +5 -0
  125. package/src/op/discover.ts +71 -0
  126. package/src/op/index.ts +16 -3
  127. package/src/op/lifecycle-receipt-store.test.ts +60 -0
  128. package/src/op/lifecycle-receipt-store.ts +61 -0
  129. package/src/op/local-executor.ts +216 -18
  130. package/src/op/local-output.ts +13 -0
  131. package/src/op/op-ir.ts +14 -0
  132. package/src/op/op-verb-class.ts +6 -0
  133. package/src/op/operator.ts +75 -4
  134. package/src/op/runtime.ts +6 -0
  135. package/src/op/runtimes/local.ts +3 -0
  136. package/src/op/step-output-ref.ts +6 -2
  137. package/src/op/steward.test.ts +212 -0
  138. package/src/op/steward.ts +253 -0
  139. package/src/op/types.ts +53 -0
  140. package/src/op/work-lease-decl.ts +80 -0
  141. package/src/op/work-lease-run.test.ts +326 -0
  142. package/src/op/work-lease-run.ts +395 -0
  143. package/src/workspace/box-isolation.test.ts +261 -0
  144. package/src/workspace/box-isolation.ts +205 -0
  145. package/src/workspace/check-contract.test.ts +3 -1
  146. package/src/workspace/check.schema.json +15 -7
  147. package/src/workspace/checks/box-isolation.ts +68 -0
  148. package/src/workspace/checks/boxes.test.ts +197 -0
  149. package/src/workspace/checks/boxes.ts +307 -0
  150. package/src/workspace/checks/records.ts +25 -0
  151. package/src/workspace/checks.test.ts +7 -0
  152. package/src/workspace/checks.ts +19 -2
  153. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -0
  154. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +100 -6
  155. package/src/workspace/conformance/index.mjs +3 -0
  156. package/src/workspace/conformance/index.ts +185 -7
  157. package/src/workspace/conformance/vitest.ts +17 -8
  158. package/src/workspace/decide.test.ts +224 -0
  159. package/src/workspace/decide.ts +576 -0
  160. package/src/workspace/decision-points.schema.json +137 -0
  161. package/src/workspace/declaration.schema.json +172 -0
  162. package/src/workspace/declaration.ts +137 -0
  163. package/src/workspace/declared-kinds.ts +25 -2
  164. package/src/workspace/intent.schema.json +4 -1
  165. package/src/workspace/point-answer.schema.json +95 -0
  166. package/src/workspace/points-cli.ts +273 -0
  167. package/src/workspace/points-write.schema.json +489 -0
  168. package/src/workspace/points.schema.json +710 -0
  169. package/src/workspace/points.test.ts +264 -0
  170. package/src/workspace/points.ts +564 -0
  171. package/src/workspace/read-contract.test.ts +18 -1
  172. package/src/workspace/reason-codes.test.ts +14 -1
  173. package/src/workspace/reason-codes.ts +36 -0
  174. package/src/workspace/record-assets.test.ts +3 -2
  175. package/src/workspace/record-assets.ts +4 -1
  176. package/src/workspace/records-amend.schema.json +2 -1
  177. package/src/workspace/records-cli.ts +15 -2
  178. package/src/workspace/records-close.schema.json +2 -1
  179. package/src/workspace/records-contract.test.ts +3 -2
  180. package/src/workspace/records-new.schema.json +4 -1
  181. package/src/workspace/records-review.schema.json +2 -1
  182. package/src/workspace/records-write.ts +85 -7
  183. package/src/workspace/records.schema.json +18 -0
  184. package/src/workspace/records.ts +61 -3
  185. package/src/workspace/source-block.test.ts +167 -0
  186. package/src/workspace/source-block.ts +129 -0
  187. package/src/workspace/status-contract.test.ts +178 -0
  188. package/src/workspace/status-stewards.ts +225 -0
  189. package/src/workspace/status.schema.json +230 -4
  190. package/src/workspace/status.ts +106 -5
  191. package/src/workspace/work-cli.test.ts +180 -0
  192. package/src/workspace/work-cli.ts +246 -0
  193. package/src/workspace/work-lease.schema.json +233 -0
  194. package/src/workspace/work-readiness-chud.test.ts +145 -0
@@ -0,0 +1,61 @@
1
+ /**
2
+ * A receipt store on the `chant/lifecycle` branch (#2736, ws-056).
3
+ *
4
+ * The aws and k8s rows keep receipts in the cloud they deploy to (an SSM
5
+ * parameter, a ConfigMap). A target with no store of its own, such as a Fly
6
+ * Machine, keeps them next to the release ledger instead: one file per receipt
7
+ * at `<env>/receipts/<name>.receipt` on the lifecycle branch, written through
8
+ * the same plumbing as the release and build ledgers (../lifecycle/git.ts), so
9
+ * a workspace member's receipts land under its own `_members/<member>/` prefix.
10
+ *
11
+ * The receipt is per environment, not per checkout: every checkout that
12
+ * fetches the branch sees which effects have fired there. Writing is local;
13
+ * the ledger push that follows a recorded release (or `chant lifecycle push`)
14
+ * carries it to the remote.
15
+ */
16
+
17
+ import { readBlobFromPath, writeBlobToPath } from "../lifecycle/git";
18
+ import type { EffectReceiptRef, ReceiptStore } from "./receipt-store";
19
+
20
+ /** Options for {@link lifecycleReceiptStore}. */
21
+ export interface LifecycleReceiptStoreOptions {
22
+ /** The environment whose ledger directory holds the receipts. Omitted, `CHANT_ENV` answers. */
23
+ environment?: string;
24
+ /** The checkout whose lifecycle branch is written. Defaults to the working directory. */
25
+ cwd?: string;
26
+ /** Environment record the `CHANT_ENV` fallback reads. Defaults to `process.env`. */
27
+ env?: Record<string, string | undefined>;
28
+ }
29
+
30
+ /** The file name a receipt is kept under: its name with anything outside `[A-Za-z0-9_.-]` replaced. */
31
+ export function lifecycleReceiptFile(name: string): string {
32
+ return `receipts/${name.replace(/[^a-zA-Z0-9_.-]/g, "_")}.receipt`;
33
+ }
34
+
35
+ /**
36
+ * A {@link ReceiptStore} over the lifecycle branch. The environment is resolved
37
+ * at first use, so constructing the store reads nothing; with none given and no
38
+ * `CHANT_ENV`, a read or write fails rather than guessing a directory.
39
+ */
40
+ export function lifecycleReceiptStore(options: LifecycleReceiptStoreOptions = {}): ReceiptStore {
41
+ const environment = (): string => {
42
+ const env = options.environment ?? (options.env ?? process.env).CHANT_ENV;
43
+ if (!env) {
44
+ throw new Error(
45
+ "lifecycle receipt store: no environment. Receipts are kept per environment on chant/lifecycle; " +
46
+ "run with --env <name>, set CHANT_ENV, or pass the environment explicitly.",
47
+ );
48
+ }
49
+ return env;
50
+ };
51
+ const opts = options.cwd ? { cwd: options.cwd } : undefined;
52
+ return {
53
+ async read(receipt: EffectReceiptRef): Promise<string | undefined> {
54
+ const content = await readBlobFromPath(environment(), lifecycleReceiptFile(receipt.name), opts);
55
+ return content === null ? undefined : content.trim();
56
+ },
57
+ async write(receipt: EffectReceiptRef, expectation: string): Promise<void> {
58
+ await writeBlobToPath(environment(), lifecycleReceiptFile(receipt.name), `${expectation}\n`, `Receipt ${receipt.name}`, opts);
59
+ },
60
+ };
61
+ }
@@ -32,6 +32,8 @@ import type { PendingGateRecord } from "../lifecycle/gate-ledger";
32
32
  import { appendRunRecord, buildRunRecord } from "../lifecycle/run-ledger";
33
33
  import type { OpRunRecord } from "./runtime";
34
34
  import { randomUUID } from "node:crypto";
35
+ import { RunWorkLease, LEASE_LOST, workLeaseProblems, workLeaseNeedsRunItem, type WorkLeaseRunResult } from "./work-lease-run";
36
+ import { WORK_LEASE_STEP_ID } from "./types";
35
37
 
36
38
  export { parseDuration } from "./duration";
37
39
 
@@ -110,6 +112,12 @@ export interface OpRunResult {
110
112
  * document.
111
113
  */
112
114
  record: OpRunRecord;
115
+ /**
116
+ * How the run's work lease went, for an Op that declares `workLease`
117
+ * (#2748): the item it claimed, or why it claimed none, whether the lease
118
+ * was released and with which outcome, or why it was lost.
119
+ */
120
+ workLease?: WorkLeaseRunResult;
113
121
  }
114
122
 
115
123
  // ── Errors ────────────────────────────────────────────────────────────────��─
@@ -165,6 +173,23 @@ class GateStop extends Error {
165
173
  }
166
174
  }
167
175
 
176
+ /**
177
+ * Internal: a step boundary found the run can't go on under its work lease
178
+ * (#2748). `lost` false is a claim that found nothing to claim: not a
179
+ * failure, the run ends `ok` with the rest skipped. `lost` true is a lease
180
+ * that was lost: the run fails with `lease-lost`.
181
+ */
182
+ class WorkStop extends Error {
183
+ constructor(
184
+ public readonly records: StepRecord[],
185
+ public readonly phase: string,
186
+ public readonly lost: boolean,
187
+ ) {
188
+ super(lost ? LEASE_LOST : "nothing to claim");
189
+ this.name = "WorkStop";
190
+ }
191
+ }
192
+
168
193
  // ── Helpers ───────────────────────────────────────────────────────────────��─
169
194
 
170
195
  const DEFAULT_PROFILE = "fastIdempotent";
@@ -391,6 +416,68 @@ interface GateContext {
391
416
  runId?: string;
392
417
  /** Called once per settled step, in production order (#2121) — what `--progress-json` streams from. */
393
418
  onRecord?: (record: StepRecord) => void;
419
+ /** The run's hold on its work item's lease (#2748). Absent in compensation phases, which run whatever the lease did. */
420
+ work?: RunWorkLease;
421
+ }
422
+
423
+ /**
424
+ * The work lease at a step boundary (#2748): claim it when it is due, and
425
+ * say whether the run can go on. The claim's record is pushed onto `sink`.
426
+ * Returns `undefined` to go on, `"unclaimed"` when nothing could be claimed,
427
+ * and `"lost"` when the lease was lost.
428
+ */
429
+ async function workBoundary(
430
+ phaseName: string,
431
+ ctx: GateContext,
432
+ resultsById: Map<string, unknown>,
433
+ sink: StepRecord[],
434
+ ): Promise<"unclaimed" | "lost" | undefined> {
435
+ const work = ctx.work;
436
+ if (!work) return undefined;
437
+ if (work.due(resultsById)) {
438
+ const start = Date.now();
439
+ let claimed: Awaited<ReturnType<RunWorkLease["claim"]>>;
440
+ try {
441
+ claimed = await work.claim(resultsById);
442
+ } catch (err) {
443
+ pushRecord(sink, ctx, {
444
+ phase: phaseName,
445
+ fn: "workLease:claim",
446
+ args: {},
447
+ status: "fail",
448
+ durationMs: Date.now() - start,
449
+ error: errMessage(err),
450
+ });
451
+ throw new PhaseFailure(sink);
452
+ }
453
+ if (claimed.kind === "unclaimed") {
454
+ pushRecord(sink, ctx, {
455
+ phase: phaseName,
456
+ fn: "workLease:claim",
457
+ args: { items: claimed.tried },
458
+ status: "skipped",
459
+ durationMs: Date.now() - start,
460
+ refusal: claimed.refusal,
461
+ outcome: { name: "WorkItem", value: null },
462
+ outcomes: [{ name: "WorkItem", value: null }],
463
+ });
464
+ return "unclaimed";
465
+ }
466
+ resultsById.set(WORK_LEASE_STEP_ID, claimed.lease);
467
+ pushRecord(sink, ctx, {
468
+ phase: phaseName,
469
+ fn: "workLease:claim",
470
+ args: { items: claimed.tried },
471
+ status: "ok",
472
+ durationMs: Date.now() - start,
473
+ outcome: { name: "WorkItem", value: claimed.lease.item },
474
+ outcomes: [
475
+ { name: "WorkItem", value: claimed.lease.item },
476
+ { name: "WorkLeaseToken", value: claimed.lease.token },
477
+ ],
478
+ });
479
+ }
480
+ return work.lost !== undefined ? "lost" : undefined;
394
481
  }
395
482
 
396
483
  /** Collect records and hand each to the caller's progress sink in one move. */
@@ -666,6 +753,13 @@ async function runPhase(
666
753
  // read of the ledger, not work, and starting activities that a pending
667
754
  // gate is about to strand would defeat the point of stopping at it.
668
755
  const gateRecords: StepRecord[] = [];
756
+ const stop = await workBoundary(phase.name, gates, resultsById, gateRecords);
757
+ if (stop) {
758
+ for (const skipped of phase.steps) {
759
+ pushRecord(gateRecords, gates, isGate(skipped) ? skippedRecord(phase.name, gateFn(skipped)) : skippedRecord(phase.name, (skipped as ActivityStep).fn, (skipped as ActivityStep).args));
760
+ }
761
+ throw new WorkStop(gateRecords, phase.name, stop === "lost");
762
+ }
669
763
  for (const step of phase.steps.filter(isGate)) {
670
764
  const { record, pending, pushed, pushWarning } = await runGateStep(step, phase.name, gates, resultsById);
671
765
  pushRecord(gateRecords, gates, record);
@@ -711,6 +805,13 @@ async function runPhase(
711
805
 
712
806
  for (let i = 0; i < steps.length; i++) {
713
807
  const step = steps[i];
808
+ // The work lease (#2748): claimed here when it is due, and checked before
809
+ // every step, so no step starts once the lease is lost.
810
+ const stop = await workBoundary(phase.name, gates, resultsById, records);
811
+ if (stop) {
812
+ skipRemaining(i);
813
+ throw new WorkStop(records, phase.name, stop === "lost");
814
+ }
714
815
  if (isGate(step)) {
715
816
  const { record, pending, pushed, pushWarning } = await runGateStep(step, phase.name, gates, resultsById);
716
817
  pushRecord(records, gates, record);
@@ -789,6 +890,15 @@ export interface RunOpOptions {
789
890
  * never promoted into a run failure.
790
891
  */
791
892
  onLedgerError?: (err: unknown) => void;
893
+ /**
894
+ * For an Op that declares `workLease` (#2748): the item this run is for
895
+ * (`chant run <op> --work <id>`), which takes the place of the Op's own, and
896
+ * who holds the lease (a steward's turn passes `stewardWorkHolder`).
897
+ * Defaults: the Op's item, and this process's holder id.
898
+ */
899
+ work?: { item?: string; holder?: string };
900
+ /** Called when a work-lease renewal fails without losing the lease; the next beat retries it. */
901
+ onWorkLeaseWarning?: (message: string) => void;
792
902
  }
793
903
 
794
904
  /**
@@ -839,6 +949,68 @@ export async function runOpLocally(
839
949
  }
840
950
  }
841
951
 
952
+ // The work lease (#2748). An Op that changes the checkout without one, or
953
+ // whose lease names no item and whose run names none, is refused before
954
+ // anything runs.
955
+ const workProblems = workLeaseProblems(config);
956
+ if (workProblems.length > 0) throw new Error(workProblems.join("\n"));
957
+ if (workLeaseNeedsRunItem(config) && !options.work?.item) {
958
+ throw new Error(
959
+ `Op "${config.name}" runs under a work item's lease and names no item: pass one with \`chant run ${config.name} --work <id>\``,
960
+ );
961
+ }
962
+ const work = config.workLease
963
+ ? new RunWorkLease(config, {
964
+ cwd: options.ledger?.cwd ?? options.cwd ?? process.cwd(),
965
+ ...(options.work?.holder ? { holder: options.work.holder } : {}),
966
+ ...(options.work?.item ? { item: options.work.item } : {}),
967
+ ...(options.onWorkLeaseWarning ? { onRenewError: options.onWorkLeaseWarning } : {}),
968
+ })
969
+ : undefined;
970
+ if (work) gates.work = work;
971
+
972
+ // Steps run under a signal that also fires when the work lease is lost, so
973
+ // the step in flight stops (chud's dispatcher stopped its agent the same way).
974
+ let stepSignal = signal;
975
+ if (work) {
976
+ const both = new AbortController();
977
+ const forward = () => both.abort();
978
+ if (signal?.aborted) both.abort();
979
+ else signal?.addEventListener("abort", forward, { once: true });
980
+ work.lostSignal.addEventListener("abort", forward, { once: true });
981
+ stepSignal = both.signal;
982
+ }
983
+
984
+ /** Release (or not) the work lease once the run's outcome is settled. */
985
+ const finishWork = async (status: OpRunResult["status"]): Promise<{ workLease?: WorkLeaseRunResult }> =>
986
+ work ? { workLease: await work.finish(status, resultsById) } : {};
987
+
988
+ /** The record a lost lease leaves, naming why. */
989
+ const lostRecord = (): StepRecord => ({
990
+ phase: UNATTRIBUTED_PHASE,
991
+ fn: "workLease:lost",
992
+ args: {},
993
+ status: "fail",
994
+ durationMs: 0,
995
+ error: `${LEASE_LOST}: ${work?.lost ?? "the lease was lost"}. The run stopped and was not recorded as done.`,
996
+ });
997
+
998
+ const skipLaterPhases = (after: string) => {
999
+ const stoppedAt = config.phases.findIndex((p) => p.name === after);
1000
+ if (stoppedAt < 0) return;
1001
+ for (const phase of config.phases.slice(stoppedAt + 1)) {
1002
+ for (const step of phase.steps) {
1003
+ records.push(
1004
+ isEffect(step)
1005
+ ? skippedRecord(phase.name, `effect:${step.receipt.name}`)
1006
+ : isGate(step)
1007
+ ? skippedRecord(phase.name, gateFn(step))
1008
+ : skippedRecord(phase.name, step.fn, step.args),
1009
+ );
1010
+ }
1011
+ }
1012
+ };
1013
+
842
1014
  const records: StepRecord[] = [];
843
1015
  const start = Date.now();
844
1016
  const startedAt = options.now ?? new Date(start).toISOString();
@@ -877,25 +1049,35 @@ export async function runOpLocally(
877
1049
  try {
878
1050
  for (const phase of config.phases) {
879
1051
  if (signal?.aborted) throw new PhaseFailure([]);
880
- records.push(...(await runPhase(phase, activities, profiles, resultsById, gates, signal)));
1052
+ records.push(...(await runPhase(phase, activities, profiles, resultsById, gates, stepSignal)));
881
1053
  }
1054
+ // The last renewal before the run is recorded (#2748): a run whose lease
1055
+ // was lost is never recorded as done.
1056
+ if (work && !(await work.fence())) throw new WorkStop([], UNATTRIBUTED_PHASE, true);
882
1057
  } catch (err) {
1058
+ // Nothing to claim, or every candidate held (#2748): not a failure. The
1059
+ // rest of the run is skipped and the claim's record says why.
1060
+ if (err instanceof WorkStop && !err.lost) {
1061
+ records.push(...err.records);
1062
+ skipLaterPhases(err.phase);
1063
+ const record = await settle(records, "ok");
1064
+ return {
1065
+ op: config.name,
1066
+ records,
1067
+ totalMs: Date.now() - start,
1068
+ status: "ok",
1069
+ startedAt,
1070
+ record,
1071
+ ...(await finishWork("ok")),
1072
+ };
1073
+ }
1074
+
883
1075
  // A pending gate ends the run where it stands: no later phase, and no
884
1076
  // `onFailure` compensation — nothing failed, so there is nothing to undo.
885
1077
  if (err instanceof GateStop) {
886
1078
  records.push(...err.records);
887
- const stoppedAt = config.phases.findIndex((p) => p.name === err.phase);
888
- for (const phase of config.phases.slice(stoppedAt + 1)) {
889
- for (const step of phase.steps) {
890
- records.push(
891
- isEffect(step)
892
- ? skippedRecord(phase.name, `effect:${step.receipt.name}`)
893
- : isGate(step)
894
- ? skippedRecord(phase.name, gateFn(step))
895
- : skippedRecord(phase.name, step.fn, step.args),
896
- );
897
- }
898
- }
1079
+ skipLaterPhases(err.phase);
1080
+ const record = await settle(records, "gated", err.pending);
899
1081
  return {
900
1082
  op: config.name,
901
1083
  records,
@@ -905,11 +1087,17 @@ export async function runOpLocally(
905
1087
  gate: err.pending,
906
1088
  ...(err.pushed !== undefined ? { gatePushed: err.pushed } : {}),
907
1089
  ...(err.pushWarning ? { gatePushWarning: err.pushWarning } : {}),
908
- record: await settle(records, "gated", err.pending),
1090
+ record,
1091
+ ...(await finishWork("gated")),
909
1092
  };
910
1093
  }
911
1094
 
912
- if (err instanceof PhaseFailure) {
1095
+ if (err instanceof WorkStop) {
1096
+ // The lease was lost (#2748): the rest is skipped, and the lost record
1097
+ // below says why.
1098
+ records.push(...err.records);
1099
+ skipLaterPhases(err.phase);
1100
+ } else if (err instanceof PhaseFailure) {
913
1101
  records.push(...err.records);
914
1102
  } else {
915
1103
  // Everything else the phase loop can throw (#2301). `PhaseFailure` and
@@ -934,12 +1122,18 @@ export async function runOpLocally(
934
1122
  });
935
1123
  }
936
1124
 
1125
+ // A step that failed because the lease was lost mid-step, or a lease lost
1126
+ // at any boundary: one record names it (#2748).
1127
+ if (work?.lost !== undefined) records.push(lostRecord());
1128
+
937
1129
  // Compensation: run onFailure phases in reverse order (best-effort). Skipped
938
1130
  // on abort (Ctrl-C) — the user asked to stop, so don't start new work.
1131
+ // It runs whatever the work lease did, so it takes no work boundary.
1132
+ const compensation: GateContext = { ...gates, work: undefined };
939
1133
  if (!signal?.aborted) {
940
1134
  for (const phase of [...(config.onFailure ?? [])].reverse()) {
941
1135
  try {
942
- records.push(...(await runPhase(phase, activities, profiles, resultsById, gates, signal)));
1136
+ records.push(...(await runPhase(phase, activities, profiles, resultsById, compensation, signal)));
943
1137
  } catch (compErr) {
944
1138
  if (compErr instanceof PhaseFailure || compErr instanceof GateStop) {
945
1139
  records.push(...compErr.records);
@@ -959,6 +1153,7 @@ export async function runOpLocally(
959
1153
  }
960
1154
  }
961
1155
 
1156
+ const failed = await settle(records, "fail");
962
1157
  throw new OpRunFailure(
963
1158
  {
964
1159
  op: config.name,
@@ -966,19 +1161,22 @@ export async function runOpLocally(
966
1161
  totalMs: Date.now() - start,
967
1162
  status: "fail",
968
1163
  startedAt,
969
- record: await settle(records, "fail"),
1164
+ record: failed,
1165
+ ...(await finishWork("fail")),
970
1166
  },
971
1167
  { cause: err },
972
1168
  );
973
1169
  }
974
1170
 
1171
+ const record = await settle(records, "ok");
975
1172
  return {
976
1173
  op: config.name,
977
1174
  records,
978
1175
  totalMs: Date.now() - start,
979
1176
  status: "ok",
980
1177
  startedAt,
981
- record: await settle(records, "ok"),
1178
+ record,
1179
+ ...(await finishWork("ok")),
982
1180
  };
983
1181
  }
984
1182
 
@@ -55,6 +55,19 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr): void {
55
55
  }
56
56
  }
57
57
 
58
+ // The work lease (#2748): which item the run held, and how it ended.
59
+ const work = result.workLease;
60
+ if (work?.item) {
61
+ const end = work.lost
62
+ ? `lost (${work.lost})`
63
+ : work.released
64
+ ? `released ${work.outcome}`
65
+ : "not released; it runs out on its own";
66
+ write(`[work] ${work.item} held by ${work.holder}${work.branch ? ` on ${work.branch}` : ""}: ${end}`);
67
+ } else if (work?.refusal) {
68
+ write(`[work] nothing claimed: ${work.refusal}`);
69
+ }
70
+
58
71
  const total = `${(result.totalMs / 1000).toFixed(1)}s`;
59
72
  if (result.status === "ok") {
60
73
  write(`Op "${result.op}" completed in ${total}`);
package/src/op/op-ir.ts CHANGED
@@ -86,6 +86,7 @@ import type {
86
86
  OutcomeAttribute,
87
87
  OpConfig,
88
88
  OpSchedule,
89
+ OpWorkLease,
89
90
  PhaseDefinition,
90
91
  StepDefinition,
91
92
  ActivityStep,
@@ -187,6 +188,15 @@ export interface OpIR {
187
188
  * additive optional key, so it needs no `formatVersion` bump.
188
189
  */
189
190
  schedule?: OpSchedule;
191
+ /**
192
+ * The work item the Op runs under (#2748) — `OpConfig.workLease` verbatim,
193
+ * a step-output reference in `item` or `outcome` in the same
194
+ * `{ kind: "step-output-ref", step, path }` shape as in args. Absent when it
195
+ * declares none. A runtime that can't take the lease must not run the Op.
196
+ */
197
+ workLease?: OpWorkLease;
198
+ /** `OpConfig.changesCheckout` (#2748), present only when true. */
199
+ changesCheckout?: true;
190
200
  phases: OpIRPhase[];
191
201
  onFailure: OpIRPhase[];
192
202
  /** Every activity profile referenced by a step in this Op, keyed by profile name — from the registry passed to {@link buildOpIR}, empty when none was. */
@@ -350,6 +360,8 @@ export function buildOpIR(
350
360
  depends: config.depends ?? [],
351
361
  labels: config.labels ?? {},
352
362
  ...(config.schedule ? { schedule: config.schedule } : {}),
363
+ ...(config.workLease ? { workLease: config.workLease } : {}),
364
+ ...(config.changesCheckout ? { changesCheckout: true as const } : {}),
353
365
  phases: config.phases.map((p) => irPhase(p, contractRegistry)),
354
366
  onFailure: (config.onFailure ?? []).map((p) => irPhase(p, contractRegistry)),
355
367
  activityProfiles: sortedEntries(profiles),
@@ -428,5 +440,7 @@ export function opConfigFromIR(ir: OpIR): OpConfig {
428
440
  ...(ir.onFailure.length > 0 ? { onFailure: ir.onFailure.map(opPhaseFromIR) } : {}),
429
441
  ...(Object.keys(ir.labels).length > 0 ? { labels: ir.labels } : {}),
430
442
  ...(ir.schedule ? { schedule: ir.schedule } : {}),
443
+ ...(ir.workLease ? { workLease: ir.workLease } : {}),
444
+ ...(ir.changesCheckout ? { changesCheckout: true } : {}),
431
445
  };
432
446
  }
@@ -47,6 +47,10 @@ const READ_ONLY_ACTIVITY_FNS: ReadonlySet<string> = new Set([
47
47
  "spriteListDir",
48
48
  "listCheckpoints",
49
49
  "convergeTick",
50
+ "spriteUrl",
51
+ "spriteServiceGet",
52
+ "spriteServiceList",
53
+ "spriteServiceLogs",
50
54
  ]);
51
55
 
52
56
  /** Activity function names that always delete/destroy, regardless of args. */
@@ -57,6 +61,8 @@ const ALWAYS_DESTRUCTIVE_ACTIVITY_FNS: ReadonlySet<string> = new Set([
57
61
  "awsDelete",
58
62
  "gcpDelete",
59
63
  "spriteDestroy",
64
+ "spriteDelete",
65
+ "spriteServiceDelete",
60
66
  "k3dDown",
61
67
  "k3sUninstall",
62
68
  ]);
@@ -39,6 +39,9 @@ import type { ActivityFn, ActivityProfile } from "./activity-registry";
39
39
  import { discoverOps, type DiscoveredOp } from "./discover";
40
40
  import { runOpLocally, OpRunFailure, type OpRunResult } from "./local-executor";
41
41
  import { acquireLease, stillHoldsLease, currentHolderId, DEFAULT_LEASE_TTL_MS, type LeaseRecord, type AcquireLeaseResult } from "../lifecycle/lease";
42
+ import { runEnvOf } from "../lifecycle/run-ledger";
43
+ import { stewardLeaseName, type StewardDeclaration } from "./steward";
44
+ import { stewardWorkHolder } from "./work-lease-run";
42
45
  import { StaleLockError } from "../lifecycle/git";
43
46
  import { cronMatches, cronDueBetween } from "./cron";
44
47
  import { createChangeSignalGate, DEFAULT_SIGNAL_FLOOR_MS, type WakeReason } from "./change-signal";
@@ -94,7 +97,14 @@ export type OperatorTickEvent =
94
97
  * back off silently forever instead of surfacing the fix. One op's lease
95
98
  * error never aborts the round for every other op.
96
99
  */
97
- | { kind: "lease-error"; op: string; env: string; error: string };
100
+ | { kind: "lease-error"; op: string; env: string; error: string }
101
+ /**
102
+ * A local steward's round (#2731) found the steward's own lease held by
103
+ * another live holder: another `chant operator --steward` for the same
104
+ * steward. Nothing ticked. `op` is the steward's lease name and `env` is
105
+ * `-`, so a log line keyed on either still reads.
106
+ */
107
+ | { kind: "steward-busy"; op: string; env: string; steward: string; heldBy?: string };
98
108
 
99
109
  export interface OperatorRoundOptions {
100
110
  cwd?: string;
@@ -119,6 +129,30 @@ export interface OperatorRoundOptions {
119
129
  * round makes up exactly one tick rather than a backlog.
120
130
  */
121
131
  scheduleState?: Map<string, Date>;
132
+ /**
133
+ * Run a local steward's Ops instead of every discovered ConvergeOp (#2731).
134
+ * Only its scheduled Ops tick, each on its own cron, because that is what
135
+ * the same steward does on Fountain: an Op with no schedule runs when
136
+ * someone asks for it. The round first takes (or renews) the steward's own
137
+ * lease, and ticks nothing when another holder has it.
138
+ */
139
+ steward?: StewardDeclaration;
140
+ }
141
+
142
+ /** Take or renew a local steward's own lease (#2731). */
143
+ export async function acquireStewardLease(
144
+ steward: string,
145
+ holder: string,
146
+ opts: { cwd?: string; ttlMs?: number; now?: () => Date } = {},
147
+ ): Promise<AcquireLeaseResult> {
148
+ return acquireLease(stewardLeaseName(steward), holder, { ...opts, ttlMs: opts.ttlMs ?? DEFAULT_LEASE_TTL_MS });
149
+ }
150
+
151
+ /** The Ops one round considers: a steward's scheduled ones, or every ConvergeOp. */
152
+ async function roundOps(opts: OperatorRoundOptions): Promise<DiscoveredOp["config"][]> {
153
+ if (opts.steward) return opts.steward.ops.filter((op) => op.schedule !== undefined);
154
+ const { ops } = await discoverConvergeOps({ cwd: opts.cwd, env: opts.env });
155
+ return ops.map((d) => d.config);
122
156
  }
123
157
 
124
158
  /**
@@ -129,11 +163,42 @@ export interface OperatorRoundOptions {
129
163
  */
130
164
  export async function runOperatorRound(opts: OperatorRoundOptions): Promise<OperatorTickEvent[]> {
131
165
  const holder = opts.holder ?? currentHolderId();
132
- const { ops } = await discoverConvergeOps({ cwd: opts.cwd, env: opts.env });
133
166
  const events: OperatorTickEvent[] = [];
167
+ const steward = opts.steward;
168
+ const leaseOpts = { cwd: opts.cwd, ttlMs: opts.leaseTtlMs, now: opts.now };
169
+
170
+ // A local steward is one writer (#2731): its round runs only while it holds
171
+ // its own lease, renewed here and again after every tick, so a turn longer
172
+ // than the lease's TTL still keeps it.
173
+ const holdSteward = async (): Promise<boolean> => {
174
+ if (!steward) return true;
175
+ let acquired: AcquireLeaseResult;
176
+ try {
177
+ acquired = await acquireStewardLease(steward.name, holder, leaseOpts);
178
+ } catch (err) {
179
+ events.push({
180
+ kind: "lease-error",
181
+ op: stewardLeaseName(steward.name),
182
+ env: "-",
183
+ error: err instanceof Error ? err.message : String(err),
184
+ });
185
+ return false;
186
+ }
187
+ if (acquired.acquired) return true;
188
+ events.push({
189
+ kind: "steward-busy",
190
+ op: stewardLeaseName(steward.name),
191
+ env: "-",
192
+ steward: steward.name,
193
+ ...(acquired.heldBy?.holder ? { heldBy: acquired.heldBy.holder } : {}),
194
+ });
195
+ return false;
196
+ };
197
+
198
+ if (!(await holdSteward())) return events;
134
199
 
135
- for (const { config } of ops) {
136
- const env = envOf(config) ?? "unknown";
200
+ for (const config of await roundOps(opts)) {
201
+ const env = steward ? runEnvOf(config) : (envOf(config) ?? "unknown");
137
202
 
138
203
  // An Op that declares its own cadence (#2120) is ticked on that cron
139
204
  // rather than on every round. Level-triggered: the question is whether a
@@ -180,6 +245,9 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
180
245
  try {
181
246
  const result = await runOpLocally(config, opts.activities, opts.profiles, opts.signal, {
182
247
  ledger: { cwd: opts.cwd },
248
+ // A steward's turn claims work leases as `<steward>/<op>@<holder>`
249
+ // (#2748), which is how `workspace status` finds the lease it holds.
250
+ ...(steward ? { work: { holder: stewardWorkHolder(steward.name, config.name, holder) } } : {}),
183
251
  // #2301: without a sink, `settle` catches a failed ledger append and
184
252
  // drops it. That is the failure this tick can least afford to lose —
185
253
  // the message below points the reader at the ledger record, which is
@@ -210,6 +278,7 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
210
278
  : String(err);
211
279
  events.push({ kind: "tick-failed", op: config.name, env, error: message });
212
280
  }
281
+ if (!(await holdSteward())) break;
213
282
  }
214
283
 
215
284
  return events;
@@ -430,5 +499,7 @@ export function formatRoundLine(event: OperatorTickEvent): string {
430
499
  return `operator: ${event.op}@${event.env} fenced=1(lease lost mid-tick — ledger record still written)`;
431
500
  case "lease-error":
432
501
  return `operator: ${event.op}@${event.env} error=1(lease acquire failed — ${event.error})`;
502
+ case "steward-busy":
503
+ return `operator: steward ${event.steward} skipped=1(steward-lease-held${event.heldBy ? `:${event.heldBy}` : ""})`;
433
504
  }
434
505
  }
package/src/op/runtime.ts CHANGED
@@ -156,6 +156,12 @@ export interface OpRunStartOptions {
156
156
  progress?: (record: StepRecord) => void;
157
157
  /** Aborts in-flight work (Ctrl-C). */
158
158
  signal?: AbortSignal;
159
+ /**
160
+ * For an Op that declares `workLease` (#2748): the work item this run is
161
+ * for (`--work <id>`) and who holds its lease (`--holder`). Only the local
162
+ * runtime takes it; a hosted run's lease is taken where the run executes.
163
+ */
164
+ work?: { item?: string; holder?: string };
159
165
  }
160
166
 
161
167
  /**
@@ -166,6 +166,9 @@ export function createLocalOpRuntime(opts: { projectPath?: string } = {}): OpRun
166
166
  `appended to the run ledger: ${err instanceof Error ? err.message : String(err)}\n`,
167
167
  ),
168
168
  ...(startOpts.progress ? { onRecord: startOpts.progress } : {}),
169
+ ...(startOpts.work ? { work: startOpts.work } : {}),
170
+ onWorkLeaseWarning: (message) =>
171
+ process.stderr.write(`warning: "${op.name}" could not renew its work lease, retrying at the next beat: ${message}\n`),
169
172
  },
170
173
  );
171
174
  const status = statusFrom(op.name, runId, startedAt, result);
@@ -87,6 +87,7 @@
87
87
 
88
88
  import { z } from "zod";
89
89
  import type { ActivityStep, OpConfig, PhaseDefinition } from "./types";
90
+ import { WORK_LEASE_STEP_ID } from "./types";
90
91
  import { pathExistsInSchema, schemaAtPath, primitiveKindOf, type ActivityContract, type ActivityContractIssue } from "./activity-contract";
91
92
 
92
93
  const STEP_OUTPUT_REF_BRAND = Symbol.for("chant.op.stepOutputRef");
@@ -308,7 +309,7 @@ function indexById(locations: StepLocation[]): { byId: Map<string, StepLocation>
308
309
  * the same parallel phase).
309
310
  */
310
311
  export function validateStepOutputRefScope(
311
- config: Pick<OpConfig, "name" | "phases" | "onFailure">,
312
+ config: Pick<OpConfig, "name" | "phases" | "onFailure"> & Partial<Pick<OpConfig, "workLease">>,
312
313
  ): ActivityContractIssue[] {
313
314
  const issues: ActivityContractIssue[] = [];
314
315
 
@@ -339,6 +340,9 @@ export function validateStepOutputRefScope(
339
340
  for (const consumer of locations) {
340
341
  const refs = collectStepOutputRefs(consumer.step.args);
341
342
  for (const ref of refs) {
343
+ // The run's work lease (#2748) is published under a reserved id once it
344
+ // is claimed; an Op that declares one may reference it from any step.
345
+ if (config.workLease && ref.step === WORK_LEASE_STEP_ID) continue;
342
346
  if (duplicateIds.has(ref.step)) {
343
347
  issues.push({
344
348
  opName: config.name,
@@ -395,7 +399,7 @@ export function validateStepOutputRefScope(
395
399
  * schema (an empty `path` — the whole return value — is always valid).
396
400
  */
397
401
  export function validateStepOutputRefs(
398
- config: Pick<OpConfig, "name" | "phases" | "onFailure">,
402
+ config: Pick<OpConfig, "name" | "phases" | "onFailure"> & Partial<Pick<OpConfig, "workLease">>,
399
403
  contracts: ReadonlyMap<string, ActivityContract>,
400
404
  ): ActivityContractIssue[] {
401
405
  const issues = validateStepOutputRefScope(config);