@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
@@ -33,7 +33,7 @@
33
33
  */
34
34
  import { hostname } from "node:os";
35
35
  import { randomUUID } from "node:crypto";
36
- import { readRefSha, updateRefCAS, deleteRefCAS, writeBlob, readBlobBySha, pushRef, fetchRefInto, RefCASConflictError } from "./git";
36
+ import { readRefSha, updateRefCAS, deleteRefCAS, writeBlob, readBlobBySha, pushRefStatus, fetchRefIntoStatus, RefCASConflictError, StaleLockError } from "./git";
37
37
  import { resolveMemberLedger } from "./member-ledger";
38
38
 
39
39
  export const LEASE_REF_PREFIX = "refs/chant/lease/";
@@ -95,7 +95,7 @@ function leaseFreshnessKey(record?: LeaseRecord): string {
95
95
  return record ? `${record.acquiredAt} ${record.expiresAt}` : "";
96
96
  }
97
97
 
98
- /** One lease's live state — the entire durable record; there is no history, only the current holder (see this module's doc on why no separate ledger). */
98
+ /** One lease's live state: the current holder only. An operator lease keeps no history; a work lease (./work-lease.ts) appends each change to `_leases/<id>.jsonl` beside it. */
99
99
  export interface LeaseRecord {
100
100
  op: string;
101
101
  /** `<hostname>:<pid>:<random>` — a diagnostic identity, not itself the fencing mechanism (`token` is). */
@@ -137,6 +137,23 @@ export interface ReadLeaseResult {
137
137
  /** The ref's current SHA (the CAS anchor for the next write), or `null` if no lease has ever been written. */
138
138
  sha: string | null;
139
139
  record?: LeaseRecord;
140
+ /**
141
+ * The value of the remote-tracking ref (`refs/chant/lease-remote/<op>`)
142
+ * after the fetch: what the remote held when last seen, or `null` when it
143
+ * held nothing. The `--force-with-lease` expectation of the next push
144
+ * (#2732).
145
+ */
146
+ remoteSha: string | null;
147
+ }
148
+
149
+ /** Options every lease read and write takes. `cwd` also picks the ledger, and so the member prefix of the refs (./member-ledger.ts). */
150
+ export interface LeaseOptions {
151
+ cwd?: string;
152
+ }
153
+
154
+ async function readRecordAt(ref: string, opts?: { cwd?: string }): Promise<{ sha: string | null; record?: LeaseRecord }> {
155
+ const sha = await readRefSha(ref, opts);
156
+ return { sha, record: sha ? parseLease((await readBlobBySha(sha, opts)) ?? "") : undefined };
140
157
  }
141
158
 
142
159
  /**
@@ -159,26 +176,76 @@ export interface ReadLeaseResult {
159
176
  * against — is always the local ref's own actual value; only the local
160
177
  * canonical ref is ever a valid basis for a `updateRefCAS`/`deleteRefCAS`
161
178
  * call against it, regardless of what the comparison decided about `record`.
179
+ *
180
+ * When the remote answers without the ref, the lease was released there, and
181
+ * the tracking ref is dropped so a stale copy of it no longer counts (#2732).
182
+ * A read-only report that must stay off the network reads the refs itself
183
+ * (`./work-lease.ts`'s `listWorkLeases`).
162
184
  */
163
- export async function readLease(opName: string, opts?: { cwd?: string }): Promise<ReadLeaseResult> {
185
+ export async function readLease(opName: string, opts?: LeaseOptions): Promise<ReadLeaseResult> {
164
186
  const { ref, trackingRef } = await projectLeaseRefs(opName, opts);
165
- await fetchRefInto(ref, trackingRef, opts).catch(() => undefined);
166
-
167
- const sha = await readRefSha(ref, opts);
168
- const localRecord = sha ? parseLease((await readBlobBySha(sha, opts)) ?? "") : undefined;
187
+ const fetched = await fetchRefIntoStatus(ref, trackingRef, opts).catch(() => "failed" as const);
188
+ if (fetched === "missing") {
189
+ const stale = await readRefSha(trackingRef, opts);
190
+ if (stale) await deleteRefCAS(trackingRef, stale, opts).catch(() => undefined);
191
+ }
169
192
 
170
- const remoteSha = await readRefSha(trackingRef, opts);
171
- const remoteRecord = remoteSha ? parseLease((await readBlobBySha(remoteSha, opts)) ?? "") : undefined;
193
+ const { sha, record: localRecord } = await readRecordAt(ref, opts);
194
+ const { sha: remoteSha, record: remoteRecord } = await readRecordAt(trackingRef, opts);
172
195
 
173
196
  const record = leaseFreshnessKey(remoteRecord) > leaseFreshnessKey(localRecord) ? remoteRecord : localRecord;
174
- return { sha, record };
197
+ return { sha, record, remoteSha };
175
198
  }
176
199
 
200
+ /** Why {@link acquireLease} did not acquire (#2732). */
201
+ export type LeaseRefusal =
202
+ /** Someone holds it live: another holder, or, under `mode: "claim"`, anyone. */
203
+ | "held"
204
+ /** `mode: "renew"` and nobody holds it live any more: it expired or was released. */
205
+ | "not-held"
206
+ /** `mode: "renew"` and the live lease has another token than the one given. */
207
+ | "token-mismatch"
208
+ /** Another writer changed the ref between this call's read and its write. */
209
+ | "race"
210
+ /** `requirePush` and the remote refused the push: another clone got there first, or the remote could not be reached. */
211
+ | "push-rejected";
212
+
177
213
  export interface AcquireLeaseResult {
178
214
  acquired: boolean;
179
215
  lease?: LeaseRecord;
180
216
  /** Present when not acquired: the lease record currently held by someone else. */
181
217
  heldBy?: LeaseRecord;
218
+ /** Present when not acquired: why (#2732). */
219
+ reason?: LeaseRefusal;
220
+ }
221
+
222
+ /** Options for {@link acquireLease}. */
223
+ export interface AcquireLeaseOptions extends LeaseOptions {
224
+ ttlMs?: number;
225
+ now?: () => Date;
226
+ /**
227
+ * `acquire` (the default, the operator's): take it when free or expired,
228
+ * renew it when `holder` already holds it. `claim`: take it only when free
229
+ * or expired, and refuse even its own holder. `renew`: only move the expiry
230
+ * of a live lease `holder` holds (#2732).
231
+ */
232
+ mode?: "acquire" | "claim" | "renew";
233
+ /** With `mode: "renew"`, the token the caller holds; a live lease with another token is refused. */
234
+ token?: string;
235
+ /**
236
+ * Count the write only once the remote has taken it, when there is a
237
+ * remote. A rejected push undoes the local write and refuses with
238
+ * `push-rejected`, so two clones racing for one lease cannot both hold it.
239
+ * The operator leaves it off: its push is best-effort (#2732).
240
+ */
241
+ requirePush?: boolean;
242
+ /**
243
+ * Wait out a `.lock` another process holds on the ref for up to this long,
244
+ * retrying, before surfacing it as a {@link StaleLockError}. A lock left by
245
+ * a killed process outlives the wait; one held by a racing writer does
246
+ * not. Off (0) by default, as the operator has always had it.
247
+ */
248
+ lockWaitMs?: number;
182
249
  }
183
250
 
184
251
  /**
@@ -198,22 +265,42 @@ export interface AcquireLeaseResult {
198
265
  * against a lease nobody can ever actually acquire again without manual
199
266
  * intervention. It propagates instead, so the caller (`../op/operator.ts`'s
200
267
  * `runOperatorRound`) can surface it as its own distinct, diagnosable event
201
- * rather than a silent, permanent skip.
268
+ * rather than a silent, permanent skip. `lockWaitMs` waits a racing writer's
269
+ * lock out first.
270
+ *
271
+ * `mode`, `token` and `requirePush` are the work lease's (#2732,
272
+ * ./work-lease.ts); the operator uses none of them.
202
273
  */
203
274
  export async function acquireLease(
204
275
  opName: string,
205
276
  holder: string,
206
- opts?: { cwd?: string; ttlMs?: number; now?: () => Date },
277
+ opts?: AcquireLeaseOptions,
207
278
  ): Promise<AcquireLeaseResult> {
279
+ const deadline = Date.now() + (opts?.lockWaitMs ?? 0);
280
+ for (;;) {
281
+ try {
282
+ return await acquireOnce(opName, holder, opts);
283
+ } catch (err) {
284
+ if (!(err instanceof StaleLockError) || Date.now() >= deadline) throw err;
285
+ await new Promise((r) => setTimeout(r, 20 + Math.floor(Math.random() * 60)));
286
+ }
287
+ }
288
+ }
289
+
290
+ async function acquireOnce(opName: string, holder: string, opts?: AcquireLeaseOptions): Promise<AcquireLeaseResult> {
208
291
  const ttlMs = opts?.ttlMs ?? DEFAULT_LEASE_TTL_MS;
209
292
  const now = opts?.now?.() ?? new Date();
293
+ const mode = opts?.mode ?? "acquire";
210
294
 
211
- const { sha, record: current } = await readLease(opName, opts);
295
+ const { sha, record: current, remoteSha } = await readLease(opName, opts);
212
296
  const expired = !current || isExpired(current, now);
213
297
  const ownedByUs = current?.holder === holder;
214
298
 
215
- if (current && !expired && !ownedByUs) {
216
- return { acquired: false, heldBy: current };
299
+ if (mode === "renew") {
300
+ if (!current || expired || !ownedByUs) return { acquired: false, reason: current && !expired ? "held" : "not-held", ...(current ? { heldBy: current } : {}) };
301
+ if (opts?.token !== undefined && current.token !== opts.token) return { acquired: false, reason: "token-mismatch", heldBy: current };
302
+ } else if (current && !expired && (!ownedByUs || mode === "claim")) {
303
+ return { acquired: false, heldBy: current, reason: "held" };
217
304
  }
218
305
 
219
306
  const renewing = !!current && ownedByUs && !expired;
@@ -225,21 +312,31 @@ export async function acquireLease(
225
312
  expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
226
313
  };
227
314
 
228
- const { ref } = await projectLeaseRefs(opName, opts);
315
+ const { ref, trackingRef } = await projectLeaseRefs(opName, opts);
229
316
  const blobSha = await writeBlob(JSON.stringify(record), opts);
230
317
  try {
231
318
  await updateRefCAS(ref, blobSha, sha, opts);
232
319
  } catch (err) {
233
320
  if (err instanceof RefCASConflictError) {
234
321
  const retry = await readLease(opName, opts);
235
- return { acquired: false, heldBy: retry.record };
322
+ return { acquired: false, heldBy: retry.record, reason: "race" };
236
323
  }
237
324
  // A StaleLockError (or any other non-CAS failure) is NOT "someone else
238
325
  // has it" — propagate it as its own distinct error rather than folding
239
326
  // it into `heldBy`, per this function's doc.
240
327
  throw err;
241
328
  }
242
- await pushRef(ref, opts).catch(() => undefined);
329
+ const pushed = await pushRefStatus(ref, { ...opts, expect: remoteSha }).catch(() => "rejected" as const);
330
+ if (pushed === "pushed") {
331
+ // What the remote holds now; the next push expects it.
332
+ await updateRefCAS(trackingRef, blobSha, remoteSha, opts).catch(() => undefined);
333
+ } else if (pushed === "rejected" && opts?.requirePush) {
334
+ // Undo the local write, which the remote refused, and report who has it there.
335
+ await (sha === null ? deleteRefCAS(ref, blobSha, opts) : updateRefCAS(ref, sha, blobSha, opts)).catch(() => undefined);
336
+ const retry = await readLease(opName, opts);
337
+ const heldBy = retry.record && retry.record.token !== record.token ? retry.record : undefined;
338
+ return { acquired: false, reason: "push-rejected", ...(heldBy ? { heldBy } : {}) };
339
+ }
243
340
  return { acquired: true, lease: record };
244
341
  }
245
342
 
@@ -249,23 +346,30 @@ export async function acquireLease(
249
346
  * silently drop someone else's. Best-effort courtesy: a lease nobody
250
347
  * releases is reclaimed anyway once its TTL passes, so a failed release
251
348
  * (returns `false`, never throws) is not itself a correctness problem.
349
+ *
350
+ * The deletion is pushed to the remote, expecting the value last fetched
351
+ * there, so another clone sees the lease free without waiting out its TTL
352
+ * (#2732).
252
353
  */
253
354
  export async function releaseLease(
254
355
  opName: string,
255
356
  holder: string,
256
357
  token: string,
257
- opts?: { cwd?: string },
358
+ opts?: LeaseOptions,
258
359
  ): Promise<boolean> {
259
- const { sha, record } = await readLease(opName, opts);
260
- if (!sha || !record || record.holder !== holder || record.token !== token) return false;
261
- const { ref } = await projectLeaseRefs(opName, opts);
262
- try {
263
- await deleteRefCAS(ref, sha, opts);
264
- } catch {
265
- return false;
360
+ const { sha, record, remoteSha } = await readLease(opName, opts);
361
+ if (!record || record.holder !== holder || record.token !== token) return false;
362
+ const { ref, trackingRef } = await projectLeaseRefs(opName, opts);
363
+ if (sha) {
364
+ try {
365
+ await deleteRefCAS(ref, sha, opts);
366
+ } catch {
367
+ return false;
368
+ }
266
369
  }
267
- await pushRef(ref, opts).catch(() => undefined);
268
- return true;
370
+ const pushed = await pushRefStatus(ref, { ...opts, expect: remoteSha }).catch(() => "rejected" as const);
371
+ if (pushed === "pushed" && remoteSha) await deleteRefCAS(trackingRef, remoteSha, opts).catch(() => undefined);
372
+ return sha !== null || pushed === "pushed";
269
373
  }
270
374
 
271
375
  /**
@@ -278,7 +382,7 @@ export async function stillHoldsLease(
278
382
  opName: string,
279
383
  holder: string,
280
384
  token: string,
281
- opts?: { cwd?: string },
385
+ opts?: LeaseOptions,
282
386
  ): Promise<boolean> {
283
387
  const { record } = await readLease(opName, opts);
284
388
  return record?.holder === holder && record?.token === token;
@@ -5,8 +5,9 @@
5
5
  * A workspace member writes every lifecycle store under
6
6
  * `_members/<member>/` on the one existing branch: releases, snapshots, runs,
7
7
  * converge records and observation baselines under `<env>/`, gates under
8
- * `_gates/`, build records under `_builds/`. Its operator leases move the same
9
- * way, to `refs/chant/lease/_members/<member>/<op>`. Push, fetch, lease and
8
+ * `_gates/`, build records under `_builds/`, release plans under `_plans/`
9
+ * (ws-055, #2733). Its operator leases move the same way, to
10
+ * `refs/chant/lease/_members/<member>/<op>`. Push, fetch, lease and
10
11
  * staleness code are untouched: they still see one branch and plain refs.
11
12
  *
12
13
  * Everything else keeps today's flat layout, byte for byte:
@@ -0,0 +1,148 @@
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, mkdirSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { persistReleasePlan, readReleasePlan, InvalidReleasePlanError, type ReleasePlan } from "./plan-ledger";
7
+ import { readRefSha } from "./git";
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
+ async function initRepo(dir: string): Promise<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
+ function makePlan(digest: string, extra: Record<string, unknown> = {}): ReleasePlan {
24
+ return { digest, release: "r-abc12345", units: [{ id: "w-1", contract: "c-1" }], evidence: [], ...extra };
25
+ }
26
+
27
+ describe("lifecycle/plan-ledger", () => {
28
+ describe("persistReleasePlan / readReleasePlan", () => {
29
+ test("round-trips a plan by its own digest", async () => {
30
+ await withTestDir(async (dir) => {
31
+ await initRepo(dir);
32
+ const plan = makePlan("sha256:" + "1".repeat(64));
33
+
34
+ const { commit, written } = await persistReleasePlan(plan, { cwd: dir });
35
+ expect(written).toBe(true);
36
+ expect(commit).toMatch(/^[0-9a-f]{40}$/);
37
+
38
+ const readBack = await readReleasePlan(plan.digest, { cwd: dir });
39
+ expect(readBack).toEqual(plan);
40
+ });
41
+ });
42
+
43
+ test("readReleasePlan returns null for an unknown digest", async () => {
44
+ await withTestDir(async (dir) => {
45
+ await initRepo(dir);
46
+ expect(await readReleasePlan("sha256:doesnotexist", { cwd: dir })).toBeNull();
47
+ });
48
+ });
49
+
50
+ test("a plan already written is never rewritten: no second commit, content unchanged", async () => {
51
+ await withTestDir(async (dir) => {
52
+ await initRepo(dir);
53
+ const plan = makePlan("sha256:" + "2".repeat(64));
54
+
55
+ const first = await persistReleasePlan(plan, { cwd: dir });
56
+ expect(first.written).toBe(true);
57
+ const tipAfterFirst = await readRefSha("refs/heads/chant/lifecycle", { cwd: dir });
58
+
59
+ const second = await persistReleasePlan(plan, { cwd: dir });
60
+ expect(second.written).toBe(false);
61
+ expect(second.commit).toBeNull();
62
+ const tipAfterSecond = await readRefSha("refs/heads/chant/lifecycle", { cwd: dir });
63
+
64
+ // Not a new commit — the branch tip is unchanged.
65
+ expect(tipAfterSecond).toBe(tipAfterFirst);
66
+ expect(await readReleasePlan(plan.digest, { cwd: dir })).toEqual(plan);
67
+ });
68
+ });
69
+
70
+ test("a plan with a different digest is a separate entry, coexisting with the first", async () => {
71
+ await withTestDir(async (dir) => {
72
+ await initRepo(dir);
73
+ const planA = makePlan("sha256:" + "a".repeat(64), { release: "r-a" });
74
+ const planB = makePlan("sha256:" + "b".repeat(64), { release: "r-b" });
75
+
76
+ await persistReleasePlan(planA, { cwd: dir });
77
+ await persistReleasePlan(planB, { cwd: dir });
78
+
79
+ expect(await readReleasePlan(planA.digest, { cwd: dir })).toEqual(planA);
80
+ expect(await readReleasePlan(planB.digest, { cwd: dir })).toEqual(planB);
81
+ });
82
+ });
83
+
84
+ test("refuses a plan with no digest field", async () => {
85
+ await withTestDir(async (dir) => {
86
+ await initRepo(dir);
87
+ const plan = { release: "r-nodigest" } as unknown as ReleasePlan;
88
+ await expect(persistReleasePlan(plan, { cwd: dir })).rejects.toThrow(InvalidReleasePlanError);
89
+ });
90
+ });
91
+
92
+ test("release ledger and plan store coexist on the same orphan branch", async () => {
93
+ const { appendReleaseRecord, readReleaseLedger } = await import("./release-ledger");
94
+ await withTestDir(async (dir) => {
95
+ await initRepo(dir);
96
+ const plan = makePlan("sha256:" + "3".repeat(64));
97
+ await persistReleasePlan(plan, { cwd: dir });
98
+ await appendReleaseRecord(
99
+ {
100
+ component: "svc",
101
+ env: "prod",
102
+ digest: plan.digest,
103
+ gitSha: "deadbeef",
104
+ runId: "run-1",
105
+ timestamp: "2026-01-01T00:00:00.000Z",
106
+ actor: "ci-bot",
107
+ },
108
+ { cwd: dir },
109
+ );
110
+
111
+ expect(await readReleasePlan(plan.digest, { cwd: dir })).toEqual(plan);
112
+ const { records } = await readReleaseLedger("prod", { cwd: dir });
113
+ expect(records).toHaveLength(1);
114
+ expect(records[0].digest).toBe(plan.digest);
115
+ });
116
+ });
117
+
118
+ test("a member's plan lives under _members/<member>/_plans/ (#2524 D7), reached via the prefix readReleasePlan takes", async () => {
119
+ await withTestDir(async (dir) => {
120
+ await initRepo(dir);
121
+ mkdirSync(join(dir, "apps", "web"), { recursive: true });
122
+ writeFileSync(join(dir, "chant.workspace.json"), JSON.stringify({
123
+ name: "acme",
124
+ schema: 1,
125
+ members: [{ name: "web", dir: "apps/web", kind: "chant" }],
126
+ }));
127
+ writeFileSync(join(dir, "apps", "web", "chant.config.ts"), "");
128
+ git(["add", "-A"], dir);
129
+ git(["commit", "-q", "-m", "workspace"], dir);
130
+
131
+ const plan = makePlan("sha256:" + "4".repeat(64));
132
+ // Persisted from inside the member's own directory, the way `chant
133
+ // components release` runs there — writeBlobToPath resolves the
134
+ // _members/web/ prefix automatically from cwd.
135
+ const webDir = join(dir, "apps", "web");
136
+ await persistReleasePlan(plan, { cwd: webDir });
137
+
138
+ // readReleasePlan reads it back two ways: with the matching prefix
139
+ // (as ../workspace/status.ts resolves it for a "members"-layout
140
+ // release), and directly at the branch path it actually landed on.
141
+ expect(await readReleasePlan(plan.digest, { cwd: dir, prefix: "_members/web/" })).toEqual(plan);
142
+ const raw = git(["show", `chant/lifecycle:_members/web/_plans/sha256_${"4".repeat(64)}.json`], dir);
143
+ expect(raw.exitCode).toBe(0);
144
+ expect(JSON.parse(raw.stdout)).toEqual(plan);
145
+ });
146
+ });
147
+ });
148
+ });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Release plan store (ws-055, #2733, part of the chud retirement epic
3
+ * #2715): persists a release plan — the work items and evidence a release
4
+ * ships — to the `chant/lifecycle` orphan branch, content-addressed by the
5
+ * plan's own digest.
6
+ *
7
+ * **Why this exists.** ws-055 (docs/design/decisions/ws-055-dev-model-
8
+ * ledgers.md) decided where a development model's plans live: "a release
9
+ * plan is written content-addressed to `_plans/<digest>.json` on
10
+ * chant/lifecycle, and the release ledger record already names that
11
+ * digest." A `ReleaseRecord.digest` (../lifecycle/release-ledger.ts) is
12
+ * ordinarily an artifact digest; for a release a runner (the studio kit
13
+ * today, chud before it) plans work-item by work-item, that same field
14
+ * *is* the plan's own digest — the release ledger needs no new field to
15
+ * point at it, it already names the plan. Reading a release through the
16
+ * read contract (../workspace/status.ts) resolves that digest to the plan
17
+ * exactly the way it already resolves a build archive digest to a
18
+ * `BuildArchiveManifest` (./build-ledger-store.ts) — this module is that
19
+ * store's plan-shaped sibling, same plumbing, same directory-per-kind
20
+ * convention on the one orphan branch.
21
+ *
22
+ * **Storage.** Reuses `writeBlobToPath`/`readBlobFromPath` (./git.ts) —
23
+ * the identical hash-object -> mktree -> commit-tree -> update-ref
24
+ * pipeline `writeSnapshot`/`persistBuildManifest` already use. Plans live
25
+ * under a fixed top-level `_plans/` directory, a peer of `_builds/` and the
26
+ * per-env directories, never nested inside one — a plan is not env-scoped,
27
+ * it is named by its own content. Per #2524 D7 / #2538, `writeBlobToPath`'s
28
+ * `ledgerDir` already folds a workspace member's own `_members/<member>/`
29
+ * prefix in ahead of `_plans/`, so a member's plans live at
30
+ * `_members/<member>/_plans/<digest>.json` the same way its build records
31
+ * live at `_members/<member>/_builds/` — this module does nothing itself
32
+ * to earn that; it falls out of reusing the shared plumbing.
33
+ *
34
+ * **Content-addressed, keyed by the plan's own `digest`.** One file per
35
+ * plan, `_plans/<digest-with-":"->"_">.json` (`:` is not usable in a git
36
+ * tree entry name the way `readBlobFromPath`'s `<ref>:<path>` spelling
37
+ * reads it back, the same reason `./build-ledger-store.ts` substitutes it
38
+ * for `_builds/`). The digest is computed by whoever plans the release —
39
+ * the release Op, or a runner like the studio kit — not by chant; this
40
+ * module only trusts and stores it, exactly as `persistBuildManifest`
41
+ * trusts a manifest's own `manifestDigest`.
42
+ *
43
+ * **A plan already written is never rewritten (#2733 acceptance).**
44
+ * Unlike `persistBuildManifest`, which writes unconditionally every call
45
+ * (safe only because identical content produces an identical blob and,
46
+ * usually, an identical tree — but still a new, redundant commit on the
47
+ * branch each time), `persistReleasePlan` reads the path first and returns
48
+ * without writing when a plan is already stored under that digest. A plan
49
+ * is immutable by construction (it is named by a hash of its own content),
50
+ * so there is never a reason to overwrite one — only ever a reason to skip
51
+ * the write.
52
+ */
53
+
54
+ import { sortedJsonReplacer } from "../utils";
55
+ import { writeBlobToPath, readBlobFromPath, RefCASConflictError } from "./git";
56
+
57
+ /** Fixed top-level directory on the `chant/lifecycle` orphan branch that holds every persisted release plan — a peer of `_builds/` and the per-env directories, never nested inside one (see module doc for why). */
58
+ const PLANS_DIR = "_plans";
59
+
60
+ /**
61
+ * A release plan (ws-055): the work items and evidence a release ships.
62
+ * chant does not own this shape — it is a runner's own record, the way a
63
+ * record kind's schema is the kind's own (../workspace/records.ts) — only
64
+ * `digest` is required, since it is the storage key. Every other field is
65
+ * read back and returned exactly as written.
66
+ */
67
+ export interface ReleasePlan {
68
+ /**
69
+ * Content-addressed digest of this plan (`sha256:...`) — the same digest
70
+ * the release ledger record names (`ReleaseRecord.digest`,
71
+ * ../lifecycle/release-ledger.ts), so a record leads to its plan.
72
+ * Computed by the caller before persisting; this module trusts it rather
73
+ * than recomputing it, the same way `persistBuildManifest` trusts a
74
+ * manifest's own `manifestDigest`.
75
+ */
76
+ digest: string;
77
+ [key: string]: unknown;
78
+ }
79
+
80
+ /** Thrown by `persistReleasePlan` when the plan carries no usable `digest` — a plan can't be content-addressed without one. */
81
+ export class InvalidReleasePlanError extends Error {
82
+ constructor(message: string) {
83
+ super(message);
84
+ this.name = "InvalidReleasePlanError";
85
+ }
86
+ }
87
+
88
+ /** Turn a `sha256:...`-style digest into a filesystem/git-tree-safe filename stem. */
89
+ function digestToFilenameStem(digest: string): string {
90
+ return digest.replace(/:/g, "_");
91
+ }
92
+
93
+ function planFilename(digest: string): string {
94
+ return `${digestToFilenameStem(digest)}.json`;
95
+ }
96
+
97
+ /**
98
+ * Persist a release plan to the orphan branch, keyed by its own `digest`.
99
+ * Does not push to the remote — call `pushLifecycle` (./git.ts) afterward,
100
+ * the same two-step (`write` then `push`) shape `appendReleaseRecord`/
101
+ * `persistBuildManifest` use, so a caller persisting a plan alongside the
102
+ * release record it names can batch both into one push.
103
+ *
104
+ * Never rewrites a plan already stored under `plan.digest` (#2733
105
+ * acceptance) — reads the path first, and returns `{ commit: null, written:
106
+ * false }` without writing when it is already there. A `RefCASConflictError`
107
+ * from a genuine race (another writer persisted the same digest's plan
108
+ * between this call's read and its write) is treated the same way: the
109
+ * plan is content-addressed, so whatever is already at that path under this
110
+ * digest is authoritative, never overwritten.
111
+ */
112
+ export async function persistReleasePlan(
113
+ plan: ReleasePlan,
114
+ opts?: { cwd?: string },
115
+ ): Promise<{ commit: string | null; written: boolean }> {
116
+ if (typeof plan.digest !== "string" || plan.digest.length === 0) {
117
+ throw new InvalidReleasePlanError("release plan is missing its own \"digest\" field — a release plan is content-addressed by its own digest");
118
+ }
119
+ const filename = planFilename(plan.digest);
120
+ const existing = await readBlobFromPath(PLANS_DIR, filename, opts);
121
+ if (existing !== null) return { commit: null, written: false };
122
+
123
+ const json = JSON.stringify(plan, sortedJsonReplacer);
124
+ try {
125
+ const commit = await writeBlobToPath(PLANS_DIR, filename, json, "Release plan", { ...opts, expectPriorPathSha: null });
126
+ return { commit, written: true };
127
+ } catch (err) {
128
+ if (err instanceof RefCASConflictError) return { commit: null, written: false };
129
+ throw err;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Read a persisted release plan back by its own digest — the same digest a
135
+ * `ReleaseRecord.digest` names. Returns `null` when no plan was ever
136
+ * persisted under that digest (never throws — most releases carry no plan
137
+ * at all, and reading one for status is a normal, expected miss), and when
138
+ * the stored blob fails to parse as JSON.
139
+ *
140
+ * `prefix` lets a multi-member reader (../workspace/status.ts) resolve a
141
+ * specific member's plan directory directly, the same way
142
+ * `readReleaseLedgerLines` is handed an already-member-prefixed path there
143
+ * — `cwd` alone can't do it, since one status read walks every member from
144
+ * one process without changing directory per member.
145
+ */
146
+ export async function readReleasePlan(
147
+ digest: string,
148
+ opts?: { cwd?: string; prefix?: string },
149
+ ): Promise<ReleasePlan | null> {
150
+ const dir = `${opts?.prefix ?? ""}${PLANS_DIR}`;
151
+ const content = await readBlobFromPath(dir, planFilename(digest), opts);
152
+ if (!content) return null;
153
+ try {
154
+ return JSON.parse(content) as ReleasePlan;
155
+ } catch {
156
+ return null;
157
+ }
158
+ }