@codedology/code-forge 0.1.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/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/code-forge.mjs +145 -0
- package/docs/README.md +37 -0
- package/docs/reference/blocks.json +288 -0
- package/docs/reference/config.md +162 -0
- package/examples/first-use/README.md +11 -0
- package/examples/first-use/clamp.acceptance.yml +8 -0
- package/examples/first-use/clamp.brief.md +16 -0
- package/examples/first-use/coder/src/math.mjs +31 -0
- package/examples/first-use/coder/test/clamp.test.mjs +26 -0
- package/examples/node-lib/.env.example +3 -0
- package/examples/node-lib/.gitattributes +3 -0
- package/examples/node-lib/.github/workflows/test.yml +11 -0
- package/examples/node-lib/CHANGELOG.md +13 -0
- package/examples/node-lib/README.md +17 -0
- package/examples/node-lib/gitignore +3 -0
- package/examples/node-lib/package.json +19 -0
- package/examples/node-lib/src/list.mjs +23 -0
- package/examples/node-lib/src/math.mjs +21 -0
- package/examples/node-lib/src/strings.mjs +34 -0
- package/examples/node-lib/test/list.test.mjs +12 -0
- package/examples/node-lib/test/math.test.mjs +13 -0
- package/examples/node-lib/test/package.test.mjs +18 -0
- package/examples/node-lib/test/strings.test.mjs +28 -0
- package/package.json +52 -0
- package/schema/code-forge.schema.json +331 -0
- package/schema/facts.schema.json +28 -0
- package/scripts/first-use.mjs +517 -0
- package/scripts/gen-config-doc.mjs +241 -0
- package/skill/SKILL.md +79 -0
- package/skill/references/adapters/claude-code.md +19 -0
- package/skill/references/adapters/solo.md +34 -0
- package/skill/references/adapters/subprocess.md +15 -0
- package/skill/references/code.md +64 -0
- package/skill/references/continuity.md +31 -0
- package/skill/references/decisions.md +50 -0
- package/skill/references/degraded.md +40 -0
- package/skill/references/harden.md +26 -0
- package/skill/references/ledger.md +23 -0
- package/skill/references/plan.md +43 -0
- package/skill/references/proof.md +34 -0
- package/skill/references/review.md +42 -0
- package/skill/references/rules-core.md +72 -0
- package/skill/references/security.md +29 -0
- package/skill/scripts/forge +40 -0
- package/skill/templates/block-record.md +19 -0
- package/skill/templates/facts-sheet.md +22 -0
- package/skill/templates/plan.md +53 -0
- package/src/cli/author.mjs +87 -0
- package/src/cli/block.mjs +262 -0
- package/src/cli/doctor.mjs +74 -0
- package/src/cli/facts.mjs +124 -0
- package/src/cli/gates.mjs +280 -0
- package/src/cli/help.mjs +48 -0
- package/src/cli/init.mjs +14 -0
- package/src/cli/jev.mjs +175 -0
- package/src/cli/keys.mjs +170 -0
- package/src/cli/ledger.mjs +160 -0
- package/src/cli/list.mjs +68 -0
- package/src/cli/models.mjs +131 -0
- package/src/cli/plan.mjs +70 -0
- package/src/cli/proof.mjs +316 -0
- package/src/cli/remove.mjs +76 -0
- package/src/cli/report.mjs +73 -0
- package/src/cli/resolve.mjs +79 -0
- package/src/cli/review-file.mjs +152 -0
- package/src/cli/run.mjs +159 -0
- package/src/cli/s2.mjs +93 -0
- package/src/cli/spawn.mjs +104 -0
- package/src/cli/upgrade.mjs +103 -0
- package/src/cli/validate.mjs +108 -0
- package/src/cli/version.mjs +18 -0
- package/src/cli/worker.mjs +76 -0
- package/src/config/defaults/anthropic.mjs +18 -0
- package/src/config/defaults/index.mjs +24 -0
- package/src/config/defaults/openai.mjs +16 -0
- package/src/config/defaults/xai.mjs +18 -0
- package/src/config/known-ids.mjs +171 -0
- package/src/config/load.mjs +135 -0
- package/src/config/migrate.mjs +46 -0
- package/src/config/refresh.mjs +276 -0
- package/src/config/schema-compile.mjs +211 -0
- package/src/config/schema-paths.mjs +84 -0
- package/src/config/secret-patterns.mjs +85 -0
- package/src/config/validate.mjs +694 -0
- package/src/decide/author-loop.mjs +90 -0
- package/src/decide/escalation.mjs +262 -0
- package/src/decide/fallback-rules.mjs +172 -0
- package/src/decide/jev-client.mjs +216 -0
- package/src/decide/questions.mjs +161 -0
- package/src/decide/thresholds.mjs +199 -0
- package/src/doctor/index.mjs +105 -0
- package/src/doctor/local.mjs +162 -0
- package/src/doctor/probes.mjs +358 -0
- package/src/doctor/rows.mjs +51 -0
- package/src/doctor/worker-probe.mjs +125 -0
- package/src/engines/alias-map.mjs +78 -0
- package/src/engines/builders/claude.mjs +224 -0
- package/src/engines/builders/codex.mjs +242 -0
- package/src/engines/builders/grok.mjs +132 -0
- package/src/engines/builders/index.mjs +35 -0
- package/src/engines/builders/validate-params.mjs +34 -0
- package/src/engines/codex-home.mjs +230 -0
- package/src/engines/isolation.mjs +84 -0
- package/src/engines/probe.mjs +116 -0
- package/src/engines/provider-cli.mjs +24 -0
- package/src/engines/render-rules.mjs +41 -0
- package/src/engines/sentinel.mjs +133 -0
- package/src/engines/usage-parse.mjs +149 -0
- package/src/gates/acceptance.mjs +95 -0
- package/src/gates/detect.mjs +187 -0
- package/src/gates/invariants.mjs +43 -0
- package/src/gates/run.mjs +72 -0
- package/src/gates/safe-edit.mjs +266 -0
- package/src/gates/scope.mjs +154 -0
- package/src/gates/secret-scan.mjs +79 -0
- package/src/gates/transcript-grep.mjs +27 -0
- package/src/install/agent-env.mjs +76 -0
- package/src/install/detect.mjs +121 -0
- package/src/install/harnesses.mjs +131 -0
- package/src/install/link.mjs +463 -0
- package/src/install/wizard/answers.mjs +389 -0
- package/src/install/wizard/flags.mjs +248 -0
- package/src/install/wizard/project-file.mjs +128 -0
- package/src/install/wizard/run.mjs +366 -0
- package/src/install/wizard/steps.mjs +201 -0
- package/src/keys/backends/constants.mjs +7 -0
- package/src/keys/backends/file.mjs +99 -0
- package/src/keys/backends/keychain.mjs +81 -0
- package/src/keys/backends/security-cli.mjs +105 -0
- package/src/keys/onepassword.mjs +46 -0
- package/src/keys/store.mjs +347 -0
- package/src/ledger/calibration.mjs +109 -0
- package/src/ledger/export.mjs +52 -0
- package/src/ledger/outcome.mjs +397 -0
- package/src/ledger/paths.mjs +30 -0
- package/src/ledger/prices.mjs +49 -0
- package/src/ledger/report.mjs +250 -0
- package/src/ledger/write.mjs +279 -0
- package/src/proof/export.mjs +358 -0
- package/src/proof/lock.mjs +138 -0
- package/src/proof/red-green.mjs +519 -0
- package/src/proof/red-parse.mjs +103 -0
- package/src/proof/tiers.mjs +62 -0
- package/src/review/budget.mjs +241 -0
- package/src/review/context.mjs +156 -0
- package/src/review/engine.mjs +264 -0
- package/src/review/finding.schema.json +47 -0
- package/src/review/fixloop.mjs +467 -0
- package/src/review/gate-check.mjs +249 -0
- package/src/review/lenses/A.md +22 -0
- package/src/review/lenses/B.md +23 -0
- package/src/review/lenses/full.md +23 -0
- package/src/review/lenses/judge.md +21 -0
- package/src/review/lenses/quick.md +21 -0
- package/src/review/lenses/recheck.md +23 -0
- package/src/review/packet.mjs +355 -0
- package/src/review/triage.mjs +149 -0
- package/src/review/validate-review.mjs +70 -0
- package/src/session/author.mjs +228 -0
- package/src/session/facts.mjs +855 -0
- package/src/session/plan-check.mjs +294 -0
- package/src/session/s2.mjs +164 -0
- package/src/session/spawn.mjs +610 -0
- package/src/state/block.mjs +289 -0
- package/src/state/cli-args.mjs +67 -0
- package/src/state/paths.mjs +92 -0
- package/src/state/registry.mjs +189 -0
- package/src/state/run.mjs +266 -0
- package/src/state/signer.mjs +132 -0
- package/src/util/exec.mjs +315 -0
- package/src/util/forbidden.mjs +787 -0
- package/src/util/git.mjs +381 -0
- package/src/util/log.mjs +100 -0
- package/src/util/reaper.mjs +300 -0
- package/src/util/redact.mjs +212 -0
- package/src/util/tmp.mjs +409 -0
- package/src/worker/engine.mjs +338 -0
- package/src/worker/loop.mjs +392 -0
- package/src/worker/queue.mjs +186 -0
- package/src/worker/review-state.mjs +145 -0
- package/src/worker/ticket.mjs +195 -0
package/src/util/tmp.mjs
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-run temp root (plan §9.6 rule 2, V12).
|
|
3
|
+
*
|
|
4
|
+
* Every temp path the package creates lives under ONE directory per run:
|
|
5
|
+
* `<base>/<run-id>/`, where `<base>` is the `tmp.root` config value when the caller passes it and
|
|
6
|
+
* `<os.tmpdir()>/code-forge` otherwise. The run root holds `owner.json` (`{pid, start_time,
|
|
7
|
+
* created_at}` of the process that created it) and the pid registry `pids/` that `exec` fills.
|
|
8
|
+
* A killed run therefore leaves one directory, not thousands, and the next `run start` (or the
|
|
9
|
+
* test preload) calls `sweepRoots()`, which reaps the dead run's registered children
|
|
10
|
+
* (`./reaper.mjs`) and removes the directory. A root whose owner is alive, or whose owner cannot
|
|
11
|
+
* be read, is kept.
|
|
12
|
+
*
|
|
13
|
+
* Trust rule: the base directory and every run root must be a real directory (not a symlink)
|
|
14
|
+
* owned by this user and not writable by group or others. On a shared `/tmp` another user could
|
|
15
|
+
* otherwise plant a root with `pids/` entries and make the reaper signal our processes. `runRoot`
|
|
16
|
+
* throws on an untrusted directory; `sweepRoots` reports it as `unknown` and never reaps it.
|
|
17
|
+
* `owner.json` is written atomically (temp file in the root, then rename), so a parallel sweep
|
|
18
|
+
* reads either the previous owner record or the new one, never a truncated one. An owner file
|
|
19
|
+
* that exists but cannot be parsed, or names an invalid pid, is `unknown` and never reaped. Only a
|
|
20
|
+
* root with NO owner file (ENOENT) uses the ownerless path: it is removed once nothing a live run
|
|
21
|
+
* touches (the root, `pids/`, its entries) has changed for `ownerlessGraceMs` (a root being
|
|
22
|
+
* created right now has no owner file for a moment).
|
|
23
|
+
*
|
|
24
|
+
* Claim rule: a sweep never reaps a root in place. It first renames the root to a tombstone
|
|
25
|
+
* (`.reaping-<random>`, a name no run id can ever take: RUN_ID rejects a leading `.`), re-reads
|
|
26
|
+
* the owner on the renamed path, and only then reaps and removes it. A run that re-adopted the
|
|
27
|
+
* same run id in between (a resumed run) therefore either owns a fresh directory (its
|
|
28
|
+
* `runRoot` retries after the rename) or is found alive on the tombstone, which is put back.
|
|
29
|
+
*
|
|
30
|
+
* A process that never picked a run root gets a lazy one (`proc-<pid>-<ms>`) the first time
|
|
31
|
+
* `currentRunRoot()` is called, so `exec` always has somewhere to register its children. The lazy
|
|
32
|
+
* root lives under the base named by `CODE_FORGE_TMP_ROOT` when that is an absolute path (the CLI
|
|
33
|
+
* router sets it from the project's `tmp.root`, B19), else under `<os.tmpdir()>/code-forge`. On a
|
|
34
|
+
* normal process exit the lazy root is removed when its `pids/` holds no live entry
|
|
35
|
+
* (`releaseLazyRoot`); a root with a live child, or an entry it cannot read, is kept for the sweep.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { randomBytes } from 'node:crypto';
|
|
39
|
+
import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
40
|
+
import os from 'node:os';
|
|
41
|
+
import path from 'node:path';
|
|
42
|
+
import { isAlive, listEntries, readStartTime, sweep, UNKNOWN_START_TIME } from './reaper.mjs';
|
|
43
|
+
|
|
44
|
+
/** Name of the directory under `os.tmpdir()` used when no `tmp.root` is configured. */
|
|
45
|
+
export const DEFAULT_BASE_NAME = 'code-forge';
|
|
46
|
+
|
|
47
|
+
/** File inside a run root naming the process that owns it. */
|
|
48
|
+
export const OWNER_FILE = 'owner.json';
|
|
49
|
+
|
|
50
|
+
/** A run id is one plain path segment: no separators, no `.`/`..`. */
|
|
51
|
+
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
52
|
+
|
|
53
|
+
/** An ownerless root younger than this is being created, not abandoned. */
|
|
54
|
+
const DEFAULT_OWNERLESS_GRACE_MS = 10 * 60 * 1000;
|
|
55
|
+
|
|
56
|
+
/** Prefix of a root claimed by a sweep; starts with `.` so `RUN_ID` can never produce it. */
|
|
57
|
+
const TOMBSTONE_PREFIX = '.reaping-';
|
|
58
|
+
|
|
59
|
+
/** Environment variable naming the base directory when no `root` is passed (the CLI's `tmp.root`). */
|
|
60
|
+
export const TMP_ROOT_ENV = 'CODE_FORGE_TMP_ROOT';
|
|
61
|
+
|
|
62
|
+
/** @type {string | null} */
|
|
63
|
+
let current = null;
|
|
64
|
+
|
|
65
|
+
/** The lazy root this process created (`proc-<pid>-<ms>`), or null. */
|
|
66
|
+
/** @type {string | null} */
|
|
67
|
+
let lazy = null;
|
|
68
|
+
let exitHooked = false;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Why `dir` is not a trusted directory, or null when it is: must exist, not be a symlink, be
|
|
72
|
+
* owned by this user and have none of the `mask` permission bits set (default: no group/other
|
|
73
|
+
* write bit; pass `0o077` for a directory that must be private).
|
|
74
|
+
* @param {string} dir
|
|
75
|
+
* @param {number} [mask]
|
|
76
|
+
* @returns {string | null}
|
|
77
|
+
*/
|
|
78
|
+
export function untrustedReason(dir, mask = 0o022) {
|
|
79
|
+
let st;
|
|
80
|
+
try {
|
|
81
|
+
st = lstatSync(dir);
|
|
82
|
+
} catch {
|
|
83
|
+
return 'missing';
|
|
84
|
+
}
|
|
85
|
+
if (st.isSymbolicLink()) return 'symlink';
|
|
86
|
+
if (!st.isDirectory()) return 'not a directory';
|
|
87
|
+
if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return 'foreign owner';
|
|
88
|
+
if ((st.mode & mask) !== 0) return mask === 0o022 ? 'group/other writable' : 'group/other accessible';
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Write `<dir>/owner.json` atomically: a fresh temp file in the same directory, then rename over
|
|
94
|
+
* the final name. On any failure the temp file is removed, so no partial file is ever left.
|
|
95
|
+
* @param {string} dir
|
|
96
|
+
* @param {{pid: number, start_time: string, created_at: string}} owner
|
|
97
|
+
*/
|
|
98
|
+
function writeOwnerAtomic(dir, owner) {
|
|
99
|
+
const temp = path.join(dir, `.${OWNER_FILE}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
|
|
100
|
+
try {
|
|
101
|
+
writeFileSync(temp, `${JSON.stringify(owner)}\n`, { mode: 0o600, flag: 'wx' });
|
|
102
|
+
renameSync(temp, path.join(dir, OWNER_FILE));
|
|
103
|
+
} catch (err) {
|
|
104
|
+
rmSync(temp, { force: true });
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @param {string} dir @param {string} what */
|
|
110
|
+
function assertTrusted(dir, what) {
|
|
111
|
+
const reason = untrustedReason(dir);
|
|
112
|
+
if (reason !== null) throw new Error(`tmp: refusing untrusted ${what} (${reason}): ${dir}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The directory run roots live in.
|
|
117
|
+
* @param {string} [root] - the `tmp.root` config value; must be absolute when given.
|
|
118
|
+
* @returns {string}
|
|
119
|
+
*/
|
|
120
|
+
export function tmpBase(root) {
|
|
121
|
+
if (root === undefined || root === null) {
|
|
122
|
+
const fromEnv = process.env[TMP_ROOT_ENV];
|
|
123
|
+
if (typeof fromEnv === 'string' && path.isAbsolute(fromEnv)) return fromEnv;
|
|
124
|
+
return path.join(os.tmpdir(), DEFAULT_BASE_NAME);
|
|
125
|
+
}
|
|
126
|
+
if (typeof root !== 'string' || !path.isAbsolute(root)) {
|
|
127
|
+
throw new TypeError('tmp: tmp.root must be an absolute path');
|
|
128
|
+
}
|
|
129
|
+
return root;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create (if needed) the run root `<base>/<runId>/`, record this process as its owner, and
|
|
134
|
+
* return its real absolute path.
|
|
135
|
+
* @param {string} runId
|
|
136
|
+
* @param {{root?: string}} [opts]
|
|
137
|
+
* @returns {string}
|
|
138
|
+
*/
|
|
139
|
+
export function runRoot(runId, opts = {}) {
|
|
140
|
+
if (typeof runId !== 'string' || !RUN_ID.test(runId)) {
|
|
141
|
+
throw new TypeError('tmp: runId must be one path segment of letters, digits, ".", "_" or "-"');
|
|
142
|
+
}
|
|
143
|
+
const base = tmpBase(opts.root);
|
|
144
|
+
const dir = path.join(base, runId);
|
|
145
|
+
// A parallel sweep that claimed a dead root of this name renames it away (see the claim rule)
|
|
146
|
+
// between our mkdir and our write: the directory is then "missing" or the write fails with
|
|
147
|
+
// ENOENT, and the retry creates a fresh directory in its place. A tombstone itself can never be
|
|
148
|
+
// adopted here: its name starts with `.`, which RUN_ID rejects above.
|
|
149
|
+
for (let attempt = 0; ; attempt++) {
|
|
150
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
151
|
+
assertTrusted(base, 'tmp base');
|
|
152
|
+
const reason = untrustedReason(dir);
|
|
153
|
+
if (reason === 'missing' && attempt < 2) continue;
|
|
154
|
+
if (reason !== null) throw new Error(`tmp: refusing untrusted run root (${reason}): ${dir}`);
|
|
155
|
+
try {
|
|
156
|
+
writeOwnerAtomic(dir, {
|
|
157
|
+
pid: process.pid,
|
|
158
|
+
start_time: readStartTime(process.pid) ?? UNKNOWN_START_TIME,
|
|
159
|
+
created_at: new Date().toISOString(),
|
|
160
|
+
});
|
|
161
|
+
return realpathSync(dir);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code !== 'ENOENT' || attempt >= 2) throw err;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Make `dir` this process's run root (`null` forgets it; the next `currentRunRoot()` makes a lazy one).
|
|
170
|
+
* @param {string | null} dir
|
|
171
|
+
*/
|
|
172
|
+
export function setRunRoot(dir) {
|
|
173
|
+
if (dir !== null && (typeof dir !== 'string' || !path.isAbsolute(dir))) {
|
|
174
|
+
throw new TypeError('tmp: a run root must be an absolute path');
|
|
175
|
+
}
|
|
176
|
+
current = dir;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** @returns {string} this process's run root, created lazily as `proc-<pid>-<ms>`. */
|
|
180
|
+
export function currentRunRoot() {
|
|
181
|
+
if (current === null) {
|
|
182
|
+
current = runRoot(`proc-${process.pid}-${Date.now()}`);
|
|
183
|
+
lazy = current;
|
|
184
|
+
if (!exitHooked) {
|
|
185
|
+
exitHooked = true;
|
|
186
|
+
process.once('exit', () => {
|
|
187
|
+
releaseLazyRoot();
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return current;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Remove this process's lazy root when its pid registry holds no live entry: every file in
|
|
196
|
+
* `pids/` must be a well-formed entry whose pid is dead. Anything else (a live child, an entry
|
|
197
|
+
* that cannot be read) keeps the root for the next sweep. Called from the `exit` hook, so it NEVER
|
|
198
|
+
* throws: any error keeps the root and returns false.
|
|
199
|
+
* @param {{rmSync?: typeof rmSync, listEntries?: typeof listEntries}} [deps] - test seams.
|
|
200
|
+
* @returns {boolean} true when the lazy root was removed.
|
|
201
|
+
*/
|
|
202
|
+
export function releaseLazyRoot(deps = {}) {
|
|
203
|
+
try {
|
|
204
|
+
if (lazy === null) return false;
|
|
205
|
+
const dir = lazy;
|
|
206
|
+
const pids = path.join(dir, 'pids');
|
|
207
|
+
const names = safeReaddir(pids);
|
|
208
|
+
const entries = (deps.listEntries ?? listEntries)(pids);
|
|
209
|
+
if (entries.length !== names.length || entries.some((e) => isAlive(e.pid))) return false;
|
|
210
|
+
(deps.rmSync ?? rmSync)(dir, { recursive: true, force: true });
|
|
211
|
+
if (current === dir) current = null;
|
|
212
|
+
lazy = null;
|
|
213
|
+
return true;
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The pid-registry directory of a run root.
|
|
221
|
+
* @param {string} [root] - defaults to `currentRunRoot()`.
|
|
222
|
+
* @returns {string}
|
|
223
|
+
*/
|
|
224
|
+
export function pidsDir(root) {
|
|
225
|
+
return path.join(root ?? currentRunRoot(), 'pids');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The state of a run root's recorded owner:
|
|
230
|
+
* - `alive`: the recorded pid is a live process with the recorded start time;
|
|
231
|
+
* - `dead`: gone, or its pid now names another process;
|
|
232
|
+
* - `missing`: there is no owner file (ENOENT) — the root is being created, or was abandoned;
|
|
233
|
+
* - `invalid`: an owner file exists but is unreadable, not JSON, or names an invalid pid.
|
|
234
|
+
* @param {string} dir
|
|
235
|
+
* @returns {'alive' | 'dead' | 'missing' | 'invalid'}
|
|
236
|
+
*/
|
|
237
|
+
export function ownerState(dir) {
|
|
238
|
+
let text;
|
|
239
|
+
try {
|
|
240
|
+
text = readFileSync(path.join(dir, OWNER_FILE), 'utf8');
|
|
241
|
+
} catch (err) {
|
|
242
|
+
return /** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT' ? 'missing' : 'invalid';
|
|
243
|
+
}
|
|
244
|
+
let owner;
|
|
245
|
+
try {
|
|
246
|
+
owner = JSON.parse(text);
|
|
247
|
+
} catch {
|
|
248
|
+
return 'invalid';
|
|
249
|
+
}
|
|
250
|
+
if (!owner || !Number.isInteger(owner.pid) || owner.pid <= 1) return 'invalid';
|
|
251
|
+
if (!isAlive(owner.pid)) return 'dead';
|
|
252
|
+
if (typeof owner.start_time === 'string' && owner.start_time !== UNKNOWN_START_TIME) {
|
|
253
|
+
const live = readStartTime(owner.pid);
|
|
254
|
+
if (live !== null && live !== owner.start_time) return 'dead';
|
|
255
|
+
}
|
|
256
|
+
return 'alive';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Whether the recorded owner of a run root is still the same live process.
|
|
261
|
+
* `true` = alive, `false` = dead, `null` = unknown (owner file missing or invalid).
|
|
262
|
+
* @param {string} dir
|
|
263
|
+
* @returns {boolean | null}
|
|
264
|
+
*/
|
|
265
|
+
export function ownerAlive(dir) {
|
|
266
|
+
const state = ownerState(dir);
|
|
267
|
+
return state === 'alive' ? true : state === 'dead' ? false : null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Remove sibling run roots whose owner is dead, after reaping their registered children.
|
|
272
|
+
* This process's own root is never touched; untrusted roots (and every root of an untrusted
|
|
273
|
+
* base) and roots with an invalid owner file are `unknown` and never reaped; a root with no
|
|
274
|
+
* owner file is removed only once its last activity is older than `ownerlessGraceMs`. Every
|
|
275
|
+
* reap goes through the claim rule (rename to a tombstone, re-check, then reap); tombstones
|
|
276
|
+
* left by a killed sweep are visited whatever the `prefix`.
|
|
277
|
+
* @param {{root?: string, prefix?: string, graceMs?: number, ownerlessGraceMs?: number}} [opts] -
|
|
278
|
+
* `root` is the base directory (default `tmpBase()`); `prefix` limits the sweep to entries
|
|
279
|
+
* starting with it.
|
|
280
|
+
* @returns {Promise<{removed: string[], kept: string[], unknown: string[]}>} absolute paths.
|
|
281
|
+
*/
|
|
282
|
+
export async function sweepRoots(opts = {}) {
|
|
283
|
+
const base = opts.root ?? tmpBase();
|
|
284
|
+
const prefix = opts.prefix ?? '';
|
|
285
|
+
const ownerlessGraceMs = opts.ownerlessGraceMs ?? DEFAULT_OWNERLESS_GRACE_MS;
|
|
286
|
+
/** @type {{removed: string[], kept: string[], unknown: string[]}} */
|
|
287
|
+
const out = { removed: [], kept: [], unknown: [] };
|
|
288
|
+
/** @type {import('node:fs').Dirent[]} */
|
|
289
|
+
let entries;
|
|
290
|
+
try {
|
|
291
|
+
entries = readdirSync(base, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
292
|
+
} catch {
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
const own = current === null ? null : path.resolve(current);
|
|
296
|
+
const baseTrusted = untrustedReason(base) === null;
|
|
297
|
+
for (const entry of entries) {
|
|
298
|
+
if (!(entry.name.startsWith(prefix) || entry.name.startsWith(TOMBSTONE_PREFIX))) continue;
|
|
299
|
+
if (!(entry.isDirectory() || entry.isSymbolicLink())) continue;
|
|
300
|
+
const dir = path.join(base, entry.name);
|
|
301
|
+
if (own !== null && (dir === own || safeRealpath(dir) === own)) continue;
|
|
302
|
+
if (!baseTrusted || untrustedReason(dir) !== null) {
|
|
303
|
+
out.unknown.push(dir);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const state = ownerState(dir);
|
|
307
|
+
if (state === 'alive') {
|
|
308
|
+
out.kept.push(dir);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (state !== 'dead' && !(state === 'missing' && Date.now() - lastActivityMs(dir) > ownerlessGraceMs)) {
|
|
312
|
+
out.unknown.push(dir);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
// Claim, then re-check on the claimed path: a run that re-adopted this run id between the
|
|
316
|
+
// check above and the rename is alive on the tombstone and gets its root back, untouched.
|
|
317
|
+
const tomb = path.join(base, `${TOMBSTONE_PREFIX}${randomBytes(6).toString('hex')}`);
|
|
318
|
+
try {
|
|
319
|
+
renameSync(dir, tomb);
|
|
320
|
+
} catch {
|
|
321
|
+
continue; // already gone (removed by another sweep)
|
|
322
|
+
}
|
|
323
|
+
if (ownerState(tomb) === 'alive') {
|
|
324
|
+
unclaim(tomb, dir);
|
|
325
|
+
out.kept.push(dir);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
await sweep(path.join(tomb, 'pids'), { graceMs: opts.graceMs });
|
|
329
|
+
rmSync(tomb, { recursive: true, force: true });
|
|
330
|
+
out.removed.push(dir);
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Undo a claim: put the tombstone back at `dir`. When the re-adopting run already re-created
|
|
337
|
+
* `dir` (its first spawn makes `pids/` again), the rename fails; its owner record and pid
|
|
338
|
+
* entries are then moved into the re-created directory and the tombstone is removed, so the
|
|
339
|
+
* live run's root is never left ownerless.
|
|
340
|
+
* @param {string} tomb
|
|
341
|
+
* @param {string} dir
|
|
342
|
+
*/
|
|
343
|
+
function unclaim(tomb, dir) {
|
|
344
|
+
try {
|
|
345
|
+
renameSync(tomb, dir);
|
|
346
|
+
return;
|
|
347
|
+
} catch {
|
|
348
|
+
// `dir` exists again and is not empty: merge below
|
|
349
|
+
}
|
|
350
|
+
const pids = path.join(dir, 'pids');
|
|
351
|
+
mkdirSync(pids, { recursive: true, mode: 0o700 });
|
|
352
|
+
for (const name of safeReaddir(path.join(tomb, 'pids'))) {
|
|
353
|
+
try {
|
|
354
|
+
renameSync(path.join(tomb, 'pids', name), path.join(pids, name));
|
|
355
|
+
} catch {
|
|
356
|
+
// unregistered meanwhile
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
renameSync(path.join(tomb, OWNER_FILE), path.join(dir, OWNER_FILE));
|
|
361
|
+
} catch {
|
|
362
|
+
// no owner record on the tombstone
|
|
363
|
+
}
|
|
364
|
+
rmSync(tomb, { recursive: true, force: true });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** @param {string} p @returns {string[]} entry names, or [] when unreadable. */
|
|
368
|
+
function safeReaddir(p) {
|
|
369
|
+
try {
|
|
370
|
+
return readdirSync(p);
|
|
371
|
+
} catch {
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** @param {string} p @returns {string | null} */
|
|
377
|
+
function safeRealpath(p) {
|
|
378
|
+
try {
|
|
379
|
+
return realpathSync(p);
|
|
380
|
+
} catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** @param {string} p @returns {number | null} modification time, or null when unreadable. */
|
|
386
|
+
function mtimeMs(p) {
|
|
387
|
+
try {
|
|
388
|
+
return lstatSync(p).mtimeMs;
|
|
389
|
+
} catch {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* When a run last touched this root: the newest mtime of the root itself, its `pids/` directory
|
|
396
|
+
* and every entry in it (each spawn writes one). An unreadable root counts as touched now.
|
|
397
|
+
* @param {string} dir
|
|
398
|
+
* @returns {number}
|
|
399
|
+
*/
|
|
400
|
+
function lastActivityMs(dir) {
|
|
401
|
+
let newest = mtimeMs(dir);
|
|
402
|
+
if (newest === null) return Date.now();
|
|
403
|
+
const pids = path.join(dir, 'pids');
|
|
404
|
+
for (const p of [pids, ...safeReaddir(pids).map((name) => path.join(pids, name))]) {
|
|
405
|
+
const m = mtimeMs(p);
|
|
406
|
+
if (m !== null && m > newest) newest = m;
|
|
407
|
+
}
|
|
408
|
+
return newest;
|
|
409
|
+
}
|