forgedock 1.34.0 → 1.36.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 (61) hide show
  1. package/README.md +78 -324
  2. package/bin/engine/events.mjs +152 -0
  3. package/bin/engine/events.test.mjs +128 -0
  4. package/bin/engine/telemetry-seam.mjs +4 -3
  5. package/bin/engine-cli.mjs +181 -26
  6. package/bin/experiment.mjs +55 -0
  7. package/bin/forgedock.mjs +19 -1
  8. package/bin/hooks/reread-nudge.mjs +44 -5
  9. package/bin/lean/batch.mjs +66 -3
  10. package/bin/lean/batch.test.mjs +176 -1
  11. package/bin/lean/budget.mjs +14 -3
  12. package/bin/lean/budget.test.mjs +11 -1
  13. package/bin/lean/conflictres.mjs +93 -0
  14. package/bin/lean/conflictres.test.mjs +93 -0
  15. package/bin/lean/conformance.mjs +30 -9
  16. package/bin/lean/conformance.test.mjs +51 -0
  17. package/bin/lean/effects.mjs +14 -3
  18. package/bin/lean/execution.mjs +2 -0
  19. package/bin/lean/i18n.mjs +65 -0
  20. package/bin/lean/i18n.test.mjs +47 -0
  21. package/bin/lean/lane.mjs +16 -0
  22. package/bin/lean/lean.mjs +147 -36
  23. package/bin/lean/lean.test.mjs +319 -8
  24. package/bin/lean/orchestrate.mjs +130 -13
  25. package/bin/lean/postdeploy-check.mjs +149 -0
  26. package/bin/lean/postdeploy-check.test.mjs +146 -0
  27. package/bin/lean/postdeploy-monitor.mjs +87 -0
  28. package/bin/lean/postdeploy-monitor.test.mjs +121 -0
  29. package/bin/lean/precheck.mjs +58 -8
  30. package/bin/lean/precheck.test.mjs +77 -3
  31. package/bin/lean/prefilter.mjs +29 -4
  32. package/bin/lean/prefilter.test.mjs +13 -5
  33. package/bin/lean/probe.mjs +2 -1
  34. package/bin/lean/prompts.mjs +36 -6
  35. package/bin/lean/sandbox-provider.contract.test.mjs +28 -0
  36. package/bin/lean/sandbox-remote.mjs +7 -0
  37. package/bin/lean/schedd.mjs +34 -5
  38. package/bin/lean/schedd.test.mjs +84 -0
  39. package/bin/lean/sequence.mjs +26 -0
  40. package/bin/lean/sequence.test.mjs +41 -2
  41. package/bin/lean/toolstate.mjs +77 -0
  42. package/bin/lean/toolstate.test.mjs +83 -0
  43. package/bin/lean/verify.mjs +18 -2
  44. package/bin/lean/verify.test.mjs +29 -3
  45. package/bin/stats/aggregate.mjs +1 -1
  46. package/bin/stats/experiment.mjs +44 -0
  47. package/bin/stats/github-source.mjs +2 -1
  48. package/bin/stats/index.mjs +42 -2
  49. package/bin/stats/release.mjs +75 -0
  50. package/bin/stats/release.test.mjs +68 -0
  51. package/commands/orchestrate.md +8 -3
  52. package/docs/CONFIG.md +61 -2
  53. package/docs/EVENTS.md +68 -0
  54. package/docs/STATS.md +31 -0
  55. package/docs/TELEMETRY.md +3 -1
  56. package/docs/site/install.md +89 -0
  57. package/package.json +1 -1
  58. package/scripts/bench-lightlane.mjs +2 -1
  59. package/scripts/eval-gate-scorecard.mjs +7 -0
  60. package/scripts/file-deferred-followups.sh +1 -0
  61. package/scripts/file-deferred-followups.test.sh +8 -0
@@ -63,6 +63,18 @@ export function duplicateExportsPrompt(findings, mode = "local") {
63
63
  return `This change exports names that another module of the same app or package already exports:\n${rows}\n\nRename the new export or reuse the existing one, and update every reference and any registry, map or test keyed by the name (same-named entries collide or leave one unprobed). ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
64
64
  }
65
65
 
66
+ /** #4596: new keys the lane copied from the source-locale catalog into another locale's catalog. */
67
+ export function i18nCopiesPrompt(findings, mode = "local") {
68
+ const rows = findings.slice(0, 30).map((d) => `- ${d.path}: ${d.key} = ${JSON.stringify(d.value)} (same as ${d.source})`).join("\n");
69
+ return `This change adds keys to locale catalogs as English copies of the source locale:\n${rows}\n\nTranslate each into that catalog's language (keep placeholders), and add any translation-status entry the repo tracks as a minimal diff: insert in place, never re-sort or reformat the file. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
70
+ }
71
+
72
+ /** #4591: lane-added migrations the repo's generator did not produce. */
73
+ export function migrationGeneratorPrompt(problems, mode = "local") {
74
+ const rows = problems.slice(0, 30).map((p) => `- ${p}`).join("\n");
75
+ return `These migration files were named by hand, not produced by the repo's migration generator:\n${rows}\n\nA made-up name or timestamp collides with another lane's migration. Delete each such file, then recreate it with the generator (hand-written SQL: the generator's \`--custom\` mode, e.g. \`drizzle-kit generate --custom --name=<name>\`, then put your SQL in the file it wrote), so the journal entry and snapshot come from it. Never name or number migration files by hand. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
76
+ }
77
+
66
78
  /** #4390: byte cap of the diff hunks a context-ceiling restart carries (the stat is always kept). */
67
79
  export const RESTART_DIFF_MAX_BYTES = 40 * 1024;
68
80
 
@@ -142,7 +154,7 @@ ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}
142
154
  If the issue is already fixed or invalid, write ${RESULT_FILE} {"status":"already-fixed|invalid","summary":"one line","evidence":["file:line ..."]} and stop. If it is blocked, write {"status":"blocked","summary":"",...} with a typed "block" (rules below) and stop.
143
155
 
144
156
  2. Otherwise write ${CONTRACT_FILE}, the frozen hand-off: a developer who never saw the issue must be able to implement and verify it from this file alone.
145
- {"requirements":[{"id":"R1","quote":"<verbatim sentence or checklist item from the issue>","disposition":"deliver|defer|post-deploy|not-applicable","why":"<required for defer and post-deploy>","evidence":"<file:line, required for not-applicable>","verify":"<optional: in_lane|out_of_band>"}],
157
+ {"requirements":[{"id":"R1","quote":"<verbatim sentence or checklist item from the issue>","disposition":"deliver|defer|post-deploy|not-applicable","why":"<defer, post-deploy>","check":"<post-deploy: id in .forge/post-deploy-checks.json>","evidence":"<file:line, required for not-applicable>","verify":"<optional: in_lane|out_of_band>"}],
146
158
  "deliverables":[{"id":"D1","path":"<file to change>","change":"<what>","requirements":["R1"]}],
147
159
  "preserve":[{"id":"P1","what":"<caller, contract or behaviour that must not change, with its path>"}],
148
160
  "validate":[{"id":"V1","command":"<exact command: the new regression test plus the existing tests that reference the files you change; a git diff compares against the merge base (origin/<lane>...HEAD), never the moving origin/<lane> tip>"}],
@@ -171,13 +183,16 @@ export function builderImplementPrompt(mode = "local", { toolchain = null, spars
171
183
 
172
184
  ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}${remote ? `\n\n${VERIFY_RULES[mode]}` : ""}
173
185
 
174
- The contract changes only through typed amendments in ${RESULT_FILE}, each with a why, which the engine posts on the issue:
175
- - {"kind":"add-deliverable","path":"<file>","why":""}: a file you must change that the contract did not list.
186
+ The contract changes only through typed amendments in ${RESULT_FILE}, each with a why:
187
+ - {"kind":"add-deliverable","path":"<file>","why":""}: a file the contract did not list.
176
188
  - {"kind":"add-requirement","requirement":{"id":"R9","quote":"<verbatim from the issue>","disposition":"deliver|defer|post-deploy"},"why":""}: an issue requirement the contract missed.
177
- - {"kind":"defer|post-deploy","requirement":"R#","why":""}: a contracted requirement that becomes separate work or a post-deploy check. The engine files it as a tracked follow-up. Not "type"; other kinds are rejected.
189
+ - {"kind":"defer|post-deploy","requirement":"R#","why":"","check":"<id>"}: a requirement that becomes separate work or a post-deploy check. Filed as a tracked follow-up. Not "type"; other kinds are rejected.
190
+ - {"kind":"amend-requirement","requirement":"R#","text":"<revised wording>","why":""}: reworded; judged against text.
191
+ New keys: translate, never copy English; status entries in place.
178
192
  Never report a deliver requirement as "deferred" or "follow-up" without an amendment.
179
- A new dependency between workspace packages in a package.json that no deliverable names is blocked by the engine: add it through add-deliverable with a why naming the package, or do not add it.
180
- If the repository has scripts/check-devdocs-freshness.sh, a behaviour change under the paths it checks must update the matching dossier (devdocs/modules/*.md, per devdocs/index.yaml) in the same change. Run it against the base before writing the result; the engine runs it too.
193
+ Never name or number migration files by hand; use the generator.
194
+ A new workspace-package dependency in a package.json no deliverable names is blocked by the engine: add it through add-deliverable with a why naming the package, or do not add it.
195
+ If the repository has scripts/check-devdocs-freshness.sh, a behaviour change under the paths it checks must update the matching dossier (devdocs/modules/*.md, per devdocs/index.yaml) in the same change. Run it against the base first; the engine runs it too.
181
196
 
182
197
  Before writing ${RESULT_FILE}, after your last edit, run \`node ${FORGEDOCK_CLI} check\` in the worktree and fix until it passes; if it cannot pass, report the problem as blocked.
183
198
 
@@ -244,6 +259,18 @@ export function enumeratingPlanNote(list) {
244
259
  return `\n\nEnumerating tests (found by the engine): these tests list every member of a module or directory this issue names, so a new export, table, job, action, namespace or file there must join their list or CI goes red. List each one you will touch under Affected Files in ${INVESTIGATION_FILE}, and add a validate command that runs only those tests.\n${list.slice(0, 40).map((e) => `- ${e.module} -> ${e.test}:${e.line} (${e.why})`).join("\n")}`;
245
260
  }
246
261
 
262
+ /** #4585: the repo's verify.invariants as a checklist for the plan; "" when none. */
263
+ export function invariantsPlanNote(list) {
264
+ if (!Array.isArray(list) || !list.length) return "";
265
+ return `\n\nRepo-wide invariants (verify.invariants): these commands run before every push whichever tests you choose, and each lists every table, export, key, namespace or file of its kind. A new one needs an entry in every such registry (e.g. registry, export, purge order, seeder, translation status, boundary or placeholder lists). Name each registry you will touch under Affected Files in ${INVESTIGATION_FILE}.\n${list.slice(0, 20).map((c) => `- \`${c}\``).join("\n")}`;
266
+ }
267
+
268
+ /** #4585: the builder rule for verify.invariants; "" when none. */
269
+ export function invariantsBuildRule(list) {
270
+ if (!Array.isArray(list) || !list.length) return "";
271
+ return `\n\nInvariants checklist: for each new table, export, key, namespace, page or file, add its entry to every registry or coverage list that enumerates its kind (e.g. a new table needs registry, export, purge and seeder entries; a new i18n key needs every locale). Then run each command below and fix any failure before you finish.\n${list.slice(0, 20).map((c) => `- \`${c}\``).join("\n")}`;
272
+ }
273
+
247
274
  const NOT_MEMBER = `or, when the symbol is not a member of that list, record {"symbol":"<name>","test":"<test path>","why":"<why it is not a member>"} in ${RESULT_FILE} "not_members"`;
248
275
 
249
276
  /** #4322: the builder rule citing the plan's enumerating tests; "" when none. */
@@ -311,6 +338,7 @@ const BLOCK_RULES = `"blocked" needs a typed "block" in ${RESULT_FILE}, one of:
311
338
  - {"kind":"split-required","subissues":[{"title":"","body":"","depends_on":[0],"files":["<path>"]}],"size":{"files":N,"lines":N}}: too big for one change. The engine files the sub-issues (depends_on = indexes of earlier sub-issues; files = the files or dirs each will edit, also accepted as "paths") only when every sub-issue's files are disjoint from the others' or the whole change's size estimate exceeds ${SPLIT_MIN_FILES} files or ${SPLIT_MIN_LINES} lines; otherwise keep one lane: an ordered checklist in the contract with a validate per item. In an admitted split, when siblings would edit the same hub (a barrel or index export list, a union type, a shared test fixture, an index doc), pre-stage its slots in the earliest sub-issue and make the others depend on it, or give each sibling its own per-feature file; the engine serialises sub-issues that still share a file.
312
339
  - {"kind":"human","why":"${HUMAN_BLOCK_REASONS.join("|")}"}: only an external action agents cannot take, or a product decision too critical for agents.
313
340
  "An upstream issue must land first", "needs a split" and "needs prod data that read-only monitoring can read" are never "human".
341
+ A check that times out (rc 124) is host load, never a block or "human" product-decision: run the narrowest targeted command for your changed files and say the full one could not finish in time.
314
342
  Remote sandbox lacking a host-only capability (sops, prod_monitor, ops_credentials): name it in the summary; the host reruns the lane.`;
315
343
 
316
344
  export function blockCorrectionPrompt(problem) {
@@ -342,7 +370,9 @@ export const REVIEWER_SYSTEM = `You are an independent conformance reviewer of o
342
370
 
343
371
  You receive the GitHub issue with its comments, the frozen contract (requirements quoted from the issue, deliverables, preserve constraints, validate commands) with typed amendments, the builder's result with validation output, the changed files, the diff, and a row skeleton with one row per contract id and changed file. The engine computes the verdict from your rows; a reply without rows is rejected, so fill the skeleton rather than composing rows: fill EVERY row (set each empty status, evidence and validated_by, and each null deferral_ok; keep the ids and files as given):
344
372
  - issue_coverage: one row per requirement or acceptance criterion in the issue, quoted verbatim, saying whether the contract covers it, narrows it, or misses it. The skeleton lists the contract's quotes; add a row for any issue requirement the contract does not quote.
373
+ - amendment: a requirement with an \`amendment\` is judged against amendment.text and its why, not the quote; its issue_coverage row is covered when the amendment follows from its why. If it does not hold, mark the row partial with note "amendment: <what to change>".
345
374
  - requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with evidence as a backtick-quoted repo-relative path:line copied verbatim from the changed files, including (group), [slug] and [...slug] folders (e.g. \`apps/web/app/(console)/layout.tsx:12\`, not route.ts:12) and the validate id or test that proves it (validated_by). For a defer or post-deploy requirement add deferral_ok: true only if it is genuinely separate work or only checkable after a deploy, false if it belongs in this change. For a verify out_of_band row (measured by the named job, not in this lane) set instrumented: true only when its budget test or bench is committed and that job is configured, with that path:line as evidence; false otherwise. Do not judge the measured result.
375
+ - post-deploy requirements: deferral_ok is false when the requirement has no \`check\` id, or its check is neither in .forge/post-deploy-checks.json on the default branch nor added by this change (the commands run from that file only).
346
376
  - scope: one row per changed file, pre-filled with the engine's authorization: the deliverable id that authorizes it (deliverables may be directories or globs), "test", "incidental" (changelog, lockfile), or "none" with what it changes. Change it only if the diff shows the file does something its deliverable does not cover.
347
377
  - preserve: one row per preserve id: intact|violated|unverified, with path:line. The skeleton starts each at "unverified"; set intact only after checking it.
348
378
  - regressions: concrete breakages only, each with path:line.
@@ -328,3 +328,31 @@ describe("remote capacity (GET /status, #4540)", () => {
328
328
  assert.equal(await p({ capacity: async () => { throw new Error("x"); } }).capacity(), null);
329
329
  });
330
330
  });
331
+
332
+ describe("remote stop at the checkpoint (#4612)", () => {
333
+ it("httpTransport.stop: POST .../stop, 2xx true, 404/405 false, other statuses throw", async () => {
334
+ const calls = [];
335
+ const mk = (status) => httpTransport({ endpoint: "https://pool.example/v1", fetchFn: async (url, init) => { calls.push([init.method, url]); return { status, ok: status < 400, text: async () => "", headers: { get: () => null } }; } });
336
+ assert.equal(await mk(202).stop("a b"), true);
337
+ assert.deepEqual(calls[0], ["POST", "https://pool.example/v1/sandboxes/a%20b/stop"]);
338
+ assert.equal(await mk(404).stop("x"), false);
339
+ assert.equal(await mk(405).stop("x"), false);
340
+ await assert.rejects(mk(500).stop("x"), /stop: HTTP 500/);
341
+ });
342
+
343
+ it("provider.stop and providerLanes.stopLane signal the transport, never destroy; no transport stop = false", async () => {
344
+ const root = tmpDir("remote-stop-");
345
+ const batch = openBatch(root, "bt");
346
+ const { provider, transport } = remoteFixture(batch);
347
+ const stopped = [];
348
+ const lanes = providerLanes({ provider, laneArgs: () => [], ledgerFor: () => null });
349
+ assert.equal(await lanes.stopLane(1, { handle: { id: "sbx" } }), false, "the transport has no stop");
350
+ transport.stop = async (id) => { stopped.push(id); return true; };
351
+ assert.equal(await lanes.stopLane(1, { handle: { id: "sbx" } }), true);
352
+ assert.equal(await lanes.stopLane(1, {}), false, "no handle");
353
+ transport.stop = async () => { throw new Error("boom"); };
354
+ assert.equal(await provider.stop({ id: "sbx" }), false);
355
+ assert.deepEqual(stopped, ["sbx"]);
356
+ assert.deepEqual(transport.destroyed, []);
357
+ });
358
+ });
@@ -44,6 +44,7 @@ export function isActionsEndpoint(endpoint) {
44
44
  * GET /sandboxes/:id/logs?cursor=N -> {text, cursor}, GET /sandboxes/:id/result -> lane result (404 = none yet),
45
45
  * GET /sandboxes/:id/usage -> {minutes, agent?} (agent: the pool agent that ran it, #4549), DELETE /sandboxes/:id (404 = gone). `Authorization: Bearer <token>`.
46
46
  * 429/503 on launch is a retryable capacity error (`Retry-After` seconds). Error text never carries the token.
47
+ * Optional `POST /sandboxes/:id/stop` (#4612) asks the pool to SIGTERM the lane so its teardown checkpoints and it reports `deferred run.interrupted`; 2xx = sent, 404/405 = unsupported or gone (never a DELETE fallback).
47
48
  * Optional `GET /status -> {free: N}` is the pool's free slots (#4540); anything else (404, error, malformed) is unknown (null).
48
49
  */
49
50
  export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fetch }) {
@@ -73,6 +74,7 @@ export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fet
73
74
  async logs(id, cursor = 0) { const r = await call("GET", `${p(id)}/logs?cursor=${encodeURIComponent(cursor)}`); return r.ok ? { text: String(r.data?.text ?? ""), cursor: r.data?.cursor ?? cursor } : { text: "", cursor }; },
74
75
  async result(id) { const r = await call("GET", `${p(id)}/result`); return r.ok && r.data && typeof r.data === "object" ? r.data : null; },
75
76
  async usage(id) { const r = await call("GET", `${p(id)}/usage`); return r.ok && Number.isFinite(Number(r.data?.minutes)) ? { minutes: Number(r.data.minutes), ...(typeof r.data.agent === "string" && r.data.agent ? { agent: r.data.agent } : {}) } : null; }, // agent: #4549, optional
77
+ async stop(id) { const r = await call("POST", `${p(id)}/stop`); if (r.ok) return true; if (r.status === 404 || r.status === 405) return false; throw fail("stop", r); },
76
78
  async destroy(id) { const r = await call("DELETE", p(id)); if (r.ok || r.status === 404) return true; throw fail("destroy", r); },
77
79
  async capacity() {
78
80
  // `free` (#4540) and agent liveness `online`/`total` (#4547) are each optional; none valid is unknown.
@@ -167,6 +169,11 @@ export function remoteProvider({ batch, repo, settings = {}, name = "remote", tr
167
169
  async capacity() {
168
170
  try { return (await transport.capacity?.()) ?? null; } catch { return null; }
169
171
  },
172
+ /** #4612: a graceful stop at the lane's checkpoint; false when unsupported or failed. Never destroys the sandbox. */
173
+ async stop(h) {
174
+ if (typeof transport.stop !== "function") return false;
175
+ try { return (await transport.stop(h.id)) === true; } catch { return false; }
176
+ },
170
177
  async teardown(h) {
171
178
  await minutesOf(h);
172
179
  try { return (await transport.destroy(h.id)) !== false; } catch { return false; }
@@ -8,15 +8,18 @@
8
8
  *
9
9
  * Protocol: newline-delimited JSON, one request per connection.
10
10
  * {op:"submit", argv, cwd, env} → {type:"batch", id, dir}, {type:"row", row}…, {type:"line", text}…, {type:"done", result, report}
11
- * {op:"attach", id} → log.jsonl rows replayed, then live rows/lines and done; a finished batch replays report.json
11
+ * {op:"attach", id, resume?} → (`resume` also clears a pending cancel, #4592) log.jsonl rows replayed, then live rows/lines and done; a finished batch replays report.json
12
12
  * ({type:"done", replay:true}) unless it has unrun added issues; a batch nobody hosts is resumed here
13
13
  * {op:"set-parallel", id, k} → {type:"ok", parallel}
14
+ * {op:"requeue", id, issues, reason, by} → {type:"ok", requeued, refused, hosted, reopened} (#4550): see batch.requeue; a finished batch is hosted again
14
15
  * {op:"add", id, issues} → {type:"ok", added, skipped, hosted} (#4408): merged into the batch's `add` file, which a
15
16
  * hosted runner reads on its next pass and an unhosted batch on its next resume
16
- * {op:"cancel", id} → {type:"ok", cancelled:true, hosted} (#4376): writes the batch's `cancel` file; that batch
17
+ * {op:"cancel", id, stopRemote?} → {type:"ok", cancelled:true, hosted} (#4376): writes the batch's `cancel` file; that batch
17
18
  * admits nothing more, its running local lanes get SIGTERM (their teardown saves the work)
18
19
  * and the rest end held; no other batch and not the daemon. A finished, unhosted batch:
19
20
  * {type:"ok", cancelled:false, finished:true}, nothing written
21
+ * {op:"hold"|"release", id, issues} → {type:"ok", changed, hosted} (#4592): merges into / removes from the batch's `hold` file; a hosted
22
+ * runner admits no held issue from its next pass (running lanes are not stopped)
20
23
  * {op:"status"} → {type:"status", pid, batches:[{id, running}]}
21
24
  * Failures answer {type:"error", message}. A client closing its connection never cancels a batch (only `cancel` stops one), and stopping the daemon
22
25
  * never stops a lane (lanes are detached sessions). On start the daemon resumes every daemon-hosted batch without a
@@ -39,6 +42,7 @@ import { batchRoot, listBatches, openBatch } from "./batch.mjs";
39
42
  export const IDLE_MS = 10 * 60 * 1000;
40
43
  export const STOP_MS = 10 * 1000;
41
44
  export const LOCK_CHECK_MS = 5 * 1000;
45
+ export const DUE_MS = 15 * 60 * 1000; // #4627: how often the due callback (windowed post-deploy monitoring) runs while the daemon is up
42
46
 
43
47
  export function scheddPaths(dir = schedDir()) {
44
48
  return { dir, sock: join(dir, "schedd.sock"), lock: join(dir, "schedd.lock"), log: join(dir, "schedd.log") };
@@ -81,7 +85,7 @@ function takeLock(lock, pid, alive) {
81
85
  */
82
86
  export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(process.env.FORGEDOCK_SCHEDD_IDLE_MS) || IDLE_MS,
83
87
  root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true, stopMs = STOP_MS,
84
- lockCheckMs = LOCK_CHECK_MS } = {}) {
88
+ lockCheckMs = LOCK_CHECK_MS, due = null, dueMs = Number(process.env.FORGEDOCK_SCHEDD_DUE_MS) || DUE_MS } = {}) {
85
89
  const p = scheddPaths(dir);
86
90
  mkdirSync(dir, { recursive: true });
87
91
  const lock = takeLock(p.lock, pid, alive);
@@ -140,11 +144,26 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
140
144
  const { added, skipped } = b.addIssues(req.issues ?? []);
141
145
  return send({ type: "ok", added, skipped, hosted: hosted.has(req.id) }, true);
142
146
  }
147
+ if (req.op === "requeue") {
148
+ // #4550: the runner applies the entries on its next pass; a finished, unhosted batch is reopened (hosted as a resume).
149
+ const b = openBatch(root, req.id);
150
+ if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
151
+ const { requeued, refused } = b.requeue(req.issues ?? [], { reason: req.reason, by: req.by });
152
+ const reopen = requeued.length > 0 && !hosted.has(req.id) && !starting.has(req.id);
153
+ if (reopen) await host({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} });
154
+ return send({ type: "ok", requeued, refused, hosted: hosted.has(req.id), reopened: reopen }, true);
155
+ }
156
+ if (req.op === "hold" || req.op === "release") {
157
+ const b = openBatch(root, req.id);
158
+ if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
159
+ const changed = req.op === "hold" ? b.holdIssues(req.issues ?? []) : b.releaseIssues(req.issues ?? []);
160
+ return send({ type: "ok", changed, hosted: hosted.has(req.id) }, true);
161
+ }
143
162
  if (req.op === "cancel") {
144
163
  const b = openBatch(root, req.id);
145
164
  if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
146
165
  if (!hosted.has(req.id) && b.readReport()) return send({ type: "ok", cancelled: false, finished: true }, true);
147
- b.requestCancel({ reason: "operator" });
166
+ b.requestCancel({ reason: "operator", stopRemote: req.stopRemote === true });
148
167
  return send({ type: "ok", cancelled: true, hosted: hosted.has(req.id) }, true);
149
168
  }
150
169
  const sub = (m) => send(m, m.type === "done" || m.type === "error");
@@ -153,6 +172,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
153
172
  return send({ type: "batch", id: ent.id, dir: ent.dir });
154
173
  }
155
174
  if (req.op === "attach") {
175
+ if (req.resume) { const rb = openBatch(root, req.id); if (rb.exists()) rb.clearCancel(); } // #4592: the operator's resume continues a cancelled batch the daemon still hosts
156
176
  if (!hosted.has(req.id) && starting.has(req.id)) await starting.get(req.id).catch(() => {});
157
177
  const live = hosted.get(req.id);
158
178
  if (live) {
@@ -164,7 +184,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
164
184
  const b = openBatch(root, req.id);
165
185
  if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
166
186
  const rep = b.readReport();
167
- if (rep && !(rep.rows ?? []).some((r) => r.outcome === "held") && !b.unrunAdds().length) {
187
+ if (rep && !(rep.rows ?? []).some((r) => r.outcome === "held") && !b.unrunAdds().length && !b.unrunRequeues().length) {
168
188
  return send({ type: "done", replay: true, result: { rows: rep.rows ?? [], crossLane: rep.crossLane ?? [], batch: b.id, ok: !!rep.ok }, report: rep.report ?? null }, true);
169
189
  }
170
190
  const ent = await host({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} }, sub);
@@ -203,6 +223,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
203
223
  if (closing) return closing;
204
224
  clearInterval(idleTimer);
205
225
  clearInterval(lockTimer);
226
+ if (dueTimer) clearInterval(dueTimer);
206
227
  for (const c of conns) c.destroy();
207
228
  const stopping = [...runs.keys()];
208
229
  for (const ac of runs.values()) ac.abort();
@@ -232,6 +253,14 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
232
253
  else if (!existsSync(p.sock)) { log(`schedd: lost socket ${p.sock}, exiting`); close(); }
233
254
  }, lockCheckMs);
234
255
  lockTimer.unref?.();
256
+ // #4627: windowed post-deploy checks. One run at a time; a failure is logged and never stops the daemon.
257
+ let dueRunning = false;
258
+ const dueTimer = due ? setInterval(async () => {
259
+ if (closing || dueRunning) return;
260
+ dueRunning = true;
261
+ try { await due(); } catch (e) { log(`schedd: due run failed: ${e?.message ?? e}`); } finally { dueRunning = false; }
262
+ }, dueMs) : null;
263
+ dueTimer?.unref?.();
235
264
 
236
265
  // Daemon restart: resume every daemon-hosted batch that never wrote its report; live lanes are re-adopted.
237
266
  if (resumeOnStart) {
@@ -208,6 +208,32 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
208
208
  } finally { await h.cleanup(); }
209
209
  });
210
210
 
211
+ it("#4550: --requeue on a finished batch with a report reopens it through the daemon and runs the issue again; --add still skips it", async () => {
212
+ const h = host();
213
+ try {
214
+ await h.daemon();
215
+ const client = { ...h.client, io: { gh: async (a) => (a[0] === "issue" ? JSON.stringify({ state: "OPEN", labels: [] }) : "o/r\n") } };
216
+ await withRoot(h, () => capture(async () => {
217
+ writeFileSync(h.release, "");
218
+ const first = await orchestrateFromCli(["1", "--parallel", "2", "--repo", "o/r", "--no-prefilter"], h.client);
219
+ assert.deepEqual(first.rows.map((r) => [r.issue, r.outcome]), [[1, "merged"]]);
220
+ const [id] = listBatches(h.root);
221
+ const b = openBatch(h.root, id);
222
+ // The lane ended needs-human and the operator decided it: the finished batch holds that terminal row.
223
+ b.store.save({ ...b.store.load(), results: { 1: { issue: 1, outcome: "needs-human", pr: null, cost_usd: 0, minutes: 0 } } });
224
+ const rep = b.readReport();
225
+ b.writeReport({ ...rep, rows: [{ issue: 1, outcome: "needs-human" }] });
226
+ assert.deepEqual((await orchestrateFromCli(["--add", id, "1"], client)).skipped.map((x) => x.reason), ["already in batch"]);
227
+ const res = await orchestrateFromCli(["--requeue", id, "1", "--reason", "decided"], client);
228
+ assert.deepEqual(res.requeued, [1]);
229
+ await until(() => h.launched().length === 2);
230
+ await until(() => b.store.load()?.results?.[1]?.outcome === "merged" && b.readReport()?.rows?.[0]?.outcome === "merged");
231
+ assert.deepEqual(listBatches(h.root), [id], "no second batch");
232
+ assert.equal(b.readReport().rows[0].requeued.reason, "decided");
233
+ }));
234
+ } finally { await h.cleanup(); }
235
+ });
236
+
211
237
  it("#4376: --cancel stops one batch (its lane torn down, the rest held, report written); the other batch and the daemon run on", async () => {
212
238
  const h = host();
213
239
  try {
@@ -238,6 +264,36 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
238
264
  } finally { await h.cleanup(); }
239
265
  });
240
266
 
267
+ it("#4592: --hold keeps #2 from running on a live batch; --resume <id> 2 after a cancel re-admits it", async () => {
268
+ const h = host();
269
+ try {
270
+ await h.daemon();
271
+ await withRoot(h, () => capture(async () => {
272
+ const first = orchestrateFromCli(["1", "2", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client);
273
+ await until(() => h.launched().length === 1 && h.lanePids().length === 1);
274
+ const [id] = listBatches(h.root);
275
+ const b = openBatch(h.root, id);
276
+ const held = await orchestrateFromCli(["--hold", id, "2", "x"], h.client);
277
+ assert.deepEqual(held.changed, [2]);
278
+ assert.deepEqual(b.readHeld(), [2]);
279
+ await assert.rejects(orchestrateFromCli(["--hold", id], h.client), /usage/);
280
+ await assert.rejects(orchestrateFromCli(["--release", "nope", "2"], h.client), /no batch nope/);
281
+ const res = await orchestrateFromCli(["--cancel", id], h.client);
282
+ assert.equal(res.cancelled, true);
283
+ const cancelled = await first;
284
+ assert.deepEqual(cancelled.rows.map((r) => [r.issue, r.outcome]), [[1, "held"], [2, "held"]]);
285
+ assert.deepEqual(b.readHeld(), [2], "the hold survives the cancel");
286
+ // The issue's case: a resume naming the issue re-admits it (it used to print `skipped #2 (already in batch)`).
287
+ const io = { gh: async (x) => (x[0] === "issue" ? JSON.stringify({ state: "OPEN" }) : "o/r\n") };
288
+ const resumed = orchestrateFromCli(["--resume", id, "2"], { ...h.client, io });
289
+ await until(() => b.readCancel() === null && b.readHeld().length === 0);
290
+ setTimeout(() => writeFileSync(h.release, ""), 300);
291
+ const done = await resumed;
292
+ assert.deepEqual(done.rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"]]);
293
+ }));
294
+ } finally { await h.cleanup(); }
295
+ });
296
+
241
297
  it("#4481: a batch cancelled through the daemon is not resumed by a schedd restart", async () => {
242
298
  const h = host();
243
299
  try {
@@ -457,3 +513,31 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
457
513
  assert.deepEqual(env, { NODE_OPTIONS: "x", PATH: "/c", CLAUDE_CONFIG_DIR: "/acct-b", FORGEDOCK_SCHED_DIR: "/s" });
458
514
  });
459
515
  });
516
+
517
+ describe("schedd due timer (#4627)", () => {
518
+ const boot = async (opts) => startDaemon({ dir: tmpDir("schedd-due-"), root: tmpDir("schedd-due-b-"), runBatch: async () => ({}), resumeOnStart: false, log: () => {}, ...opts });
519
+
520
+ it("runs the due callback on its interval, never overlapped, and survives a throwing callback", async () => {
521
+ let calls = 0, active = 0, overlap = false;
522
+ const logs = [];
523
+ const d = await boot({ dueMs: 15, log: (m) => logs.push(m), due: async () => {
524
+ calls++; active++; if (active > 1) overlap = true;
525
+ await new Promise((r) => setTimeout(r, 40));
526
+ active--; if (calls === 1) throw new Error("boom");
527
+ } });
528
+ try {
529
+ await until(() => calls >= 3);
530
+ assert.equal(overlap, false);
531
+ assert.ok(logs.some((m) => /due run failed: boom/.test(m)));
532
+ assert.ok(existsSync(d.sock));
533
+ } finally { await d.close(); }
534
+ const n = calls;
535
+ await new Promise((r) => setTimeout(r, 80));
536
+ assert.equal(calls, n, "the timer is cleared on close");
537
+ });
538
+
539
+ it("without a due callback there is no timer", async () => {
540
+ const d = await boot({ dueMs: 10 });
541
+ await d.close();
542
+ });
543
+ });
@@ -431,3 +431,29 @@ export async function renumberLane({ git, base, cwd, laneRef = "HEAD", regenerat
431
431
  return { ok: true, moves: moves.map(({ from, to }) => ({ from, to })), commit: (await g(["rev-parse", "HEAD"])).trim() || null };
432
432
  } catch (e) { return await fail(`deterministic renumber failed: ${String(e?.message ?? e).split("\n")[0]}`); }
433
433
  }
434
+
435
+ /**
436
+ * #4591: hand-named migrations. In a drizzle-style dir (`<dir>/meta/_journal.json` at `ref`) a lane-added `<tag>.sql` the
437
+ * generator wrote always has a journal entry the lane added and a `<dir>/meta/<prefix>_snapshot.json` the lane added
438
+ * (`generate --custom` too); a file named by hand has neither, and its made-up timestamp collides with a sibling lane's.
439
+ * No journal dir: []. @returns {Promise<string[]>} one problem per file
440
+ */
441
+ export async function generatorProblems({ git, base, ref = "HEAD" }) {
442
+ const g = out(git), problems = [];
443
+ const added = lines(await g(["-c", "core.quotepath=off", "diff", "--name-only", "--diff-filter=A", `${base}...${ref}`])).filter((f) => !SKIP_RE.test(f));
444
+ const tagsAt = async (r, jp) => { try { return new Set((JSON.parse(await g(["show", `${r}:${jp}`])).entries ?? []).map((e) => e?.tag)); } catch { return new Set(); } };
445
+ for (const dir of [...new Set(added.filter((f) => f.endsWith(".sql")).map((f) => dirname(f)))]) {
446
+ const jp = posix.join(dir, "meta", "_journal.json");
447
+ if (!(await g(["show", `${ref}:${jp}`])).trim()) continue;
448
+ const [was, now] = [await tagsAt(base, jp), await tagsAt(ref, jp)];
449
+ for (const f of added.filter((x) => dirname(x) === dir && x.endsWith(".sql"))) {
450
+ const tag = basename(f, ".sql"), prefix = /^(\d+)[_-]/.exec(tag)?.[1];
451
+ if (!prefix) continue;
452
+ const why = [];
453
+ if (!now.has(tag) || was.has(tag)) why.push("no journal entry added for it");
454
+ if (!added.includes(posix.join(dir, "meta", `${prefix}_snapshot.json`))) why.push(`no meta/${prefix}_snapshot.json added`);
455
+ if (why.length) problems.push(`${f}: not produced by the migration generator (${why.join("; ")})`);
456
+ }
457
+ }
458
+ return problems;
459
+ }
@@ -8,9 +8,9 @@ import { execFileSync } from "node:child_process";
8
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
10
  import { tmpDir } from "../tests/_tmp.mjs";
11
- import { sequenceCollisions, journalProblems, snapshotLane, verifyRenumber, numberOnlyChange, headerOnlyChange, renumberLane, resolveSequenceConflicts } from "./sequence.mjs";
11
+ import { sequenceCollisions, journalProblems, snapshotLane, verifyRenumber, numberOnlyChange, headerOnlyChange, renumberLane, resolveSequenceConflicts, generatorProblems } from "./sequence.mjs";
12
12
  import { makeSequenceLeaseIo } from "./seqlease.mjs";
13
- import { sequencePrompt, sequenceSyncNote } from "./prompts.mjs";
13
+ import { sequencePrompt, sequenceSyncNote, migrationGeneratorPrompt, builderImplementPrompt } from "./prompts.mjs";
14
14
 
15
15
  const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } });
16
16
  const gitIn = (cwd) => async (a) => sh(cwd, ...a);
@@ -453,3 +453,42 @@ describe("sequence-numbered documents: two siblings each add an ADR (#4385)", ()
453
453
  assert.match(p, /series index/);
454
454
  });
455
455
  });
456
+
457
+ describe("hand-named migrations (#4591)", () => {
458
+ const T1 = "20261025020000_sso_domain_verification", T2 = "20261025020100_next";
459
+ function base() {
460
+ const d = tmpDir("gen-");
461
+ sh(d, "init", "-q", "-b", "main");
462
+ put(d, { "db/meta/_journal.json": journal("20261024000000_init"), "db/meta/20261024000000_snapshot.json": "{}\n", "db/20261024000000_init.sql": "x;\n" });
463
+ commit(d, "init");
464
+ sh(d, "checkout", "-qb", "lane");
465
+ return d;
466
+ }
467
+ it("flags a hand-named file with no journal entry and no snapshot, naming both gaps", async () => {
468
+ const d = base();
469
+ put(d, { [`db/${T1}.sql`]: "y;\n" });
470
+ commit(d, "hand");
471
+ const p = await generatorProblems({ git: gitIn(d), base: "main" });
472
+ assert.equal(p.length, 1);
473
+ assert.match(p[0], /20261025020000_sso_domain_verification\.sql: not produced by the migration generator \(no journal entry added for it; no meta\/20261025020000_snapshot\.json added\)/);
474
+ });
475
+ it("accepts generator output (journal entry + snapshot, also --custom) and dirs without a journal", async () => {
476
+ const d = base();
477
+ put(d, { [`db/${T1}.sql`]: "y;\n", [`db/${T2}.sql`]: "z;\n", "db/meta/_journal.json": journal("20261024000000_init", T1, T2), "db/meta/20261025020000_snapshot.json": "{}\n", "db/meta/20261025020100_snapshot.json": "{}\n", "plain/0001_a.sql": "a;\n" });
478
+ commit(d, "generated");
479
+ assert.deepEqual(await generatorProblems({ git: gitIn(d), base: "main" }), []);
480
+ });
481
+ it("flags an entry without a snapshot, and accepts an engine renumber (P3)", async () => {
482
+ const d = base();
483
+ put(d, { [`db/${T1}.sql`]: "y;\n", "db/meta/_journal.json": journal("20261024000000_init", T1) });
484
+ commit(d, "journal only");
485
+ assert.match((await generatorProblems({ git: gitIn(d), base: "main" }))[0], /\(no meta\/20261025020000_snapshot\.json added\)/);
486
+ const f = fixture();
487
+ renumber(f);
488
+ assert.deepEqual(await generatorProblems({ git: gitIn(f), base: "main" }), []);
489
+ });
490
+ it("the fix prompt and the builder rule forbid naming migrations by hand", () => {
491
+ assert.match(migrationGeneratorPrompt(["db/x.sql: not produced by the migration generator (no journal entry added for it)"]), /drizzle-kit generate --custom[\s\S]*Never name or number migration files by hand/);
492
+ assert.match(builderImplementPrompt("local"), /Never name or number migration files by hand/);
493
+ });
494
+ });
@@ -0,0 +1,77 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * #4590: tool state that must never ride a lane branch: the engine's own scratch (.forge-lean/) and tool caches
5
+ * (.eslintcache, *.tsbuildinfo, and a generated next-env.d.ts when the repo ignores it). `info/exclude` only hides untracked
6
+ * files, so a force-added or checkpoint-staged one gets committed and then conflicts in every base merge. This is the
7
+ * deterministic gate: before a push, files the branch ADDED against the merge base are removed from the index (a file the
8
+ * base itself tracks is the repo's own choice and is never touched). Git is injected: `git(args) -> stdout`, throws on failure.
9
+ */
10
+ export const LEAN_SCRATCH = ".forge-lean/";
11
+ const CACHE_BASENAMES = [/^\.eslintcache$/, /\.tsbuildinfo$/];
12
+ const NEXT_ENV = "next-env.d.ts";
13
+
14
+ /** Lines `forgedock init` adds to a repo's .gitignore (next-env.d.ts only for Next.js repos). */
15
+ export const GITIGNORE_LINES = [".forge-lean/", ".eslintcache", "*.tsbuildinfo"];
16
+ export const GITIGNORE_NEXT_LINE = NEXT_ENV;
17
+
18
+ const base = (p) => p.slice(p.lastIndexOf("/") + 1);
19
+ /** Deny-listed path? `nextEnvIgnored`: the repo's own .gitignore ignores next-env.d.ts, so a tracked copy is stray. */
20
+ export function isToolState(path, { nextEnvIgnored = false } = {}) {
21
+ const p = String(path ?? "").replace(/^\.\//, "");
22
+ if (!p) return false;
23
+ if (p.startsWith(LEAN_SCRATCH) || p === LEAN_SCRATCH.slice(0, -1)) return true;
24
+ const b = base(p);
25
+ return CACHE_BASENAMES.some((re) => re.test(b)) || (nextEnvIgnored && b === NEXT_ENV);
26
+ }
27
+ export const denied = (paths, opts) => paths.filter((p) => isToolState(p, opts));
28
+
29
+ const zlist = (out) => String(out ?? "").split("\0").filter(Boolean);
30
+ const quiet = async (git, args) => { try { await git(args); return true; } catch { return false; } };
31
+
32
+ /** The subset of `paths` the repo's .gitignore ignores (`--no-index`: tracked copies count). */
33
+ async function ignoredNextEnv(git, paths) {
34
+ const out = [];
35
+ for (const p of paths) if (base(p) === NEXT_ENV && await quiet(git, ["check-ignore", "-q", "--no-index", "--", p])) out.push(p);
36
+ return out;
37
+ }
38
+
39
+ /** Deny-listed paths among `paths`, including next-env.d.ts copies the repo ignores. */
40
+ export async function strayPaths(git, paths) {
41
+ const plain = denied(paths);
42
+ const nexts = await ignoredNextEnv(git, paths.filter((p) => !plain.includes(p)));
43
+ return [...plain, ...nexts];
44
+ }
45
+
46
+ /** After `git add -A`: unstage deny-listed paths so a checkpoint never commits them. Returns true when something else is staged. */
47
+ export async function unstageToolState(git) {
48
+ const staged = zlist(await git(["diff", "--cached", "--name-only", "-z"]));
49
+ const stray = await strayPaths(git, staged);
50
+ if (stray.length) await git(["reset", "-q", "--", ...stray]);
51
+ return stray.length ? (await git(["diff", "--cached", "--name-only"])).trim() !== "" : staged.length > 0;
52
+ }
53
+
54
+ /**
55
+ * Remove from the index (and commit) the deny-listed files this branch added against `base`. Returns the removed paths ([]
56
+ * when the branch is clean). Never throws: a failure leaves the branch as it was and the push proceeds.
57
+ */
58
+ export async function untrackToolState({ git, base: ref, ev = () => {} }) {
59
+ try {
60
+ const added = zlist(await git(["diff", "--diff-filter=A", "--name-only", "-z", `${ref}...HEAD`]));
61
+ const stray = await strayPaths(git, added);
62
+ if (!stray.length) return [];
63
+ await git(["rm", "--cached", "-r", "-q", "--ignore-unmatch", "--", ...stray]);
64
+ await git(["commit", "--no-verify", "-m", "chore: untrack lane tool state (#4590)", "-m", stray.slice(0, 20).join("\n")]);
65
+ ev({ event: "TOOLSTATE_UNTRACKED", files: stray.slice(0, 20), count: stray.length });
66
+ return stray;
67
+ } catch (e) {
68
+ ev({ event: "TOOLSTATE_UNTRACK_FAILED", error: String(e?.message ?? e).slice(0, 200) });
69
+ return [];
70
+ }
71
+ }
72
+
73
+ /** The deny-list lines a repo's .gitignore text lacks (append-only; `next`: also next-env.d.ts, for Next.js repos). */
74
+ export function gitignoreAdditions(text, { next = false } = {}) {
75
+ const have = new Set(String(text ?? "").split(/\r?\n/).map((l) => l.trim().replace(/^\//, "")));
76
+ return [...GITIGNORE_LINES, ...(next ? [GITIGNORE_NEXT_LINE] : [])].filter((l) => !have.has(l));
77
+ }
@@ -0,0 +1,83 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { execFileSync } from "node:child_process";
6
+ import { mkdirSync, writeFileSync } from "node:fs";
7
+ import { join, dirname } from "node:path";
8
+ import { tmpDir } from "../tests/_tmp.mjs";
9
+ import { isToolState, denied, untrackToolState, unstageToolState, gitignoreAdditions } from "./toolstate.mjs";
10
+
11
+ const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8" });
12
+ const put = (cwd, f, t = "x\n") => { mkdirSync(dirname(join(cwd, f)), { recursive: true }); writeFileSync(join(cwd, f), t); };
13
+ const repo = () => {
14
+ const d = tmpDir("toolstate-repo-");
15
+ sh(d, "init", "-q", "-b", "main"); sh(d, "config", "user.email", "t@t"); sh(d, "config", "user.name", "t");
16
+ put(d, "src/a.js"); put(d, ".gitignore", "next-env.d.ts\n"); sh(d, "add", "-A"); sh(d, "commit", "-q", "-m", "base");
17
+ sh(d, "checkout", "-q", "-b", "lane");
18
+ return d;
19
+ };
20
+ const gitIn = (d) => async (args) => execFileSync("git", args, { cwd: d, encoding: "utf8" });
21
+
22
+ describe("isToolState (#4590)", () => {
23
+ it("matches scratch and caches at any depth, nothing else", () => {
24
+ for (const p of [".forge-lean/result.json", ".forge-lean/logs/x", "apps/worker/.eslintcache", ".eslintcache", "a/b/tsconfig.tsbuildinfo"]) assert.ok(isToolState(p), p);
25
+ for (const p of ["src/eslintcache.ts", "src/.eslintcache.md", "forge-lean/x", "docs/.forge-lean.md", "apps/web/next-env.d.ts", "x.tsbuildinfo.json"]) assert.ok(!isToolState(p), p);
26
+ });
27
+ it("next-env.d.ts only when the repo ignores it", () => {
28
+ assert.ok(isToolState("apps/web/next-env.d.ts", { nextEnvIgnored: true }));
29
+ assert.deepEqual(denied(["a", ".eslintcache"]), [".eslintcache"]);
30
+ });
31
+ });
32
+
33
+ describe("untrackToolState on a real repo (#4590)", () => {
34
+ it("removes what the lane added, keeps product files and files the base tracks, then a second run is a no-op", async () => {
35
+ const d = repo();
36
+ put(d, "src/b.js"); put(d, ".forge-lean/result.json"); put(d, "apps/worker/.eslintcache"); put(d, "apps/web/next-env.d.ts");
37
+ sh(d, "add", "-f", "-A");
38
+ sh(d, "commit", "-q", "-m", "lane");
39
+ const events = [];
40
+ const removed = await untrackToolState({ git: gitIn(d), base: "main", ev: (e) => events.push(e) });
41
+ assert.deepEqual(removed.sort(), [".forge-lean/result.json", "apps/web/next-env.d.ts", "apps/worker/.eslintcache"]);
42
+ assert.equal(events[0].event, "TOOLSTATE_UNTRACKED");
43
+ assert.deepEqual(sh(d, "diff", "--name-only", "main...HEAD").trim().split("\n"), ["src/b.js"]);
44
+ assert.ok(sh(d, "status", "--porcelain").includes("?? .forge-lean/") || sh(d, "ls-files", "--others").includes(".forge-lean"), "files stay on disk");
45
+ assert.deepEqual(await untrackToolState({ git: gitIn(d), base: "main" }), []);
46
+ });
47
+
48
+ it("a cache the base already tracks is the repo's own choice and stays", async () => {
49
+ const d = repo(); sh(d, "checkout", "-q", "main");
50
+ put(d, "pkg/.eslintcache"); sh(d, "add", "-A"); sh(d, "commit", "-q", "-m", "base cache"); sh(d, "checkout", "-q", "lane"); sh(d, "merge", "-q", "main");
51
+ put(d, "src/c.js"); sh(d, "add", "-A"); sh(d, "commit", "-q", "-m", "lane");
52
+ assert.deepEqual(await untrackToolState({ git: gitIn(d), base: "main" }), []);
53
+ assert.ok(sh(d, "ls-files").includes("pkg/.eslintcache"));
54
+ });
55
+
56
+ it("a git failure never throws", async () => {
57
+ const events = [];
58
+ assert.deepEqual(await untrackToolState({ git: async () => { throw new Error("boom"); }, base: "main", ev: (e) => events.push(e) }), []);
59
+ assert.equal(events[0].event, "TOOLSTATE_UNTRACK_FAILED");
60
+ });
61
+ });
62
+
63
+ describe("unstageToolState (#4590)", () => {
64
+ it("a checkpoint's add -A stages the work but not the scratch or caches", async () => {
65
+ const d = repo(); put(d, "src/b.js"); put(d, ".eslintcache"); put(d, ".forge-lean/x");
66
+ sh(d, "add", "-A", "-f");
67
+ assert.equal(await unstageToolState(gitIn(d)), true);
68
+ assert.equal(sh(d, "diff", "--cached", "--name-only").trim(), "src/b.js");
69
+ });
70
+ it("only tool state staged: nothing to commit", async () => {
71
+ const d = repo(); put(d, ".eslintcache"); sh(d, "add", "-f", ".eslintcache");
72
+ assert.equal(await unstageToolState(gitIn(d)), false);
73
+ });
74
+ });
75
+
76
+ describe("gitignoreAdditions (#4590)", () => {
77
+ it("adds only the missing lines, and next-env.d.ts only for Next.js", () => {
78
+ assert.deepEqual(gitignoreAdditions(""), [".forge-lean/", ".eslintcache", "*.tsbuildinfo"]);
79
+ assert.deepEqual(gitignoreAdditions("node_modules\n/.eslintcache\n.forge-lean/\n"), ["*.tsbuildinfo"]);
80
+ assert.deepEqual(gitignoreAdditions(".forge-lean/\n.eslintcache\n*.tsbuildinfo\n"), []);
81
+ assert.deepEqual(gitignoreAdditions(".forge-lean/\n.eslintcache\n*.tsbuildinfo\n", { next: true }), ["next-env.d.ts"]);
82
+ });
83
+ });