@intentius/chant 0.90.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 (164) 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/main.d.ts.map +1 -1
  7. package/dist/cli/mcp/workspace-tools.d.ts +6 -4
  8. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +31 -0
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/components/verbs/vuln-scan.d.ts +72 -0
  12. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  13. package/dist/lifecycle/git.d.ts +40 -5
  14. package/dist/lifecycle/git.d.ts.map +1 -1
  15. package/dist/lifecycle/lease.d.ts +72 -16
  16. package/dist/lifecycle/lease.d.ts.map +1 -1
  17. package/dist/lifecycle/member-ledger.d.ts +3 -2
  18. package/dist/lifecycle/member-ledger.d.ts.map +1 -1
  19. package/dist/lifecycle/plan-ledger.d.ts +114 -0
  20. package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
  21. package/dist/lifecycle/work-lease.d.ts +140 -0
  22. package/dist/lifecycle/work-lease.d.ts.map +1 -0
  23. package/dist/op/activities/activity-contracts.d.ts +2 -2
  24. package/dist/op/builders.d.ts.map +1 -1
  25. package/dist/op/discover.d.ts +25 -0
  26. package/dist/op/discover.d.ts.map +1 -1
  27. package/dist/op/index.d.ts +9 -3
  28. package/dist/op/index.d.ts.map +1 -1
  29. package/dist/op/lifecycle-receipt-store.d.ts +34 -0
  30. package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
  31. package/dist/op/local-executor.d.ts +19 -0
  32. package/dist/op/local-executor.d.ts.map +1 -1
  33. package/dist/op/local-output.d.ts.map +1 -1
  34. package/dist/op/op-ir.d.ts +10 -1
  35. package/dist/op/op-ir.d.ts.map +1 -1
  36. package/dist/op/operator.d.ts +29 -0
  37. package/dist/op/operator.d.ts.map +1 -1
  38. package/dist/op/runtime.d.ts +9 -0
  39. package/dist/op/runtime.d.ts.map +1 -1
  40. package/dist/op/runtimes/local.d.ts.map +1 -1
  41. package/dist/op/step-output-ref.d.ts +2 -2
  42. package/dist/op/step-output-ref.d.ts.map +1 -1
  43. package/dist/op/steward.d.ts +140 -0
  44. package/dist/op/steward.d.ts.map +1 -0
  45. package/dist/op/types.d.ts +51 -0
  46. package/dist/op/types.d.ts.map +1 -1
  47. package/dist/op/work-lease-decl.d.ts +18 -0
  48. package/dist/op/work-lease-decl.d.ts.map +1 -0
  49. package/dist/op/work-lease-run.d.ts +173 -0
  50. package/dist/op/work-lease-run.d.ts.map +1 -0
  51. package/dist/workspace/box-isolation.d.ts +99 -0
  52. package/dist/workspace/box-isolation.d.ts.map +1 -0
  53. package/dist/workspace/checks/box-isolation.d.ts +18 -0
  54. package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
  55. package/dist/workspace/checks/boxes.d.ts +71 -0
  56. package/dist/workspace/checks/boxes.d.ts.map +1 -0
  57. package/dist/workspace/checks/records.d.ts +1 -0
  58. package/dist/workspace/checks/records.d.ts.map +1 -1
  59. package/dist/workspace/checks.d.ts +10 -1
  60. package/dist/workspace/checks.d.ts.map +1 -1
  61. package/dist/workspace/decide.d.ts +184 -0
  62. package/dist/workspace/decide.d.ts.map +1 -0
  63. package/dist/workspace/decision-points.schema.json +137 -0
  64. package/dist/workspace/declaration.d.ts +51 -0
  65. package/dist/workspace/declaration.d.ts.map +1 -1
  66. package/dist/workspace/declaration.schema.json +172 -0
  67. package/dist/workspace/declared-kinds.d.ts +12 -0
  68. package/dist/workspace/declared-kinds.d.ts.map +1 -1
  69. package/dist/workspace/points-cli.d.ts +113 -0
  70. package/dist/workspace/points-cli.d.ts.map +1 -0
  71. package/dist/workspace/points.d.ts +320 -0
  72. package/dist/workspace/points.d.ts.map +1 -0
  73. package/dist/workspace/reason-codes.d.ts +26 -0
  74. package/dist/workspace/reason-codes.d.ts.map +1 -1
  75. package/dist/workspace/record-assets.d.ts.map +1 -1
  76. package/dist/workspace/records-cli.d.ts +12 -0
  77. package/dist/workspace/records-cli.d.ts.map +1 -1
  78. package/dist/workspace/records.d.ts +8 -2
  79. package/dist/workspace/records.d.ts.map +1 -1
  80. package/dist/workspace/status-stewards.d.ts +121 -0
  81. package/dist/workspace/status-stewards.d.ts.map +1 -0
  82. package/dist/workspace/status.d.ts +52 -1
  83. package/dist/workspace/status.d.ts.map +1 -1
  84. package/dist/workspace/work-cli.d.ts +78 -0
  85. package/dist/workspace/work-cli.d.ts.map +1 -0
  86. package/package.json +1 -1
  87. package/src/cli/handlers/components.test.ts +93 -0
  88. package/src/cli/handlers/components.ts +44 -3
  89. package/src/cli/handlers/operator.ts +107 -2
  90. package/src/cli/handlers/run.test.ts +19 -0
  91. package/src/cli/handlers/run.ts +53 -1
  92. package/src/cli/main.test.ts +40 -0
  93. package/src/cli/main.ts +78 -2
  94. package/src/cli/mcp/workspace-tools.test.ts +14 -1
  95. package/src/cli/mcp/workspace-tools.ts +49 -5
  96. package/src/cli/registry.ts +31 -0
  97. package/src/components/verbs/vuln-scan.test.ts +124 -1
  98. package/src/components/verbs/vuln-scan.ts +142 -1
  99. package/src/lifecycle/git.ts +65 -15
  100. package/src/lifecycle/lease.test.ts +22 -0
  101. package/src/lifecycle/lease.ts +133 -29
  102. package/src/lifecycle/member-ledger.ts +3 -2
  103. package/src/lifecycle/plan-ledger.test.ts +148 -0
  104. package/src/lifecycle/plan-ledger.ts +158 -0
  105. package/src/lifecycle/work-lease.test.ts +236 -0
  106. package/src/lifecycle/work-lease.ts +426 -0
  107. package/src/op/builders.ts +5 -0
  108. package/src/op/discover.ts +71 -0
  109. package/src/op/index.ts +16 -3
  110. package/src/op/lifecycle-receipt-store.test.ts +60 -0
  111. package/src/op/lifecycle-receipt-store.ts +61 -0
  112. package/src/op/local-executor.ts +216 -18
  113. package/src/op/local-output.ts +13 -0
  114. package/src/op/op-ir.ts +14 -0
  115. package/src/op/operator.ts +75 -4
  116. package/src/op/runtime.ts +6 -0
  117. package/src/op/runtimes/local.ts +3 -0
  118. package/src/op/step-output-ref.ts +6 -2
  119. package/src/op/steward.test.ts +212 -0
  120. package/src/op/steward.ts +253 -0
  121. package/src/op/types.ts +53 -0
  122. package/src/op/work-lease-decl.ts +80 -0
  123. package/src/op/work-lease-run.test.ts +326 -0
  124. package/src/op/work-lease-run.ts +395 -0
  125. package/src/workspace/box-isolation.test.ts +261 -0
  126. package/src/workspace/box-isolation.ts +205 -0
  127. package/src/workspace/check-contract.test.ts +3 -1
  128. package/src/workspace/check.schema.json +15 -7
  129. package/src/workspace/checks/box-isolation.ts +68 -0
  130. package/src/workspace/checks/boxes.test.ts +197 -0
  131. package/src/workspace/checks/boxes.ts +307 -0
  132. package/src/workspace/checks/records.ts +25 -0
  133. package/src/workspace/checks.test.ts +7 -0
  134. package/src/workspace/checks.ts +19 -2
  135. package/src/workspace/decide.test.ts +224 -0
  136. package/src/workspace/decide.ts +576 -0
  137. package/src/workspace/decision-points.schema.json +137 -0
  138. package/src/workspace/declaration.schema.json +172 -0
  139. package/src/workspace/declaration.ts +137 -0
  140. package/src/workspace/declared-kinds.ts +25 -2
  141. package/src/workspace/intent.schema.json +4 -1
  142. package/src/workspace/point-answer.schema.json +95 -0
  143. package/src/workspace/points-cli.ts +273 -0
  144. package/src/workspace/points-write.schema.json +489 -0
  145. package/src/workspace/points.schema.json +710 -0
  146. package/src/workspace/points.test.ts +264 -0
  147. package/src/workspace/points.ts +564 -0
  148. package/src/workspace/read-contract.test.ts +18 -1
  149. package/src/workspace/reason-codes.test.ts +14 -1
  150. package/src/workspace/reason-codes.ts +33 -0
  151. package/src/workspace/record-assets.test.ts +3 -2
  152. package/src/workspace/record-assets.ts +4 -1
  153. package/src/workspace/records-cli.ts +15 -2
  154. package/src/workspace/records-contract.test.ts +3 -2
  155. package/src/workspace/records.schema.json +17 -0
  156. package/src/workspace/records.ts +33 -3
  157. package/src/workspace/status-contract.test.ts +178 -0
  158. package/src/workspace/status-stewards.ts +225 -0
  159. package/src/workspace/status.schema.json +230 -4
  160. package/src/workspace/status.ts +106 -5
  161. package/src/workspace/work-cli.test.ts +180 -0
  162. package/src/workspace/work-cli.ts +246 -0
  163. package/src/workspace/work-lease.schema.json +233 -0
  164. package/src/workspace/work-readiness-chud.test.ts +145 -0
@@ -5,6 +5,7 @@ import type { OpConfig } from "./types";
5
5
  import { walkDiscovery, workspaceMemberDirs } from "../discovery/walk";
6
6
  import { hasDiscoveryMarkerSync } from "../discovery/files";
7
7
  import { resolveDiscoveryGlobs } from "../config";
8
+ import { isStewardDeclaration, type StewardDeclaration } from "./steward";
8
9
 
9
10
  export interface DiscoveredOp {
10
11
  config: OpConfig;
@@ -152,6 +153,9 @@ export async function discoverOps(opts?: { cwd?: string }): Promise<OpDiscoveryR
152
153
  const mod = (await import(filePath)) as Record<string, unknown>;
153
154
  const exported = opsExportedBy(mod);
154
155
 
156
+ // A file may hold a steward and no Op (#2731): the steward sits beside
157
+ // the Ops it runs, and `discoverStewards` reads it from here.
158
+ if (exported.length === 0 && Object.values(mod).some(isStewardDeclaration)) continue;
155
159
  if (exported.length === 0) {
156
160
  errors.push(
157
161
  `${filePath}: exports no Op. Expected \`export default Op({...})\` or a named export such as \`export const deploy = Op({...})\``,
@@ -180,3 +184,70 @@ export async function discoverOps(opts?: { cwd?: string }): Promise<OpDiscoveryR
180
184
 
181
185
  return { ops, errors };
182
186
  }
187
+
188
+ export interface DiscoveredSteward {
189
+ declaration: StewardDeclaration;
190
+ filePath: string;
191
+ exportName: string;
192
+ }
193
+
194
+ export interface StewardDiscoveryResult {
195
+ stewards: Map<string, DiscoveredSteward>;
196
+ /** Files that could not be imported. */
197
+ errors: string[];
198
+ /** Stewards dropped because they would be a second writer: a name or an Op another steward already has. */
199
+ conflicts: string[];
200
+ }
201
+
202
+ /**
203
+ * Every steward declared in the project's `*.op.ts` files (#2731), found the
204
+ * way {@link discoverOps} finds Ops, keyed by the steward's name.
205
+ *
206
+ * Refuses, as errors, the two ways a project could end up with two writers:
207
+ * two stewards of one name, and one Op listed by two stewards. The steward
208
+ * declared first (in file walk order) keeps its entry; the other is dropped
209
+ * and reported in `conflicts`.
210
+ */
211
+ export async function discoverStewards(opts?: { cwd?: string }): Promise<StewardDiscoveryResult> {
212
+ const errors: string[] = [];
213
+ const conflicts: string[] = [];
214
+ const stewards = new Map<string, DiscoveredSteward>();
215
+ const opOwner = new Map<string, string>();
216
+
217
+ const root = await findDiscoveryRoot(opts?.cwd);
218
+ const files = await collectOpFiles(root);
219
+
220
+ for (const filePath of files) {
221
+ let mod: Record<string, unknown>;
222
+ try {
223
+ mod = (await import(filePath)) as Record<string, unknown>;
224
+ } catch (err) {
225
+ errors.push(`${filePath}: ${err instanceof Error ? err.message : String(err)}`);
226
+ continue;
227
+ }
228
+ const seen = new Set<unknown>();
229
+ const exportNames = ["default", ...Object.keys(mod).filter((k) => k !== "default")];
230
+ for (const exportName of exportNames) {
231
+ const value = mod[exportName];
232
+ if (!isStewardDeclaration(value) || seen.has(value)) continue;
233
+ seen.add(value);
234
+ const prior = stewards.get(value.name);
235
+ if (prior) {
236
+ conflicts.push(`Duplicate steward "${value.name}" in ${filePath} and ${prior.filePath}`);
237
+ continue;
238
+ }
239
+ const claimed = value.ops.find((op) => opOwner.has(op.name));
240
+ if (claimed) {
241
+ conflicts.push(
242
+ `${filePath}: steward "${value.name}" lists op "${claimed.name}", which steward "${opOwner.get(claimed.name)}" already runs. ` +
243
+ `Two stewards running one Op are two writers; list it on one of them.`,
244
+ );
245
+ continue;
246
+ }
247
+ for (const op of value.ops) opOwner.set(op.name, value.name);
248
+ stewards.set(value.name, { declaration: value, filePath, exportName });
249
+ }
250
+ }
251
+
252
+ return { stewards, errors, conflicts };
253
+ }
package/src/op/index.ts CHANGED
@@ -15,8 +15,12 @@ export { emulatorLifecycle, emulatorsOf, endpointEnvVars } from "./emulator-life
15
15
  export type { EmulatorSpec, EmulatorCapability, EmulatorDeclaration, EmulatorUpArgs, EmulatorLifecycle } from "./emulator-lifecycle";
16
16
  export { checkFreshness, compare, formatResult, latestRelease, parseVersion, unpinned } from "./emulator-freshness";
17
17
  export type { FreshnessResult } from "./emulator-freshness";
18
- export type { OpConfig, OpSchedule, PhaseDefinition, StepDefinition, ActivityStep, GateStep, EffectStep, OutcomeAttribute } from "./types";
19
- export { outcomeAttributesOf } from "./types";
18
+ export type { OpConfig, OpSchedule, OpWorkLease, PhaseDefinition, StepDefinition, ActivityStep, GateStep, EffectStep, OutcomeAttribute } from "./types";
19
+ export { outcomeAttributesOf, WORK_LEASE_STEP_ID } from "./types";
20
+ export {
21
+ RunWorkLease, workLeaseOutput, stewardWorkHolder, workLeaseProblems, workLeaseNeedsRunItem, LEASE_LOST, WORK_BRANCH_PREFIX,
22
+ } from "./work-lease-run";
23
+ export type { WorkLeaseOutput, WorkLeaseRunResult, WorkClaimOutcome, RunWorkLeaseOptions } from "./work-lease-run";
20
24
  export { isValidCronExpression, cronSyntaxMessage, cronMatches, cronDueBetween } from "./cron";
21
25
  export {
22
26
  WatchOp, ReconcileOp, ApplyOp, ConvergeOp,
@@ -100,8 +104,17 @@ export type {
100
104
  } from "./converge-rule";
101
105
  export {
102
106
  discoverConvergeOps, runOperatorRound, runOperatorForever, formatRoundLine,
103
- formatSignalLine, DEFAULT_OPERATOR_INTERVAL_MS,
107
+ formatSignalLine, DEFAULT_OPERATOR_INTERVAL_MS, acquireStewardLease,
104
108
  } from "./operator";
109
+ export {
110
+ declareSteward, isStewardDeclaration, stewardFormFor, stewardOpConfig, normaliseStewardForm, stewardLeaseName,
111
+ STEWARD_KIND, STEWARD_FORMS, STEWARD_NAME_PATTERN, DEFAULT_STEWARD_ENV,
112
+ } from "./steward";
113
+ export type {
114
+ StewardDeclaration, StewardDeclarationConfig, StewardForm, StewardFormSpec, StewardOpInput,
115
+ } from "./steward";
116
+ export { discoverStewards } from "./discover";
117
+ export type { DiscoveredSteward, StewardDiscoveryResult } from "./discover";
105
118
  export type {
106
119
  OperatorTickEvent, OperatorRoundOptions, OperatorLoopOptions,
107
120
  ChangeSubscriber, OperatorSignalEvent,
@@ -0,0 +1,60 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { withTestDir } from "@intentius/chant-test-utils";
3
+ import { spawnSync } from "node:child_process";
4
+ import { writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { lifecycleReceiptFile, lifecycleReceiptStore } from "./lifecycle-receipt-store";
7
+ import { receiptActivities, type EffectReceiptRef } from "./receipt-store";
8
+
9
+ function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
10
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
11
+ return { stdout: r.stdout ?? "", exitCode: r.status ?? -1 };
12
+ }
13
+
14
+ function initRepo(dir: string): void {
15
+ git(["init", "-q", "-b", "main"], dir);
16
+ git(["config", "user.email", "test@chant.dev"], dir);
17
+ git(["config", "user.name", "Test"], dir);
18
+ writeFileSync(join(dir, "README.md"), "fixture\n");
19
+ git(["add", "README.md"], dir);
20
+ git(["commit", "-q", "-m", "init"], dir);
21
+ }
22
+
23
+ const RECEIPT: EffectReceiptRef = { name: "fly-migration:shop/001_init.sql", effect: "fly-migrate", flavor: "hash", inputs: {} };
24
+
25
+ describe("lifecycleReceiptStore", () => {
26
+ test("a receipt is absent, then written under <env>/receipts on chant/lifecycle", async () => {
27
+ await withTestDir(async (dir) => {
28
+ initRepo(dir);
29
+ const store = lifecycleReceiptStore({ environment: "prod", cwd: dir });
30
+ expect(await store.read(RECEIPT)).toBeUndefined();
31
+ await store.write(RECEIPT, "sha256:abc");
32
+ expect(await store.read(RECEIPT)).toBe("sha256:abc");
33
+ const shown = git(["show", `chant/lifecycle:prod/${lifecycleReceiptFile(RECEIPT.name)}`], dir);
34
+ expect(shown.exitCode).toBe(0);
35
+ expect(shown.stdout.trim()).toBe("sha256:abc");
36
+ // Another environment has its own receipts.
37
+ expect(await lifecycleReceiptStore({ environment: "dev", cwd: dir }).read(RECEIPT)).toBeUndefined();
38
+ });
39
+ });
40
+
41
+ test("bound to core's receipt activities, the effect step's read-compare-write goes through it", async () => {
42
+ await withTestDir(async (dir) => {
43
+ initRepo(dir);
44
+ const acts = receiptActivities(lifecycleReceiptStore({ environment: "prod", cwd: dir }));
45
+ const first = await acts.receiptRead({ receipt: RECEIPT, expectation: "sha256:1" });
46
+ expect(first).toEqual({ current: null, expectation: "sha256:1", applied: false });
47
+ await acts.receiptWrite({ receipt: RECEIPT, expectation: "sha256:1" });
48
+ expect((await acts.receiptRead({ receipt: RECEIPT, expectation: "sha256:1" })).applied).toBe(true);
49
+ });
50
+ });
51
+
52
+ test("with no environment and no CHANT_ENV it refuses rather than guessing", async () => {
53
+ const store = lifecycleReceiptStore({ env: {} });
54
+ await expect(store.read(RECEIPT)).rejects.toThrow(/no environment/);
55
+ });
56
+
57
+ test("receipt names become safe file names", () => {
58
+ expect(lifecycleReceiptFile("fly-migration:app/002 add.sql")).toBe("receipts/fly-migration_app_002_add.sql.receipt");
59
+ });
60
+ });
@@ -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
  }