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.
- package/README.md +78 -324
- package/bin/engine/events.mjs +152 -0
- package/bin/engine/events.test.mjs +128 -0
- package/bin/engine/telemetry-seam.mjs +4 -3
- package/bin/engine-cli.mjs +181 -26
- package/bin/experiment.mjs +55 -0
- package/bin/forgedock.mjs +19 -1
- package/bin/hooks/reread-nudge.mjs +44 -5
- package/bin/lean/batch.mjs +66 -3
- package/bin/lean/batch.test.mjs +176 -1
- package/bin/lean/budget.mjs +14 -3
- package/bin/lean/budget.test.mjs +11 -1
- package/bin/lean/conflictres.mjs +93 -0
- package/bin/lean/conflictres.test.mjs +93 -0
- package/bin/lean/conformance.mjs +30 -9
- package/bin/lean/conformance.test.mjs +51 -0
- package/bin/lean/effects.mjs +14 -3
- package/bin/lean/execution.mjs +2 -0
- package/bin/lean/i18n.mjs +65 -0
- package/bin/lean/i18n.test.mjs +47 -0
- package/bin/lean/lane.mjs +16 -0
- package/bin/lean/lean.mjs +147 -36
- package/bin/lean/lean.test.mjs +319 -8
- package/bin/lean/orchestrate.mjs +130 -13
- package/bin/lean/postdeploy-check.mjs +149 -0
- package/bin/lean/postdeploy-check.test.mjs +146 -0
- package/bin/lean/postdeploy-monitor.mjs +87 -0
- package/bin/lean/postdeploy-monitor.test.mjs +121 -0
- package/bin/lean/precheck.mjs +58 -8
- package/bin/lean/precheck.test.mjs +77 -3
- package/bin/lean/prefilter.mjs +29 -4
- package/bin/lean/prefilter.test.mjs +13 -5
- package/bin/lean/probe.mjs +2 -1
- package/bin/lean/prompts.mjs +36 -6
- package/bin/lean/sandbox-provider.contract.test.mjs +28 -0
- package/bin/lean/sandbox-remote.mjs +7 -0
- package/bin/lean/schedd.mjs +34 -5
- package/bin/lean/schedd.test.mjs +84 -0
- package/bin/lean/sequence.mjs +26 -0
- package/bin/lean/sequence.test.mjs +41 -2
- package/bin/lean/toolstate.mjs +77 -0
- package/bin/lean/toolstate.test.mjs +83 -0
- package/bin/lean/verify.mjs +18 -2
- package/bin/lean/verify.test.mjs +29 -3
- package/bin/stats/aggregate.mjs +1 -1
- package/bin/stats/experiment.mjs +44 -0
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/index.mjs +42 -2
- package/bin/stats/release.mjs +75 -0
- package/bin/stats/release.test.mjs +68 -0
- package/commands/orchestrate.md +8 -3
- package/docs/CONFIG.md +61 -2
- package/docs/EVENTS.md +68 -0
- package/docs/STATS.md +31 -0
- package/docs/TELEMETRY.md +3 -1
- package/docs/site/install.md +89 -0
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +2 -1
- package/scripts/eval-gate-scorecard.mjs +7 -0
- package/scripts/file-deferred-followups.sh +1 -0
- package/scripts/file-deferred-followups.test.sh +8 -0
package/bin/lean/prompts.mjs
CHANGED
|
@@ -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":"<
|
|
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
|
|
175
|
-
- {"kind":"add-deliverable","path":"<file>","why":""}: a file
|
|
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
|
|
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
|
-
|
|
180
|
-
|
|
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; }
|
package/bin/lean/schedd.mjs
CHANGED
|
@@ -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}
|
|
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}
|
|
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) {
|
package/bin/lean/schedd.test.mjs
CHANGED
|
@@ -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
|
+
});
|
package/bin/lean/sequence.mjs
CHANGED
|
@@ -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
|
+
});
|