atris 3.74.26 → 3.74.28
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/commands/rating.js +7 -2
- package/commands/task.js +116 -10
- package/commands/worktree.js +526 -19
- package/lib/auto-accept-certified.js +56 -0
- package/lib/campaign-keep.js +5 -1
- package/lib/fleet.js +357 -49
- package/lib/gm-season.js +45 -9
- package/lib/release-kind.js +27 -0
- package/lib/review-integrity.js +7 -0
- package/lib/roster-runs.js +11 -5
- package/lib/roster-suggest.js +3 -2
- package/lib/roster-swap.js +3 -1
- package/lib/router-brain.js +7 -6
- package/lib/task-db.js +113 -3
- package/lib/voice-gate.js +1 -0
- package/lib/worktree-space.js +357 -0
- package/package.json +1 -1
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Disk room and shared installs for agent build copies.
|
|
4
|
+
//
|
|
5
|
+
// Every web build copy used to run its own `npm ci`: 0.8 GB and 25 seconds
|
|
6
|
+
// each, measured 2026-10-30 on atrisos-web. Ten copies in a push filled the
|
|
7
|
+
// disk. On APFS a clone of the folder shares its blocks with the original,
|
|
8
|
+
// so a copy whose package-lock.json matches an existing install gets that
|
|
9
|
+
// install as a clone: about 2 seconds and 12 MB instead.
|
|
10
|
+
//
|
|
11
|
+
// The shared installs live under <workspace>/.agent-worktrees/.deps/<repo>/
|
|
12
|
+
// <lockfile hash>/node_modules. Nobody runs npm there, so a clone of one is
|
|
13
|
+
// always a complete install. The folder starts with a dot so tools that walk
|
|
14
|
+
// .agent-worktrees for repo names skip it.
|
|
15
|
+
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const { spawnSync } = require('child_process');
|
|
21
|
+
|
|
22
|
+
const GB = 1e9;
|
|
23
|
+
// A web copy peaks near 1.5 GB (0.7 GB installs, 0.45 GB .next, source), and
|
|
24
|
+
// a hard push runs about ten at once: below 25 GB free a push no longer fits,
|
|
25
|
+
// so start reaps finished copies first. Below 10 GB macOS itself starts to
|
|
26
|
+
// fail (swap, updates, builds), so start refuses to add another copy.
|
|
27
|
+
const DEFAULT_REAP_BELOW_GB = 25;
|
|
28
|
+
const DEFAULT_REFUSE_BELOW_GB = 10;
|
|
29
|
+
const DEPS_CACHE_KEEP = 3;
|
|
30
|
+
const PARTIAL_MAX_AGE_MS = 60 * 60 * 1000;
|
|
31
|
+
const AGENT_COPIES_DIR = '.agent-worktrees';
|
|
32
|
+
const DEPS_DIR = '.deps';
|
|
33
|
+
const CLONE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
// clonefile(2) on a folder clones the whole tree in one call: under 2
|
|
36
|
+
// seconds for the 45,000 files of the web install, where cp -c, file by
|
|
37
|
+
// file, takes 25 to 33. osascript ships with macOS, so no compiler or extra
|
|
38
|
+
// runtime is needed. It prints 0 on success.
|
|
39
|
+
const CLONEFILE_JXA = 'function run(argv) { ObjC.bindFunction("clonefile", ["int", ["char *", "char *", "unsigned int"]]); return String($.clonefile(argv[0], argv[1], 1)); }';
|
|
40
|
+
|
|
41
|
+
function formatGb(bytes) {
|
|
42
|
+
if (bytes === null || bytes === undefined || !Number.isFinite(bytes)) return 'unknown';
|
|
43
|
+
return `${(bytes / GB).toFixed(1)} GB`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Free bytes on the disk holding `dir`, walking up to the nearest folder that
|
|
47
|
+
// exists. null when the disk cannot be read.
|
|
48
|
+
function freeBytes(dir) {
|
|
49
|
+
let probe = path.resolve(dir || '.');
|
|
50
|
+
while (!fs.existsSync(probe)) {
|
|
51
|
+
const parent = path.dirname(probe);
|
|
52
|
+
if (parent === probe) return null;
|
|
53
|
+
probe = parent;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const stats = fs.statfsSync(probe);
|
|
57
|
+
return Number(stats.bavail) * Number(stats.bsize);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readGb(value, fallback) {
|
|
64
|
+
if (value === undefined || value === null || String(value).trim() === '') return fallback;
|
|
65
|
+
const number = Number(value);
|
|
66
|
+
return Number.isFinite(number) && number >= 0 ? number : fallback;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 0 turns a limit off.
|
|
70
|
+
function spaceLimits(env = process.env) {
|
|
71
|
+
return {
|
|
72
|
+
reapBelow: readGb(env.ATRIS_WORKTREE_REAP_BELOW_GB, DEFAULT_REAP_BELOW_GB) * GB,
|
|
73
|
+
refuseBelow: readGb(env.ATRIS_WORKTREE_MIN_FREE_GB, DEFAULT_REFUSE_BELOW_GB) * GB,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The real path, also for a folder not made yet: its nearest existing parent
|
|
78
|
+
// is resolved (macOS /var is really /private/var) and the rest appended.
|
|
79
|
+
function realOrResolved(value) {
|
|
80
|
+
const resolved = path.resolve(value);
|
|
81
|
+
const missing = [];
|
|
82
|
+
for (let probe = resolved; ;) {
|
|
83
|
+
try {
|
|
84
|
+
return path.join(fs.realpathSync(probe), ...missing.reverse());
|
|
85
|
+
} catch {
|
|
86
|
+
const parent = path.dirname(probe);
|
|
87
|
+
if (parent === probe) return resolved;
|
|
88
|
+
missing.push(path.basename(probe));
|
|
89
|
+
probe = parent;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isInside(child, parent) {
|
|
95
|
+
const a = realOrResolved(child);
|
|
96
|
+
const b = realOrResolved(parent);
|
|
97
|
+
return a === b || a.startsWith(`${b}${path.sep}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Temp folders hold test fixtures and throwaway snapshots. The space guard,
|
|
101
|
+
// shared installs, and removal after ship leave them alone, so tests that
|
|
102
|
+
// build copies in a temp folder behave as before.
|
|
103
|
+
function isScratchPath(value) {
|
|
104
|
+
const roots = [os.tmpdir(), '/tmp', '/private/tmp', '/var/folders', '/private/var/folders'];
|
|
105
|
+
return roots.some((root) => fs.existsSync(root) && isInside(value, root));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isAgentCopyPath(value) {
|
|
109
|
+
return path.resolve(value).split(path.sep).includes(AGENT_COPIES_DIR);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Repos that have build copies: <workspace>/.agent-worktrees/<name> next to a
|
|
113
|
+
// checkout at <workspace>/<name>.
|
|
114
|
+
function agentCopyRepos(workspace) {
|
|
115
|
+
const dir = path.join(workspace, AGENT_COPIES_DIR);
|
|
116
|
+
let entries = [];
|
|
117
|
+
try {
|
|
118
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
119
|
+
} catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
return entries
|
|
123
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
124
|
+
.map((entry) => ({ name: entry.name, root: path.join(workspace, entry.name), copies: path.join(dir, entry.name) }))
|
|
125
|
+
.filter((repo) => fs.existsSync(path.join(repo.root, '.git')))
|
|
126
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function lockHash(lockFile) {
|
|
130
|
+
return crypto.createHash('sha256').update(fs.readFileSync(lockFile)).digest('hex').slice(0, 16);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function readJson(file) {
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function pathExists(value) {
|
|
142
|
+
try {
|
|
143
|
+
fs.lstatSync(value);
|
|
144
|
+
return true;
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isRealDir(value) {
|
|
151
|
+
try {
|
|
152
|
+
return fs.lstatSync(value).isDirectory();
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// True when the install in `modulesDir` is exactly what `lockFile` asks for.
|
|
159
|
+
// npm records what it installed in node_modules/.package-lock.json. Optional
|
|
160
|
+
// and peer packages may be missing (other platforms); anything else missing,
|
|
161
|
+
// at another version, or extra means the install does not match. Workspaces
|
|
162
|
+
// and linked folders point outside node_modules, so a clone of node_modules
|
|
163
|
+
// alone would be wrong; those never match.
|
|
164
|
+
function installedMatchesLock(lockFile, modulesDir) {
|
|
165
|
+
const wanted = readJson(lockFile);
|
|
166
|
+
const installed = readJson(path.join(modulesDir, '.package-lock.json'));
|
|
167
|
+
if (!wanted || !installed || !wanted.packages || !installed.packages) return false;
|
|
168
|
+
for (const [key, meta] of Object.entries(wanted.packages)) {
|
|
169
|
+
if (key === '') continue;
|
|
170
|
+
if (!key.startsWith('node_modules/') || (meta && meta.link)) return false;
|
|
171
|
+
const got = installed.packages[key];
|
|
172
|
+
if (!got) {
|
|
173
|
+
if (meta && (meta.optional || meta.devOptional || meta.peer)) continue;
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
if (meta && meta.version && got.version !== meta.version) return false;
|
|
177
|
+
}
|
|
178
|
+
return Object.keys(installed.packages).every((key) => key === '' || Object.prototype.hasOwnProperty.call(wanted.packages, key));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function removeTree(target) {
|
|
182
|
+
try {
|
|
183
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Clones a folder without copying its data, or fails. Never falls back to a
|
|
188
|
+
// real copy: a full copy costs as much disk as the install it replaces, and
|
|
189
|
+
// cp -c silently does exactly that on a disk that cannot clone.
|
|
190
|
+
function cloneTree(src, dst, { platform = process.platform, run = spawnSync } = {}) {
|
|
191
|
+
try {
|
|
192
|
+
if (fs.statSync(src).dev !== fs.statSync(path.dirname(dst)).dev) return { ok: false, error: 'source is on another disk' };
|
|
193
|
+
} catch (error) {
|
|
194
|
+
return { ok: false, error: String((error && error.message) || error) };
|
|
195
|
+
}
|
|
196
|
+
if (platform === 'darwin') {
|
|
197
|
+
const whole = run('osascript', ['-l', 'JavaScript', '-e', CLONEFILE_JXA, src, dst], { encoding: 'utf8', timeout: CLONE_TIMEOUT_MS });
|
|
198
|
+
if (whole.status === 0 && String(whole.stdout || '').trim() === '0') return { ok: true, method: 'clonefile' };
|
|
199
|
+
removeTree(dst);
|
|
200
|
+
return { ok: false, error: 'this disk cannot clone folders' };
|
|
201
|
+
}
|
|
202
|
+
if (platform === 'linux') {
|
|
203
|
+
const perFile = run('cp', ['-R', '--reflink=always', src, dst], { encoding: 'utf8', timeout: CLONE_TIMEOUT_MS });
|
|
204
|
+
if (perFile.status === 0) return { ok: true, method: 'reflink' };
|
|
205
|
+
removeTree(dst);
|
|
206
|
+
return { ok: false, error: String(perFile.stderr || perFile.stdout || 'cp --reflink failed').trim() };
|
|
207
|
+
}
|
|
208
|
+
return { ok: false, error: `no folder clone on ${platform}` };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function depsRepoDir(workspace, repoName) {
|
|
212
|
+
return path.join(workspace, AGENT_COPIES_DIR, DEPS_DIR, repoName);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function depsCacheEntry(workspace, repoName, hash) {
|
|
216
|
+
return path.join(depsRepoDir(workspace, repoName), hash);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Makes sure the shared store holds an install for `hash`, cloning it from
|
|
220
|
+
// `sourceDir` (a matching node_modules) when it does not. Two starts racing
|
|
221
|
+
// each build their own partial entry; the first rename wins and the other
|
|
222
|
+
// is thrown away. Returns the cached node_modules path or null.
|
|
223
|
+
function ensureCachedInstall({ workspace, repoName, hash, lockFile, sourceDir, clone = cloneTree }) {
|
|
224
|
+
const entry = depsCacheEntry(workspace, repoName, hash);
|
|
225
|
+
const cached = path.join(entry, 'node_modules');
|
|
226
|
+
if (installedMatchesLock(lockFile, cached)) return cached;
|
|
227
|
+
if (!sourceDir || !installedMatchesLock(lockFile, sourceDir)) return null;
|
|
228
|
+
const partial = path.join(depsRepoDir(workspace, repoName), `.partial-${hash}-${process.pid}-${Date.now()}`);
|
|
229
|
+
try {
|
|
230
|
+
fs.mkdirSync(partial, { recursive: true });
|
|
231
|
+
const cloned = clone(sourceDir, path.join(partial, 'node_modules'));
|
|
232
|
+
if (!cloned.ok) return null;
|
|
233
|
+
// Build caches are not part of the install and only grow the store.
|
|
234
|
+
removeTree(path.join(partial, 'node_modules', '.cache'));
|
|
235
|
+
if (!installedMatchesLock(lockFile, path.join(partial, 'node_modules'))) return null;
|
|
236
|
+
// An entry that no longer matches its own lockfile was changed by hand.
|
|
237
|
+
if (pathExists(entry) && !installedMatchesLock(lockFile, cached)) removeTree(entry);
|
|
238
|
+
try {
|
|
239
|
+
fs.renameSync(partial, entry);
|
|
240
|
+
} catch {
|
|
241
|
+
// Another start stored the same install first.
|
|
242
|
+
}
|
|
243
|
+
return installedMatchesLock(lockFile, cached) ? cached : null;
|
|
244
|
+
} finally {
|
|
245
|
+
removeTree(partial);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function touch(target) {
|
|
250
|
+
try {
|
|
251
|
+
const now = new Date();
|
|
252
|
+
fs.utimesSync(target, now, now);
|
|
253
|
+
} catch {}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Keeps the `keep` most recently used installs per repo and drops partial
|
|
257
|
+
// entries a crashed start left behind.
|
|
258
|
+
function pruneDepsCache({ workspace, repoName, keep = DEPS_CACHE_KEEP, now = Date.now() } = {}) {
|
|
259
|
+
const dir = depsRepoDir(workspace, repoName);
|
|
260
|
+
let names = [];
|
|
261
|
+
try {
|
|
262
|
+
names = fs.readdirSync(dir);
|
|
263
|
+
} catch {
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
const removed = [];
|
|
267
|
+
const entries = [];
|
|
268
|
+
for (const name of names) {
|
|
269
|
+
const full = path.join(dir, name);
|
|
270
|
+
let mtimeMs = 0;
|
|
271
|
+
try {
|
|
272
|
+
mtimeMs = fs.statSync(full).mtimeMs;
|
|
273
|
+
} catch {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (name.startsWith('.partial-')) {
|
|
277
|
+
if (now - mtimeMs > PARTIAL_MAX_AGE_MS) {
|
|
278
|
+
removeTree(full);
|
|
279
|
+
removed.push(full);
|
|
280
|
+
}
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (!name.startsWith('.')) entries.push({ full, mtimeMs });
|
|
284
|
+
}
|
|
285
|
+
entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
286
|
+
for (const old of entries.slice(keep)) {
|
|
287
|
+
removeTree(old.full);
|
|
288
|
+
removed.push(old.full);
|
|
289
|
+
}
|
|
290
|
+
return removed;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Gives a new build copy its node_modules as a clone of a matching install,
|
|
294
|
+
// so the agent skips `npm ci`. Looks in the shared store first, then in the
|
|
295
|
+
// main checkout and other copies of the same repo. Never fails the start:
|
|
296
|
+
// any miss returns { status: 'skipped', reason } and the agent installs as
|
|
297
|
+
// before.
|
|
298
|
+
function seedNodeModules({ target, workspace, repoName, sources = [], clone = cloneTree, keep = DEPS_CACHE_KEEP, isIgnored = () => true } = {}) {
|
|
299
|
+
const startedAt = Date.now();
|
|
300
|
+
const result = (status, reason, extra = {}) => ({ status, reason, ms: Date.now() - startedAt, ...extra });
|
|
301
|
+
const lockFile = path.join(target, 'package-lock.json');
|
|
302
|
+
const modules = path.join(target, 'node_modules');
|
|
303
|
+
if (!fs.existsSync(lockFile)) return result('skipped', 'no package-lock.json');
|
|
304
|
+
if (pathExists(modules)) return result('skipped', 'node_modules already present');
|
|
305
|
+
if (!isIgnored(target)) return result('skipped', 'node_modules is not ignored by git here');
|
|
306
|
+
let hash;
|
|
307
|
+
try {
|
|
308
|
+
hash = lockHash(lockFile);
|
|
309
|
+
} catch {
|
|
310
|
+
return result('skipped', 'could not read package-lock.json');
|
|
311
|
+
}
|
|
312
|
+
let cached = path.join(depsCacheEntry(workspace, repoName, hash), 'node_modules');
|
|
313
|
+
if (!installedMatchesLock(lockFile, cached)) {
|
|
314
|
+
cached = null;
|
|
315
|
+
for (const source of sources) {
|
|
316
|
+
if (!source || realOrResolved(source) === realOrResolved(target)) continue;
|
|
317
|
+
const sourceLock = path.join(source, 'package-lock.json');
|
|
318
|
+
const sourceModules = path.join(source, 'node_modules');
|
|
319
|
+
if (!isRealDir(sourceModules)) continue;
|
|
320
|
+
try {
|
|
321
|
+
if (lockHash(sourceLock) !== hash) continue;
|
|
322
|
+
} catch {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
cached = ensureCachedInstall({ workspace, repoName, hash, lockFile, sourceDir: sourceModules, clone });
|
|
326
|
+
if (cached) break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (!cached) return result('skipped', 'no matching install to clone yet; run npm ci, and later copies will clone it', { hash });
|
|
330
|
+
// Mark the entry used before cloning so a parallel prune keeps it.
|
|
331
|
+
touch(path.dirname(cached));
|
|
332
|
+
const cloned = clone(cached, modules);
|
|
333
|
+
if (!cloned.ok || !installedMatchesLock(lockFile, modules)) {
|
|
334
|
+
removeTree(modules);
|
|
335
|
+
return result('skipped', `clone failed: ${cloned.error || 'install did not match after cloning'}`, { hash });
|
|
336
|
+
}
|
|
337
|
+
pruneDepsCache({ workspace, repoName, keep });
|
|
338
|
+
return result('cloned', '', { hash, method: cloned.method, source: cached });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = {
|
|
342
|
+
AGENT_COPIES_DIR,
|
|
343
|
+
GB,
|
|
344
|
+
agentCopyRepos,
|
|
345
|
+
cloneTree,
|
|
346
|
+
depsCacheEntry,
|
|
347
|
+
formatGb,
|
|
348
|
+
freeBytes,
|
|
349
|
+
installedMatchesLock,
|
|
350
|
+
isAgentCopyPath,
|
|
351
|
+
isInside,
|
|
352
|
+
isScratchPath,
|
|
353
|
+
lockHash,
|
|
354
|
+
pruneDepsCache,
|
|
355
|
+
seedNodeModules,
|
|
356
|
+
spaceLimits,
|
|
357
|
+
};
|
package/package.json
CHANGED