@codedology/code-forge 0.15.2 → 0.15.4
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 +81 -0
- package/README.md +5 -1
- package/docs/README.md +2 -0
- package/docs/reference/blocks.json +162 -1
- package/docs/reference/config.md +38 -15
- package/native/README.md +10 -0
- package/native/bin/darwin-arm64/code-forge-native +0 -0
- package/native/bin/darwin-x64/code-forge-native +0 -0
- package/native/bin/linux-arm64/code-forge-native +0 -0
- package/native/bin/linux-x64/code-forge-native +0 -0
- package/package.json +4 -1
- package/schema/code-forge.schema.json +83 -3
- package/schema/outcome.schema.json +45 -0
- package/schema/pipeline.schema.json +30 -0
- package/scripts/first-use.mjs +8 -0
- package/scripts/gen-config-doc.mjs +1 -1
- package/skill/references/autopilot.md +6 -1
- package/skill/references/code.md +52 -0
- package/skill/references/ledger.md +13 -1
- package/skill/references/plan.md +5 -1
- package/skill/references/review.md +46 -8
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +427 -38
- package/src/cli/context.mjs +17 -7
- package/src/cli/doctor.mjs +32 -1
- package/src/cli/gates.mjs +76 -10
- package/src/cli/help.mjs +5 -0
- package/src/cli/native-status.mjs +13 -0
- package/src/cli/outcome.mjs +87 -0
- package/src/cli/pipeline.mjs +61 -0
- package/src/cli/proof.mjs +10 -7
- package/src/cli/report.mjs +53 -6
- package/src/cli/review-file.mjs +278 -41
- package/src/cli/run.mjs +3 -2
- package/src/cli/status.mjs +43 -9
- package/src/cli/sweep.mjs +28 -0
- package/src/cli/validate.mjs +6 -0
- package/src/config/validate.mjs +83 -1
- package/src/context/pack.mjs +53 -3
- package/src/doctor/index.mjs +11 -0
- package/src/doctor/local.mjs +22 -4
- package/src/doctor/worker-probe.mjs +1 -1
- package/src/engines/builders/codex.mjs +27 -7
- package/src/engines/builders/grok.mjs +11 -5
- package/src/engines/codex-home.mjs +18 -15
- package/src/engines/sentinel.mjs +11 -0
- package/src/gates/detect.mjs +6 -3
- package/src/gates/run.mjs +295 -27
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/report.mjs +143 -5
- package/src/outcome/outcome.mjs +108 -0
- package/src/pipeline/pipeline.mjs +112 -0
- package/src/proof/red-green.mjs +41 -13
- package/src/proof/runners.mjs +28 -4
- package/src/proof/worktree.mjs +25 -15
- package/src/review/agent-waiver.mjs +113 -12
- package/src/review/engine.mjs +25 -5
- package/src/review/gate-check.mjs +52 -10
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/model-stats.mjs +125 -0
- package/src/review/packet.mjs +88 -9
- package/src/review/related.mjs +112 -0
- package/src/review/reopen.mjs +42 -16
- package/src/review/route.mjs +58 -4
- package/src/review/session-retry.mjs +59 -0
- package/src/session/facts.mjs +19 -7
- package/src/session/plan-check.mjs +4 -1
- package/src/session/spawn.mjs +54 -3
- package/src/state/block.mjs +139 -49
- package/src/state/config-snapshot.mjs +13 -3
- package/src/state/run.mjs +3 -2
- package/src/util/exec.mjs +48 -31
- package/src/util/native.mjs +131 -0
- package/src/util/reaper.mjs +238 -8
- package/src/worker/engine.mjs +7 -3
- package/src/worker/loop.mjs +211 -30
- package/src/worker/queue.mjs +107 -24
- package/src/worker/watch.mjs +18 -7
package/src/cli/review-file.mjs
CHANGED
|
@@ -67,30 +67,91 @@
|
|
|
67
67
|
* needs its own review.
|
|
68
68
|
*/
|
|
69
69
|
|
|
70
|
-
import { readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
70
|
+
import { lstatSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, watch } from 'node:fs';
|
|
71
71
|
import path from 'node:path';
|
|
72
|
-
import {
|
|
72
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
73
|
+
import { answersTicketId, assertAnswers, blockTickets, clearDone, deliverBlockFindings, dropDoneIf, enqueue, enqueueAnswers, enqueueHash, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveHashTicket, resolveTicket, reviewExclusionFor } from '../worker/queue.mjs';
|
|
73
74
|
import { assertBlockId, assertInsideRoot, assertTicketId, contentHash, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
|
|
74
|
-
import {
|
|
75
|
+
import { changedFiles, checkBlockReviews, namedOnHash, ownsAt } from '../review/gate-check.mjs';
|
|
76
|
+
import { AUTO_REQUEUE_REASON, isAutomaticReopen, reopenFile, stopContentHash } from '../review/reopen.mjs';
|
|
77
|
+
import { loadState } from '../worker/review-state.mjs';
|
|
75
78
|
import { STOP_EXITS } from '../review/stop-exits.mjs';
|
|
76
79
|
import { budgetFor } from '../review/packet.mjs';
|
|
77
80
|
import { ownsFile } from '../state/registry.mjs';
|
|
78
|
-
import { readAllRows } from '../ledger/write.mjs';
|
|
81
|
+
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
79
82
|
import { parseFlags } from '../state/cli-args.mjs';
|
|
80
83
|
import { StateError } from '../state/paths.mjs';
|
|
81
84
|
import { assertRunId } from '../state/paths.mjs';
|
|
82
|
-
import { readRun } from '../state/run.mjs';
|
|
85
|
+
import { readRun, writeSigned } from '../state/run.mjs';
|
|
86
|
+
import { loadKey } from '../state/signer.mjs';
|
|
87
|
+
import { rebaseBlock } from '../state/block.mjs';
|
|
83
88
|
import { configInForce } from '../state/config-snapshot.mjs';
|
|
89
|
+
import { reviewExcludeRule } from '../config/validate.mjs';
|
|
84
90
|
import { blockTree } from '../proof/worktree.mjs';
|
|
85
91
|
import { writeSafe } from '../util/redact.mjs';
|
|
92
|
+
import { daemonRequest } from '../util/native.mjs';
|
|
93
|
+
import { runRootPath } from '../util/tmp.mjs';
|
|
86
94
|
|
|
87
95
|
const USAGE =
|
|
88
|
-
'usage: code-forge review-file <path> --block <id> [--run <id>] [--fresh] | review-file --wait <ticket> [--max <seconds>s]\n' +
|
|
96
|
+
'usage: code-forge review-file [--cwd <dir>] <path> --block <id> [--run <id>] [--fresh] | review-file --wait <ticket> [--max <seconds>s]\n' +
|
|
89
97
|
' code-forge review-file --wait-block <id> --run <id> [--max <seconds>s]\n' +
|
|
90
|
-
' code-forge review-file
|
|
98
|
+
' code-forge review-file --rebase-check --block <id> --run <id>\n' +
|
|
99
|
+
' code-forge review-file <answering file> --block <id> --answers <id,id,…> --of <source> [--run <id>]\n' +
|
|
100
|
+
' --cwd <dir> applies to queueing and both wait modes\n';
|
|
91
101
|
|
|
92
102
|
export const DEFAULT_WAIT_MS = 90_000;
|
|
93
103
|
const POLL_MS = 200;
|
|
104
|
+
const MAX_IDLE_MS = 5000;
|
|
105
|
+
|
|
106
|
+
/** Metadata only: never open file content during an idle poll.
|
|
107
|
+
* @param {string} file @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
function fileStamp(file) {
|
|
110
|
+
try {
|
|
111
|
+
const s = lstatSync(file);
|
|
112
|
+
return `${s.mtimeMs}:${s.size}:${s.mode}:${s.ino}`;
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return 'missing';
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Scan literal ownership prefixes, including directory entries so new files are noticed.
|
|
120
|
+
* @param {string} tree @param {string[]} owned @param {string[]} gitPaths
|
|
121
|
+
*/
|
|
122
|
+
function waitTreeStamp(tree, owned, gitPaths) {
|
|
123
|
+
const entries = new Map();
|
|
124
|
+
/** @param {string} full */
|
|
125
|
+
function visit(full) {
|
|
126
|
+
if (entries.has(full)) return;
|
|
127
|
+
const stamp = fileStamp(full);
|
|
128
|
+
entries.set(full, stamp);
|
|
129
|
+
if (stamp !== 'missing' && lstatSync(full).isDirectory()) {
|
|
130
|
+
for (const name of readdirSync(full).sort()) {
|
|
131
|
+
if (name !== '.git' && name !== '.code-forge') visit(path.join(full, name));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const pattern of owned) {
|
|
136
|
+
const segments = pattern.split('/');
|
|
137
|
+
const glob = segments.findIndex((s) => /[*?{]/.test(s));
|
|
138
|
+
visit(path.join(tree, ...(glob < 0 ? segments : segments.slice(0, glob))));
|
|
139
|
+
}
|
|
140
|
+
for (const full of gitPaths) visit(full);
|
|
141
|
+
return JSON.stringify([...entries].sort(([a], [b]) => a.localeCompare(b)));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Git may store worktree metadata outside the content tree.
|
|
145
|
+
* @param {string} tree @returns {string[]}
|
|
146
|
+
*/
|
|
147
|
+
function waitGitPaths(tree) {
|
|
148
|
+
let gitDir = path.join(tree, '.git');
|
|
149
|
+
if (lstatSync(gitDir).isFile()) gitDir = path.resolve(tree, readFileSync(gitDir, 'utf8').trim().replace(/^gitdir: /, ''));
|
|
150
|
+
let common = gitDir;
|
|
151
|
+
try { common = path.resolve(gitDir, readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim()); }
|
|
152
|
+
catch (err) { if (err.code !== 'ENOENT') throw err; }
|
|
153
|
+
return [path.join(tree, '.gitignore'), ...new Set([gitDir, common])].flatMap((dir, i) => i === 0 ? [dir] : ['HEAD', 'index', 'packed-refs', 'refs', 'info/exclude'].map((f) => path.join(dir, f)));
|
|
154
|
+
}
|
|
94
155
|
|
|
95
156
|
/**
|
|
96
157
|
* `90s`, `90`, `1500ms` → milliseconds.
|
|
@@ -394,9 +455,11 @@ export function fixList(result) {
|
|
|
394
455
|
* B100: read block scope from the workspace mirror, never the private run record. This is a
|
|
395
456
|
* progress summary, not an approval: block close still verifies the authoritative signed rows.
|
|
396
457
|
* @param {string} repoRoot @param {string} cwd @param {string} run @param {string} block
|
|
397
|
-
* @
|
|
458
|
+
* @param {{scopeFile: string, stamp: string, entry: Record<string, any>, tree: string, cfg: Record<string, any> | null, project: string} | null} [cached]
|
|
459
|
+
* @returns {Promise<{scopeFile: string, stamp: string, entry: Record<string, any>, tree: string, cfg: Record<string, any> | null, project: string}>}
|
|
398
460
|
*/
|
|
399
|
-
async function waitBlockScope(repoRoot, cwd, run, block) {
|
|
461
|
+
async function waitBlockScope(repoRoot, cwd, run, block, cached = null) {
|
|
462
|
+
if (cached && fileStamp(cached.scopeFile) === cached.stamp) return cached;
|
|
400
463
|
const top = await repoRootOf(cwd);
|
|
401
464
|
const candidates = new Set([repoRoot]);
|
|
402
465
|
for (let dir = realpathSync(cwd); ; dir = path.dirname(dir)) {
|
|
@@ -416,7 +479,8 @@ async function waitBlockScope(repoRoot, cwd, run, block) {
|
|
|
416
479
|
throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
|
|
417
480
|
}
|
|
418
481
|
const tree = (await blockTree(record.workspace, entry))?.top ?? repoRoot;
|
|
419
|
-
|
|
482
|
+
const scopeFile = path.join(dir, '.code-forge', 'runs', `${run}.json`);
|
|
483
|
+
return { entry, tree, cfg: configInForce(record), project: record.project, scopeFile, stamp: fileStamp(scopeFile) };
|
|
420
484
|
}
|
|
421
485
|
throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
|
|
422
486
|
}
|
|
@@ -445,42 +509,173 @@ function blockTicketOutcome(marker) {
|
|
|
445
509
|
return 'unavailable';
|
|
446
510
|
}
|
|
447
511
|
|
|
512
|
+
/** Completion timings are progress metadata; old markers remain readable.
|
|
513
|
+
* @param {Record<string, any> | null} value @returns {Record<string, any>} */
|
|
514
|
+
function ticketTimings(value) {
|
|
515
|
+
return Object.fromEntries(['queued_at', 'started_at', 'finished_at', 'duration_ms']
|
|
516
|
+
.filter((key) => value && Object.hasOwn(value, key)).map((key) => [key, value[key]]));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Restart only verified stopped reviews whose findings were not waived and whose bytes changed.
|
|
520
|
+
* Shared by the join and close; ordinary progress polling needs no signing key.
|
|
521
|
+
* @param {{repoRoot: string, run: string, block: string, tree: string, files: string[], print: (line: string) => unknown}} opts
|
|
522
|
+
* @returns {Promise<Map<string, string>>} restart timestamp by file
|
|
523
|
+
*/
|
|
524
|
+
export async function requeueStoppedFiles({ repoRoot, run, block, tree, files, print }) {
|
|
525
|
+
const restarted = new Map();
|
|
526
|
+
if (files.length === 0) return restarted;
|
|
527
|
+
const record = await readRun(run);
|
|
528
|
+
const entry = record.blocks?.[block];
|
|
529
|
+
if (entry?.status !== 'open') return restarted;
|
|
530
|
+
if (realpathSync(await mainRootOf(record.workspace)) !== realpathSync(repoRoot)) throw new WorkerError('other-workspace', 'the run belongs to another workspace');
|
|
531
|
+
const recordedTree = (await blockTree(record.workspace, entry))?.top ?? repoRoot;
|
|
532
|
+
if (realpathSync(recordedTree) !== realpathSync(tree)) throw new WorkerError('worktree-mismatch', 'the block scope does not match the recorded content tree');
|
|
533
|
+
const owns = ownsAt(path.relative(realpathSync(repoRoot), realpathSync(record.workspace)).split(path.sep).join('/'));
|
|
534
|
+
const key = await loadKey(run);
|
|
535
|
+
const rows = (await readAllRows(record.project)).filter((row) => row.run === run);
|
|
536
|
+
const root = configInForce(record)?.tmp?.root;
|
|
537
|
+
const runRootDir = runRootPath(run, root == null ? {} : { root });
|
|
538
|
+
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree: realpathSync(tree) };
|
|
539
|
+
const writeRow = (row) => appendRow(row, { slug: record.project });
|
|
540
|
+
for (const file of files) {
|
|
541
|
+
if (!owns(entry.owned_files, file, entry.owned_kinds) || reviewExcludeRule(configInForce(record), file) !== null) continue;
|
|
542
|
+
if (Object.entries(record.blocks).some(([id, other]) => id !== block && other.status === 'open' && owns(other.owned_files, file, other.owned_kinds))) continue;
|
|
543
|
+
const loaded = loadState({ runId: run, block, file, key, rows, runRootDir, contentRoot: tree, migrate: false });
|
|
544
|
+
if (!['ok', 'restored'].includes(loaded.status) || loaded.state?.status !== 'stopped' || !loaded.state.open?.length) continue;
|
|
545
|
+
const fromHash = stopContentHash(loaded.state, rows, { runId: run, block, file, key });
|
|
546
|
+
const currentHash = contentHash(tree, file);
|
|
547
|
+
if (fromHash === null || fromHash === currentHash) continue;
|
|
548
|
+
// A newer accepted review wins over a stopped snapshot. At the stopped hash, use the
|
|
549
|
+
// same waiver rules as the gate, including severity and signature checks.
|
|
550
|
+
const needsReview = (hash) => checkBlockReviews({ runId: run, block, key,
|
|
551
|
+
rows: rows.filter((row) => row.file === undefined || row.file === file),
|
|
552
|
+
files: [{ file, content_hash: hash }], proofFiles: [], highPaths: [],
|
|
553
|
+
}).refusals.some((value) => value.file === file && ['unreviewed', 'unsigned', 'not_approved', 'review_cap', 'autopilot_waiver_invalid'].includes(value.code));
|
|
554
|
+
if (!needsReview(currentHash) || !needsReview(fromHash)) continue;
|
|
555
|
+
const result = await reopenFile({ runId: run, id: block, file, reason: AUTO_REQUEUE_REASON,
|
|
556
|
+
record, rows, key, writeRow, writeSigned, automatic: true,
|
|
557
|
+
io: {
|
|
558
|
+
owns, loadState: () => loaded, contentHash: (f) => contentHash(tree, f),
|
|
559
|
+
clearDone: (f) => clearDone({ repoRoot, run, block, file: f, ...where }).removed,
|
|
560
|
+
enqueue: (f, row) => enqueue({ repoRoot, run, block, file: f, ...where, after: row.ts }).ticket,
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
restarted.set(file, result.row.ts);
|
|
564
|
+
if (!result.existing) print(`auto-requeue ${file}: content changed after stopped review (ticket ${result.ticket})\n`);
|
|
565
|
+
}
|
|
566
|
+
return restarted;
|
|
567
|
+
}
|
|
568
|
+
|
|
448
569
|
/**
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
570
|
+
* Ordinary block progress uses unsigned results. Restarting changed stopped findings verifies
|
|
571
|
+
* the durable state and writes a signed restart before queuing the new attempt.
|
|
572
|
+
* Re-scan metadata each poll and read content only after its stamp changes. Deliver unseen findings of the newest earlier ticket
|
|
573
|
+
* before rechecking a blind edit; the next join queues the fix. Changed stopped findings restart.
|
|
574
|
+
* @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown, print: (line: string) => unknown, dropDone?: typeof dropDoneIf, now?: () => number, sleep?: (ms: number, signal?: AbortSignal) => Promise<unknown>}} opts
|
|
453
575
|
*/
|
|
454
|
-
async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDone = dropDoneIf }) {
|
|
455
|
-
|
|
576
|
+
async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, print, dropDone = dropDoneIf, now = Date.now, sleep = (ms, signal) => delay(ms, undefined, { signal }).catch((err) => { if (err.name !== 'AbortError') throw err; }) }) {
|
|
577
|
+
let scope = await waitBlockScope(repoRoot, cwd, run, block);
|
|
578
|
+
const { tree } = scope;
|
|
579
|
+
const gitPaths = waitGitPaths(tree);
|
|
456
580
|
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree: realpathSync(tree) };
|
|
457
|
-
const deadline =
|
|
581
|
+
const deadline = now() + maxMs;
|
|
582
|
+
let interval = pollMs;
|
|
583
|
+
let seen = '';
|
|
584
|
+
let treeStamp = '';
|
|
585
|
+
/** @type {string[]} */
|
|
586
|
+
let current = [];
|
|
587
|
+
/** @type {Map<string, {stamp: string, content_hash: string}>} */
|
|
588
|
+
const hashes = new Map();
|
|
458
589
|
let downPolls = 0;
|
|
459
590
|
const deliveredHere = new Set();
|
|
591
|
+
let changed = false;
|
|
592
|
+
/** @type {(() => void) | null} */
|
|
593
|
+
let wake = null;
|
|
594
|
+
/** @type {import('node:fs').FSWatcher | undefined} */
|
|
595
|
+
let watcher;
|
|
596
|
+
const notify = () => { changed = true; wake?.(); };
|
|
597
|
+
// Install before scanning so a completion between the scan and the wait is retained.
|
|
598
|
+
// Queue .done markers are already published after their results, on macOS and Linux.
|
|
599
|
+
try {
|
|
600
|
+
watcher = watch(queueDir(repoRoot), (_event, name) => {
|
|
601
|
+
if (name === null || String(name).endsWith('.done')) notify();
|
|
602
|
+
});
|
|
603
|
+
watcher.on('error', () => { watcher.close(); notify(); });
|
|
604
|
+
} catch { /* unavailable watches retain the existing backoff polling */ }
|
|
605
|
+
try {
|
|
460
606
|
for (;;) {
|
|
607
|
+
changed = false;
|
|
608
|
+
scope = await waitBlockScope(repoRoot, cwd, run, block, scope);
|
|
609
|
+
// Keep one daemon join for the remaining deadline: it retains findings already
|
|
610
|
+
// delivered while other files are pending, just like deliveredHere below.
|
|
611
|
+
const answer = await daemonRequest(run, { method: 'wait-block', block, max_ms: Math.max(0, deadline - now()) }, { config: scope.cfg });
|
|
612
|
+
// A stopped review may need a signed automatic reopen. Keep that mutation
|
|
613
|
+
// on the existing Node path; ordinary pending/completed polls stay read-free.
|
|
614
|
+
if (answer && !answer.files.some((/** @type {Record<string, any>} */ file) => file.outcome === 'stopped')) {
|
|
615
|
+
if (answer.reason === 'wait_timeout' && now() < deadline) continue;
|
|
616
|
+
// Go serializes map keys alphabetically; retain the CLI's byte order.
|
|
617
|
+
const files = answer.files.map((/** @type {Record<string, any>} */ f) => ({
|
|
618
|
+
file: f.file, ticket: f.ticket, status: f.status, outcome: f.outcome,
|
|
619
|
+
...(f.rule !== undefined ? { rule: f.rule } : {}), ...ticketTimings(f),
|
|
620
|
+
}));
|
|
621
|
+
say({ block, files, done: answer.done,
|
|
622
|
+
...(answer.status !== undefined ? { status: answer.status } : {}),
|
|
623
|
+
...(answer.reason !== undefined ? { reason: answer.reason } : {}),
|
|
624
|
+
...(answer.next !== undefined ? { next: answer.next } : {}),
|
|
625
|
+
});
|
|
626
|
+
return answer.status === 'worker_down' ? 3 : 0;
|
|
627
|
+
}
|
|
461
628
|
const worker = liveWorker(repoRoot);
|
|
462
629
|
const live = worker?.run === run;
|
|
463
630
|
downPolls = live ? 0 : downPolls + 1;
|
|
464
631
|
const tickets = blockTickets({ repoRoot, run, block, ...where });
|
|
465
|
-
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
632
|
+
scope = await waitBlockScope(repoRoot, cwd, run, block, scope);
|
|
633
|
+
const { entry, cfg, project } = scope;
|
|
634
|
+
const stamp = waitTreeStamp(tree, entry.owned_files, gitPaths) + scope.stamp;
|
|
635
|
+
if (stamp !== treeStamp) {
|
|
636
|
+
current = (await changedFiles({ repoRoot: tree, base: entry.base_sha })).filter((file) => ownsFile(entry.owned_files, file, entry.owned_kinds));
|
|
637
|
+
treeStamp = stamp;
|
|
638
|
+
}
|
|
639
|
+
const stopped = current.filter((file) => {
|
|
640
|
+
const latest = tickets.filter((ticket) => ticket.file === file).at(-1);
|
|
641
|
+
return latest && blockTicketMarker(repoRoot, latest.ticket)?.status === 'stopped'
|
|
642
|
+
&& doneResult(repoRoot, latest.ticket)?.findings?.length > 0;
|
|
643
|
+
});
|
|
644
|
+
const restarted = live ? await requeueStoppedFiles({ repoRoot, run, block, tree, files: stopped, print }) : new Map();
|
|
645
|
+
// Progress only, like answersPrecheck: sandboxed callers may not read the private ledger.
|
|
646
|
+
// Reopens issued here also renew the workspace ticket, which needs no private read to join.
|
|
647
|
+
let rows = [];
|
|
648
|
+
try { rows = await readAllRows(project); } catch { /* The worker verifies the signed row. */ }
|
|
649
|
+
const reopens = new Map();
|
|
650
|
+
for (const row of rows) {
|
|
651
|
+
if (row.event !== 'review.reopened' || row.run !== run || row.block !== block || !Number.isFinite(Date.parse(row.ts))) continue;
|
|
652
|
+
if (row.by !== 'human' && !(row.by === 'agent' && row.verdict?.verdict === 'agree') && !isAutomaticReopen(row)) continue;
|
|
653
|
+
if (!reopens.has(row.file) || row.ts > reopens.get(row.file)) reopens.set(row.file, row.ts);
|
|
654
|
+
}
|
|
655
|
+
for (const [file, ts] of restarted) reopens.set(file, ts);
|
|
656
|
+
const files = current.map((file) => {
|
|
657
|
+
const rule = reviewExcludeRule(cfg, file);
|
|
658
|
+
if (rule !== null) return { file, ticket: null, status: 'excluded', outcome: null, rule };
|
|
659
|
+
const after = reopens.get(file);
|
|
660
|
+
const stamp = fileStamp(path.join(tree, file));
|
|
661
|
+
if (hashes.get(file)?.stamp !== stamp) hashes.set(file, { stamp, content_hash: contentHash(tree, file) });
|
|
662
|
+
const opts = { repoRoot, run, block, file, ...where, after, content_hash: hashes.get(file).content_hash };
|
|
663
|
+
const { ticket } = resolveHashTicket(opts);
|
|
664
|
+
const previous = tickets.filter((t) => t.file === file && (after === undefined || Date.parse(t.enqueued_at) > Date.parse(after)));
|
|
470
665
|
let present = previous.find((t) => t.ticket === ticket);
|
|
471
666
|
if (!present && previous.length > 0) {
|
|
472
667
|
const newest = previous[previous.length - 1];
|
|
473
668
|
const marker = blockTicketMarker(repoRoot, newest.ticket);
|
|
474
|
-
if (marker?.status === 'stopped') return { file, ticket: newest.ticket, status: 'done', outcome: blockTicketOutcome(marker) };
|
|
669
|
+
if (marker?.status === 'stopped') return { file, ticket: newest.ticket, status: 'done', outcome: blockTicketOutcome(marker), ...ticketTimings(marker) };
|
|
475
670
|
if (marker?.status === 'reviewed' && marker.open_findings === true && live) {
|
|
476
671
|
if (deliveredHere.has(newest.ticket) || deliverBlockFindings({ repoRoot, block, file, ticket: newest.ticket })) {
|
|
477
672
|
deliveredHere.add(newest.ticket);
|
|
478
|
-
return { file, ticket: newest.ticket, status: 'findings', outcome: blockTicketOutcome(marker) };
|
|
673
|
+
return { file, ticket: newest.ticket, status: 'findings', outcome: blockTicketOutcome(marker), ...ticketTimings(marker) };
|
|
479
674
|
}
|
|
480
675
|
}
|
|
481
676
|
}
|
|
482
677
|
if (!present && live) {
|
|
483
|
-
|
|
678
|
+
enqueueHash(opts);
|
|
484
679
|
present = { ...previous[previous.length - 1], ticket };
|
|
485
680
|
}
|
|
486
681
|
let marker = present ? blockTicketMarker(repoRoot, ticket) : null;
|
|
@@ -488,7 +683,7 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
|
|
|
488
683
|
dropFreshDone({ repoRoot, ticket, file, dropDone, once: true, statuses: RETRY_STATUSES });
|
|
489
684
|
marker = blockTicketMarker(repoRoot, ticket);
|
|
490
685
|
}
|
|
491
|
-
return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : marker ? 'done' : 'pending', outcome: blockTicketOutcome(marker) };
|
|
686
|
+
return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : marker ? 'done' : 'pending', outcome: blockTicketOutcome(marker), ...ticketTimings(marker) };
|
|
492
687
|
});
|
|
493
688
|
if (downPolls >= 2) {
|
|
494
689
|
say({ block, files, done: false, status: 'worker_down' });
|
|
@@ -499,38 +694,72 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
|
|
|
499
694
|
say({ block, files, done: false, reason: 'split_required', next: `The owner raises review.budgets for ${split.file}, runs run reload, then review-file ${split.file} --fresh.` });
|
|
500
695
|
return 0;
|
|
501
696
|
}
|
|
502
|
-
if (live && files.every((f) => f.status === 'done' || f.status === 'findings')) {
|
|
697
|
+
if (live && files.every((f) => f.status === 'done' || f.status === 'findings' || f.status === 'excluded')) {
|
|
503
698
|
say({ block, files, done: true });
|
|
504
699
|
return 0;
|
|
505
700
|
}
|
|
506
|
-
if (live && (files.some((f) => f.status === 'not_queued') ||
|
|
701
|
+
if (live && (files.some((f) => f.status === 'not_queued') || now() >= deadline)) {
|
|
507
702
|
say({ block, files, done: false, reason: files.some((f) => f.status === 'not_queued') ? 'not_queued' : 'wait_timeout' });
|
|
508
703
|
return 0;
|
|
509
704
|
}
|
|
510
|
-
|
|
705
|
+
const ticketIds = [...new Set([...tickets.map((t) => t.ticket), ...files.map((f) => f.ticket).filter(Boolean)])].sort();
|
|
706
|
+
const state = JSON.stringify([stamp, live, ticketIds, files]);
|
|
707
|
+
if (!changed) {
|
|
708
|
+
interval = seen === state ? Math.min(MAX_IDLE_MS, interval * 2) : pollMs;
|
|
709
|
+
seen = state;
|
|
710
|
+
const abort = new AbortController();
|
|
711
|
+
try {
|
|
712
|
+
const event = new Promise((resolve) => { wake = () => resolve(undefined); });
|
|
713
|
+
await Promise.race([event, sleep(Math.min(interval, Math.max(0, deadline - now())), abort.signal)]);
|
|
714
|
+
} finally { wake = null; abort.abort(); }
|
|
715
|
+
}
|
|
511
716
|
}
|
|
717
|
+
} finally { watcher?.close(); }
|
|
512
718
|
}
|
|
513
719
|
|
|
514
720
|
/**
|
|
515
721
|
* @param {string[]} args
|
|
516
|
-
* @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number, dropDone?: typeof dropDoneIf}} [deps] - `dropDone`: B75's claim, for tests.
|
|
722
|
+
* @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number, dropDone?: typeof dropDoneIf, now?: () => number, sleep?: (ms: number, signal?: AbortSignal) => Promise<unknown>}} [deps] - `dropDone`: B75's claim, for tests.
|
|
517
723
|
* @returns {Promise<number>}
|
|
518
724
|
*/
|
|
519
725
|
export async function runReviewFile(args, deps = {}) {
|
|
520
726
|
const stdout = deps.stdout ?? process.stdout;
|
|
521
727
|
const stderr = deps.stderr ?? process.stderr;
|
|
522
728
|
const say = (/** @type {Record<string, any>} */ obj) => writeSafe(stdout, `${JSON.stringify(obj)}\n`);
|
|
523
|
-
const cwd = deps.cwd ?? process.cwd();
|
|
524
729
|
try {
|
|
525
|
-
const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'wait-block', 'max', 'answers', 'of'], booleans: ['fresh'] });
|
|
730
|
+
const { flags, positionals } = parseFlags(args, { values: ['cwd', 'block', 'run', 'wait', 'wait-block', 'max', 'answers', 'of'], booleans: ['fresh', 'rebase-check'] });
|
|
731
|
+
let cwd = deps.cwd ?? process.cwd();
|
|
732
|
+
if (typeof flags.cwd === 'string') {
|
|
733
|
+
cwd = path.resolve(cwd, flags.cwd);
|
|
734
|
+
try {
|
|
735
|
+
if (!statSync(cwd).isDirectory()) throw new Error('not a directory');
|
|
736
|
+
} catch {
|
|
737
|
+
throw new WorkerError('usage', '--cwd must name an existing directory');
|
|
738
|
+
}
|
|
739
|
+
}
|
|
526
740
|
// the queue and the worker: the main tree, also from inside a block's worktree
|
|
527
741
|
const repoRoot = await mainRootOf(cwd);
|
|
528
742
|
|
|
743
|
+
if (flags['rebase-check'] === true) {
|
|
744
|
+
if (positionals.length || typeof flags.block !== 'string' || typeof flags.run !== 'string' || ['wait', 'wait-block', 'max', 'answers', 'of', 'fresh'].some((key) => flags[key] !== undefined)) {
|
|
745
|
+
throw new WorkerError('usage', '--rebase-check takes --block and --run only');
|
|
746
|
+
}
|
|
747
|
+
const block = assertBlockId(flags.block);
|
|
748
|
+
const runId = assertRunId(flags.run);
|
|
749
|
+
const worker = liveWorker(repoRoot);
|
|
750
|
+
if (!worker || worker.run !== runId) { say({ status: 'worker_down', block }); return 3; }
|
|
751
|
+
const record = await readRun(runId);
|
|
752
|
+
if (await mainRootOf(record.workspace) !== repoRoot) throw new WorkerError('other-workspace', 'the run belongs to another workspace');
|
|
753
|
+
const result = await rebaseBlock({ runId, id: block, rebaseCheck: true, writeRow: (row) => appendRow(row, { slug: record.project }) });
|
|
754
|
+
say({ block, status: 'queued', ...result, next: `code-forge block rebase ${block} --run ${runId}` });
|
|
755
|
+
return 0;
|
|
756
|
+
}
|
|
757
|
+
|
|
529
758
|
if (flags['wait-block'] !== undefined) {
|
|
530
759
|
if (positionals.length > 0 || flags.block !== undefined || flags.wait !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true || typeof flags.run !== 'string') {
|
|
531
760
|
throw new WorkerError('usage', '--wait-block takes a block, --run and --max only');
|
|
532
761
|
}
|
|
533
|
-
return await waitBlock({ repoRoot, cwd, block: assertBlockId(flags['wait-block']), run: assertRunId(flags.run), maxMs: flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max), pollMs: deps.pollMs ?? POLL_MS, say, dropDone: deps.dropDone });
|
|
762
|
+
return await waitBlock({ repoRoot, cwd, block: assertBlockId(flags['wait-block']), run: assertRunId(flags.run), maxMs: flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max), pollMs: deps.pollMs ?? POLL_MS, say, print: (line) => writeSafe(stderr, line), dropDone: deps.dropDone, now: deps.now, sleep: deps.sleep });
|
|
534
763
|
}
|
|
535
764
|
|
|
536
765
|
if (typeof flags.wait === 'string') {
|
|
@@ -542,7 +771,7 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
542
771
|
for (;;) {
|
|
543
772
|
if (isDone(repoRoot, ticket)) {
|
|
544
773
|
const result = doneResult(repoRoot, ticket);
|
|
545
|
-
say({ ticket, status: 'done', result, ...fixList(result) });
|
|
774
|
+
say({ ticket, status: 'done', result, ...ticketTimings(result), ...fixList(result) });
|
|
546
775
|
return 0;
|
|
547
776
|
}
|
|
548
777
|
downPolls = liveWorker(repoRoot) ? 0 : downPolls + 1;
|
|
@@ -561,9 +790,23 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
561
790
|
if (positionals.length !== 1 || typeof flags.block !== 'string' || flags.max !== undefined) throw new WorkerError('usage', 'give one path and --block');
|
|
562
791
|
const block = assertBlockId(flags.block);
|
|
563
792
|
if (flags.fresh === true && (flags.answers !== undefined || flags.of !== undefined)) throw new WorkerError('usage', '--fresh takes no --answers or --of');
|
|
564
|
-
if (flags.answers !== undefined || flags.of !== undefined) return await enqueueAnswersTicket({ flags, answering: positionals[0], block, cwd, repoRoot, say });
|
|
565
793
|
const file = normalizeRequestPath(positionals[0], cwd, await repoRootOf(cwd));
|
|
566
794
|
const worker = liveWorker(repoRoot);
|
|
795
|
+
let cfg = null;
|
|
796
|
+
const requestedRun = typeof flags.run === 'string' ? flags.run : worker?.run;
|
|
797
|
+
if (requestedRun) {
|
|
798
|
+
try { cfg = configInForce(await readRun(requestedRun)); }
|
|
799
|
+
catch { /* The workspace mirror below also serves sandboxed callers. */ }
|
|
800
|
+
}
|
|
801
|
+
const candidates = [file, ...(typeof flags.of === 'string' ? [normalizeRequestPath(flags.of, cwd, await repoRootOf(cwd))] : [])];
|
|
802
|
+
for (const candidate of candidates) {
|
|
803
|
+
const rule = requestedRun ? reviewExclusionFor(repoRoot, requestedRun, candidate, cfg) : reviewExcludeRule(cfg, candidate);
|
|
804
|
+
if (rule !== null) {
|
|
805
|
+
writeSafe(stdout, `not reviewed (review.exclude ${rule}): ${candidate}\n`);
|
|
806
|
+
return 0;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
if (flags.answers !== undefined || flags.of !== undefined) return await enqueueAnswersTicket({ flags, answering: positionals[0], block, cwd, repoRoot, say });
|
|
567
810
|
if (!worker || (typeof flags.run === 'string' && flags.run !== worker.run)) {
|
|
568
811
|
say({ status: 'worker_down', file });
|
|
569
812
|
return 3;
|
|
@@ -574,12 +817,6 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
574
817
|
const tree = recorded === null ? await repoRootOf(cwd) : (recorded.top ?? repoRoot);
|
|
575
818
|
assertInsideRoot(tree, file); // the path, re-checked in the tree its content is read from
|
|
576
819
|
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree };
|
|
577
|
-
let cfg = null;
|
|
578
|
-
try {
|
|
579
|
-
cfg = configInForce(await readRun(worker.run));
|
|
580
|
-
} catch {
|
|
581
|
-
// No readable snapshot: retain the existing cache and --fresh rules.
|
|
582
|
-
}
|
|
583
820
|
if (flags.fresh === true) return freshTicket({ repoRoot, run: worker.run, block, file, where, say, cfg, stderr, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
|
|
584
821
|
const queued = enqueue({ repoRoot, run: worker.run, block, file, ...where });
|
|
585
822
|
const cached = queued.status === 'done' ? doneResult(repoRoot, queued.ticket) : null;
|
package/src/cli/run.mjs
CHANGED
|
@@ -40,7 +40,8 @@ import { configInForce } from '../state/config-snapshot.mjs';
|
|
|
40
40
|
import { endRun, processStartTime, readRun, reattachWorker, RUN_MODES, startRun, stopPinnedWorker } from '../state/run.mjs';
|
|
41
41
|
import { writeSafe } from '../util/redact.mjs';
|
|
42
42
|
import { withLock } from '../util/file-locks.mjs';
|
|
43
|
-
import {
|
|
43
|
+
import { sweepProcesses } from '../util/reaper.mjs';
|
|
44
|
+
import { tmpBase } from '../util/tmp.mjs';
|
|
44
45
|
import { launchWorker, repinWorker } from '../worker/loop.mjs';
|
|
45
46
|
import { liveWorker, workerRegistry } from '../worker/queue.mjs';
|
|
46
47
|
import { repoRootOf } from '../worker/ticket.mjs';
|
|
@@ -173,7 +174,7 @@ export async function runRun(args, deps = {}) {
|
|
|
173
174
|
const project = slugFor(loaded.config, workspace);
|
|
174
175
|
const engine = typeof flags.engine === 'string' ? flags.engine : undefined;
|
|
175
176
|
const tmpRoot = typeof loaded.config?.tmp?.root === 'string' ? loaded.config.tmp.root : undefined;
|
|
176
|
-
await
|
|
177
|
+
await sweepProcesses({ apply: true, root: tmpBase(tmpRoot), zombies: false });
|
|
177
178
|
let record = await startRun({ workspace, project, config: loaded.config, engine, mode, runId, workerPid, writeRow: ledgerWriter(project), probe });
|
|
178
179
|
if (!record.worker && startWorker) {
|
|
179
180
|
const launched = await startWorker({ runId: record.run_id, workspace });
|
package/src/cli/status.mjs
CHANGED
|
@@ -54,9 +54,11 @@ import { budgetUsdOf } from '../ledger/spend.mjs';
|
|
|
54
54
|
import { readAllRows } from '../ledger/write.mjs';
|
|
55
55
|
import { findingsKey, openFindingsOf } from '../review/open-findings.mjs';
|
|
56
56
|
import { briefStatus } from '../state/brief-status.mjs';
|
|
57
|
+
import { configInForce } from '../state/config-snapshot.mjs';
|
|
57
58
|
import { parseFlags } from '../state/cli-args.mjs';
|
|
58
59
|
import { runsDir, StateError } from '../state/paths.mjs';
|
|
59
60
|
import { readRun } from '../state/run.mjs';
|
|
61
|
+
import { daemonRequest } from '../util/native.mjs';
|
|
60
62
|
import { writeSafe } from '../util/redact.mjs';
|
|
61
63
|
|
|
62
64
|
const USAGE = 'usage: code-forge status [--run <id>] [--json]\n';
|
|
@@ -80,15 +82,18 @@ async function realOr(p) {
|
|
|
80
82
|
/**
|
|
81
83
|
* The status data of one run.
|
|
82
84
|
* @param {Record<string, any>} record
|
|
85
|
+
* @param {Array<Record<string, any>> | null | undefined} [cachedRows] - daemon's incrementally read ledger
|
|
83
86
|
* @returns {Promise<RunStatus>}
|
|
84
87
|
*/
|
|
85
|
-
async function statusOf(record) {
|
|
88
|
+
export async function statusOf(record, cachedRows) {
|
|
86
89
|
/** @type {Array<Record<string, any>> | null} null: the ledger cannot be read */
|
|
87
|
-
let rows = null;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
let rows = cachedRows ?? null;
|
|
91
|
+
if (cachedRows === undefined) {
|
|
92
|
+
try {
|
|
93
|
+
rows = await readAllRows(record.project);
|
|
94
|
+
} catch {
|
|
95
|
+
rows = null;
|
|
96
|
+
}
|
|
92
97
|
}
|
|
93
98
|
let budget = null;
|
|
94
99
|
try {
|
|
@@ -113,6 +118,22 @@ async function statusOf(record) {
|
|
|
113
118
|
...(awaiting.length > 0 ? { awaiting_human_check: awaiting } : {}) };
|
|
114
119
|
}
|
|
115
120
|
|
|
121
|
+
/** Restore the Node renderer's insertion order after Go's sorted JSON map encoding.
|
|
122
|
+
* @param {Record<string, any>} s @returns {RunStatus}
|
|
123
|
+
*/
|
|
124
|
+
function daemonStatus(s) {
|
|
125
|
+
return {
|
|
126
|
+
run_id: s.run_id, status: s.status, mode: s.mode, spent_usd: s.spent_usd,
|
|
127
|
+
unknown_usd_sessions: s.unknown_usd_sessions, budget_usd: s.budget_usd,
|
|
128
|
+
blocks: s.blocks.map((/** @type {Record<string, any>} */ b) => ({
|
|
129
|
+
id: b.id, state: b.state, level: b.level, model: b.model,
|
|
130
|
+
gate: { result: b.gate.result, name: b.gate.name }, findings: b.findings,
|
|
131
|
+
})),
|
|
132
|
+
more: s.more, closed: s.closed, stopped: s.stopped,
|
|
133
|
+
...(s.awaiting_human_check ? { awaiting_human_check: s.awaiting_human_check } : {}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
116
137
|
/**
|
|
117
138
|
* Every run that is not ended whose workspace is `root`, newest first.
|
|
118
139
|
* @param {string} root
|
|
@@ -155,7 +176,7 @@ export function runLines(s) {
|
|
|
155
176
|
const lines = [`run ${s.run_id} · ${s.status ?? '-'} · ${s.mode} · spent ${spent}${s.budget_usd !== null ? ` of ${usd(s.budget_usd)}` : ''}`];
|
|
156
177
|
const tally = s.closed + s.stopped > 0 ? `closed ${s.closed} · stopped ${s.stopped}` : null;
|
|
157
178
|
const awaiting = s.awaiting_human_check?.length ? `awaiting_human_check: ${JSON.stringify(s.awaiting_human_check)}` : null;
|
|
158
|
-
const room = STATUS_MAX_LINES -
|
|
179
|
+
const room = STATUS_MAX_LINES - lines.length - (tally === null ? 0 : 1) - (awaiting === null ? 0 : 1);
|
|
159
180
|
const shown = s.blocks.length <= room ? s.blocks : s.blocks.slice(0, room - 1);
|
|
160
181
|
for (const b of shown) {
|
|
161
182
|
lines.push(`${b.id} ${b.state} ${b.level ?? '-'} · ${b.model ?? '-'} · gate ${b.gate.result}${b.gate.name ? ` (${b.gate.name})` : ''} · findings ${b.findings}`);
|
|
@@ -198,9 +219,22 @@ export default async function status(args) {
|
|
|
198
219
|
records = await activeRunsFor(projectRootFor(process.cwd()));
|
|
199
220
|
}
|
|
200
221
|
const shown = [];
|
|
201
|
-
for (const record of records)
|
|
222
|
+
for (const record of records) {
|
|
223
|
+
let config;
|
|
224
|
+
try {
|
|
225
|
+
config = configInForce(record);
|
|
226
|
+
if (!config) {
|
|
227
|
+
const loaded = await loadProjectConfig(record.workspace);
|
|
228
|
+
config = loaded.ok ? loaded.config : { native: { daemon: false } };
|
|
229
|
+
}
|
|
230
|
+
} catch { config = { native: { daemon: false } }; }
|
|
231
|
+
const answer = await daemonRequest(String(record.run_id), { method: 'status' }, { config });
|
|
232
|
+
let summary;
|
|
233
|
+
try { summary = answer ? daemonStatus(answer.runs[0]) : null; } catch { summary = null; }
|
|
234
|
+
shown.push(summary ?? await statusOf(record));
|
|
235
|
+
}
|
|
202
236
|
if (json) {
|
|
203
|
-
out(`${JSON.stringify({ runs: shown })}\n`);
|
|
237
|
+
out(`${JSON.stringify({ runs: shown })}\n`);
|
|
204
238
|
return 0;
|
|
205
239
|
}
|
|
206
240
|
if (shown.length === 0) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { parseFlags } from '../state/cli-args.mjs';
|
|
2
|
+
import { sweepProcesses } from '../util/reaper.mjs';
|
|
3
|
+
import { writeSafe } from '../util/redact.mjs';
|
|
4
|
+
|
|
5
|
+
const HELP = 'usage: code-forge sweep [--apply] [--json]\nDry run by default: list stale workers, dead-run registry children and marked orphans with RSS and total MB.\n--apply stops proven stale processes with SIGTERM, then SIGKILL after a grace period.\nZombies are reported by parent only; neither they nor their parents are signalled. Live engines have no memory limit.\n';
|
|
6
|
+
|
|
7
|
+
/** @param {string[]} args @param {{sweep?:typeof sweepProcesses}} [deps] */
|
|
8
|
+
export default async function runSweep(args, deps = {}) {
|
|
9
|
+
let flags;
|
|
10
|
+
try {
|
|
11
|
+
const parsed = parseFlags(args, { booleans: ['apply', 'json', 'help'] });
|
|
12
|
+
if (parsed.positionals.length) throw new Error('unexpected positional argument');
|
|
13
|
+
flags = parsed.flags;
|
|
14
|
+
} catch {
|
|
15
|
+
writeSafe(process.stderr, HELP);
|
|
16
|
+
return 2;
|
|
17
|
+
}
|
|
18
|
+
if (flags.help) { writeSafe(process.stdout, HELP); return 0; }
|
|
19
|
+
const report = await (deps.sweep ?? sweepProcesses)({ apply: flags.apply === true });
|
|
20
|
+
if (flags.json) writeSafe(process.stdout, `${JSON.stringify(report)}\n`);
|
|
21
|
+
else {
|
|
22
|
+
for (const item of report.items) writeSafe(process.stdout,
|
|
23
|
+
`${report.apply ? item.action ?? 'stopped' : 'would stop'} ${item.kind} pid ${item.pid} ${item.rssMb.toFixed(1)} MB ${item.command}\n`);
|
|
24
|
+
for (const group of report.zombies) writeSafe(process.stdout, `${group.count} zombies (parent ${group.ppid} ${group.command}) — report only\n`);
|
|
25
|
+
writeSafe(process.stdout, `${report.apply ? 'sweep' : 'dry run'}: ${report.items.length} processes, total ${report.totalRssMb.toFixed(1)} MB; ${report.registryRoots.length} dead-run roots\n`);
|
|
26
|
+
}
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
package/src/cli/validate.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { loadConfigFile, DEFAULT_CONFIG_FILENAME } from '../config/load.mjs';
|
|
|
11
11
|
import { loadSeenInCache } from '../config/refresh.mjs';
|
|
12
12
|
import { maskSecretTokens } from '../config/secret-patterns.mjs';
|
|
13
13
|
import { validateConfig } from '../config/validate.mjs';
|
|
14
|
+
import { loadPipeline } from '../pipeline/pipeline.mjs';
|
|
14
15
|
import * as log from '../util/log.mjs';
|
|
15
16
|
|
|
16
17
|
/** Provider -> the CLI binary name `engine: subprocess` would spawn for it. */
|
|
@@ -100,6 +101,11 @@ export default async function validate(args) {
|
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
if (result.valid) {
|
|
104
|
+
const pipeline = loadPipeline(path.dirname(parsed.file));
|
|
105
|
+
if (pipeline.exists && !pipeline.ok) {
|
|
106
|
+
for (const problem of pipeline.problems) log.error(maskSecretTokens(problem));
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
103
109
|
log.info(`${parsed.file}: valid (${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'})`);
|
|
104
110
|
return 0;
|
|
105
111
|
}
|