reserve-report-num.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
/**
* reserve-report-num.mjs - shared atomic report-number allocator.
*
* The CLI and every evaluator use the exported API below. Reservations account
* for report files, reservation sentinels, tracker row IDs, report links in
* the tracker, and tracker numbers the ledgers still name after their row was
* deleted or merged away (status-log.tsv, follow-ups.md,
* salary-observations.tsv, contacts.tsv). The tracker scan and sentinel
* creation run under the same lock as tracker writers, while O_CREAT|O_EXCL
* keeps claims atomic across processes.
*
* Usage:
* node reserve-report-num.mjs
* node reserve-report-num.mjs --count 8
* node reserve-report-num.mjs --release 035
* node reserve-report-num.mjs --release 042-049
* node reserve-report-num.mjs --gc
*
* Unrecognized arguments are rejected (exit 1, nothing reserved).
*/
import {
existsSync, mkdirSync, readFileSync, readdirSync, realpathSync,
statSync, unlinkSync, writeFileSync,
} from 'fs';
import { randomUUID } from 'crypto';
import { dirname, join, resolve } from 'path';
import { fileURLToPath } from 'url';
import { getCareerOpsRoot, resolveBatchStatePaths } from './path-resolver.mjs';
import {
extractTrackerReportNumbers, parseTrackerRow, resolveColumns,
} from './tracker-parse.mjs';
import {
acquireTrackerLock, canonicalizeTrackerPath, resolveTrackerPath, trackerLockDirFor,
} from './tracker-utils.mjs';
import { isMainModule } from './lib/is-main-module.mjs';
const ROOT = getCareerOpsRoot();
const MAX_SENTINEL_AGE_MS = 4 * 60 * 60 * 1000;
const MAX_RETRIES = 50;
const MAX_COUNT = 50;
const RESERVATION_TOKEN = Symbol('career-ops-report-reservation-token');
const USAGE_LINES = [
'Usage: node reserve-report-num.mjs [--count <1-N>] [--release <NNN>[-<MMM>]] [--gc]',
'',
' (no flags) Reserve 1 report number (default)',
` --count <1-${MAX_COUNT}> Reserve N report numbers, printed as a range`,
' --release <NNN>[-<MMM>] Release a previously reserved number or range',
' --gc Garbage-collect stale reservation sentinels',
'',
];
/** Format a report ID with a minimum width of three digits. */
export function formatReportNumber(num) {
if (!Number.isSafeInteger(num) || num < 1) {
throw new TypeError(`Report number must be a positive integer, got ${num}`);
}
return String(num).padStart(3, '0');
}
function reportsDirFor(options = {}) {
return resolve(options.reportsDir
|| process.env.CAREER_OPS_REPORTS_DIR
|| join(options.rootDir || ROOT, 'reports'));
}
function trackerPathFor(options = {}) {
return options.trackerPath
? canonicalizeTrackerPath(options.trackerPath)
: resolveTrackerPath(options.rootDir || ROOT);
}
// Read where batch-runner.sh writes it, in the checkout, and the data root's
// copy too, as merge-tracker.mjs reads them. With the data root elsewhere, a
// reservation made by hand read a file that never exists and handed out
// numbers the runner recorded as failed.
function batchStateFilesFor(options = {}) {
const explicit = options.batchStateFile || process.env.CAREER_OPS_BATCH_STATE;
return explicit ? [resolve(explicit)] : resolveBatchStatePaths(options.rootDir || ROOT);
}
// Mirrors merge-tracker.mjs's loadFailedReportNumbers: a report number the
// batch runner itself recorded as "failed" must not be handed out again, or
// the two scripts disagree by construction — this script re-issues the
// number, then merge-tracker.mjs refuses to merge a tracker line for it.
function occupiedFromFailedBatchState(batchStateFiles) {
const failed = new Set();
for (const batchStateFile of batchStateFiles) {
if (!existsSync(batchStateFile)) continue;
for (const line of readFileSync(batchStateFile, 'utf-8').split(/\r?\n/)) {
if (!line.trim() || line.startsWith('id\t')) continue;
const cols = line.split('\t');
if (cols.length < 6) continue;
const status = cols[2];
const reportNum = cols[5];
if (status === 'failed' && reportNum && reportNum !== '-') {
// Digits only, positive, safe: parseInt would accept "12abc" and
// 9007199254740992, and an unsafe number in the occupied set makes
// reserveReportNumbers throw "No safe report-number range remains".
const n = /^\d+$/.test(reportNum) ? Number(reportNum) : NaN;
if (Number.isSafeInteger(n) && n > 0) failed.add(n);
}
}
}
return failed;
}
// A bare date file is not a report. `scan-ats-full.mjs --md-out reports/` writes
// its digest as `reports/YYYY-MM-DD.md`, which matches `/^(\d+)-/` and is read
// as report #2026 — pushing every later reservation to 2027+, silently and
// permanently.
//
// Deliberately narrow: report filenames in the wild are not all
// `NNN-slug-DATE.md` (`1001-taken.md` and `NNN-RESERVED.md` are both real), so
// requiring a trailing date would drop legitimate occupancy. Exclude only names
// that are *entirely* a date. See tests/reserve-report-num.test.mjs.
const BARE_DATE_FILE_RE = /^\d{4}-\d{2}-\d{2}\.md$/;
function occupiedFromReports(reportsDir) {
const occupied = new Set();
if (!existsSync(reportsDir)) return occupied;
for (const name of readdirSync(reportsDir)) {
if (BARE_DATE_FILE_RE.test(name)) continue;
const match = name.match(/^(\d+)-/);
if (!match) continue;
const num = parseInt(match[1], 10);
if (Number.isInteger(num) && num > 0) occupied.add(num);
}
return occupied;
}
function occupiedFromTracker(trackerPath) {
const occupied = new Set();
if (!existsSync(trackerPath)) return occupied;
const lines = readFileSync(trackerPath, 'utf-8').split(/\r?\n/);
const colmap = resolveColumns(lines);
for (const line of lines) {
const row = parseTrackerRow(line, colmap);
if (!row) continue;
if (row.num > 0) occupied.add(row.num);
for (const reportNum of extractTrackerReportNumbers(row.report)) {
if (reportNum > 0) occupied.add(reportNum);
}
}
return occupied;
}
// Tracker numbers the ledgers still key on. `tracker.mjs delete` and
// dedup-tracker.mjs remove a row but leave its status-log lines, follow-ups,
// salary observations and contacts under its old number. Handing that number
// to a new row would give the new application the old one's history, so a
// number any of them still names stays taken even once its row is gone.
const LEDGER_NUM_RE = /^\d+$/;
// The three non-table line shapes followup-cadence.mjs reads an application
// number from: a legacy bullet (`- 2026-10-09 · #12 Acme — note`, BULLET_RE),
// a pin (`- next #12 2026-10-20`, OVERRIDE_RE) and a clear
// (`- cleared #12 2026-10-21`, CLEARED_RE). Any other `#N` in the file is
// prose, not a reference.
const FOLLOWUP_LINE_NUM_RES = [
/^-\s+\d{4}-\d{2}-\d{2}\s+·\s+#(\d+)\s/,
/^-\s+(?:next|cleared)\s+#(\d+)\s+\d{4}-\d{2}-\d{2}/i,
];
function ledgerPathsFor(trackerPath, options = {}) {
const dataDir = join(options.rootDir || ROOT, 'data');
return {
// Beside the tracker, as lib/status-hooks.mjs statusLogPath() writes it.
statusLog: join(dirname(trackerPath), 'status-log.tsv'),
// Every place a follow-up can land. A status change seeds it beside the
// tracker (lib/status-hooks.mjs seedFollowupOnApplied); followup-seed and
// the readers default to the data root's data/follow-ups.md or honor
// CAREER_OPS_FOLLOWUPS. These are one file in the default layout and
// split when the tracker lives elsewhere, so count each distinct one.
followups: [...new Set([
process.env.CAREER_OPS_FOLLOWUPS,
join(dataDir, 'follow-ups.md'),
join(dirname(trackerPath), 'follow-ups.md'),
].filter(Boolean).map(p => resolve(p)))],
salary: join(dataDir, 'salary-observations.tsv'),
contacts: join(dataDir, 'contacts.tsv'),
};
}
// A missing ledger is normal (most installs never write one). Any other read
// failure propagates: treating an unreadable ledger as empty is exactly how a
// number it names would be handed out again.
function readLines(path) {
try {
return readFileSync(path, 'utf-8').split(/\r?\n/);
} catch (err) {
if (err?.code === 'ENOENT') return [];
throw err;
}
}
function addLedgerNum(occupied, cell) {
const raw = String(cell ?? '').trim();
if (!LEDGER_NUM_RE.test(raw)) return;
const n = Number(raw);
if (Number.isSafeInteger(n) && n > 0) occupied.add(n);
}
export function occupiedTrackerNumbersFromLedgers(trackerPath, options = {}) {
const occupied = new Set();
const paths = ledgerPathsFor(trackerPath, options);
// status-log.tsv and salary-observations.tsv: tracker# in column 1.
for (const path of [paths.statusLog, paths.salary]) {
for (const line of readLines(path)) addLedgerNum(occupied, line.split('\t')[0]);
}
// contacts.tsv: tracker# in column 8, `-` for no application.
for (const line of readLines(paths.contacts)) addLedgerNum(occupied, line.split('\t')[7]);
// follow-ups.md: table rows are `| num | appNum | ...`.
for (const line of paths.followups.flatMap(readLines)) {
if (line.startsWith('|')) {
const parts = line.split('|');
if (LEDGER_NUM_RE.test(String(parts[1] ?? '').trim())) addLedgerNum(occupied, parts[2]);
continue;
}
for (const re of FOLLOWUP_LINE_NUM_RES) {
const m = line.match(re);
if (m) { addLedgerNum(occupied, m[1]); break; }
}
}
return occupied;
}
function collectOccupied(reportsDir, trackerPath, batchStateFiles, options) {
const occupied = occupiedFromReports(reportsDir);
for (const num of occupiedFromTracker(trackerPath)) occupied.add(num);
for (const num of occupiedFromFailedBatchState(batchStateFiles)) occupied.add(num);
for (const num of occupiedTrackerNumbersFromLedgers(trackerPath, options)) occupied.add(num);
return occupied;
}
function highestNumber(numbers) {
let max = 0;
for (const num of numbers) max = Math.max(max, num);
return max;
}
function sentinelPath(reportsDir, num) {
return join(reportsDir, `${formatReportNumber(num)}-RESERVED.md`);
}
function claimSlot(reportsDir, num, occupied, token) {
if (occupied.has(num)) return false;
try {
writeFileSync(sentinelPath(reportsDir, num), JSON.stringify({
pid: process.pid,
token,
created_at: new Date().toISOString(),
}), { flag: 'wx' });
return true;
} catch (err) {
if (err?.code === 'EEXIST') return false;
throw err;
}
}
function readSentinelOwner(sentinel) {
try {
return JSON.parse(readFileSync(sentinel, 'utf-8'));
} catch {
return null;
}
}
function releaseSlot(reportsDir, num, { token, force = false } = {}) {
const sentinel = sentinelPath(reportsDir, num);
try {
if (!force && readSentinelOwner(sentinel)?.token !== token) return false;
unlinkSync(sentinel);
return true;
} catch (err) {
if (err?.code === 'ENOENT') return false;
throw err;
}
}
function processIsAlive(pid) {
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (err) {
return err?.code === 'EPERM';
}
}
/**
* Reserve one or more contiguous report IDs.
*
* @param {number} [count=1] Number of IDs to reserve (1-50).
* @param {object} [options] Path and lock overrides.
* @returns {Promise<number[]>} Reserved numeric IDs.
*/
export async function reserveReportNumbers(count = 1, options = {}) {
if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
throw new RangeError(`Reservation count must be an integer from 1 to ${MAX_COUNT}`);
}
const reportsDir = reportsDirFor(options);
const trackerPath = trackerPathFor(options);
const batchStateFiles = batchStateFilesFor(options);
mkdirSync(reportsDir, { recursive: true });
const lock = await acquireTrackerLock(trackerLockDirFor(trackerPath), {
timeoutMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_TIMEOUT_MS) || 60_000,
retryMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_RETRY_MS) || 75,
staleMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_STALE_MS) || 10 * 60_000,
tracker: trackerPath,
...options.lockOptions,
});
try {
let occupied = collectOccupied(reportsDir, trackerPath, batchStateFiles, options);
let base = highestNumber(occupied) + 1;
const token = randomUUID();
for (let tries = 0; tries < MAX_RETRIES; tries++) {
const end = base + (count - 1);
if (!Number.isSafeInteger(base) || !Number.isSafeInteger(end)) {
throw new RangeError('No safe report-number range remains');
}
const claimed = [];
let failedAt = null;
for (let num = base; num <= end; num++) {
if (claimSlot(reportsDir, num, occupied, token)) {
claimed.push(num);
} else {
failedAt = num;
break;
}
}
if (failedAt == null) {
Object.defineProperty(claimed, RESERVATION_TOKEN, { value: token });
return claimed;
}
for (const num of claimed) releaseSlot(reportsDir, num, { token });
occupied = collectOccupied(reportsDir, trackerPath, batchStateFiles, options);
base = Math.max(failedAt + 1, highestNumber(occupied) + 1);
}
} finally {
lock.release();
}
throw new Error(`Could not claim ${count} report slot(s) after ${MAX_RETRIES} retries`);
}
/**
* Release reservation sentinels after report creation or on failure.
* Only the array returned by reserveReportNumbers owns its sentinels. The CLI
* uses force mode as an explicit administrative cleanup path.
*/
export async function releaseReportNumbers(numbers, options = {}) {
const reportsDir = reportsDirFor(options);
const values = Array.isArray(numbers) ? numbers : [numbers];
for (const num of values) {
if (!Number.isSafeInteger(num) || num < 1) {
throw new TypeError(`Report number must be a positive integer, got ${num}`);
}
}
const force = options.force === true;
const token = options.reservationToken || numbers?.[RESERVATION_TOKEN];
if (!force && !token) throw new Error('Reservation ownership token is required for release');
if (!existsSync(reportsDir)) return 0;
const trackerPath = trackerPathFor(options);
const lock = await acquireTrackerLock(trackerLockDirFor(trackerPath), {
timeoutMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_TIMEOUT_MS) || 60_000,
retryMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_RETRY_MS) || 75,
staleMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_STALE_MS) || 10 * 60_000,
tracker: trackerPath,
...options.lockOptions,
});
try {
return values.reduce(
(removed, num) => removed + Number(releaseSlot(reportsDir, num, { token, force })),
0,
);
} finally {
lock.release();
}
}
/** Remove reservation sentinels older than the configured TTL. */
export async function gcStaleReportReservations(options = {}) {
const reportsDir = reportsDirFor(options);
if (!existsSync(reportsDir)) return 0;
const trackerPath = trackerPathFor(options);
const lock = await acquireTrackerLock(trackerLockDirFor(trackerPath), {
timeoutMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_TIMEOUT_MS) || 60_000,
retryMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_RETRY_MS) || 75,
staleMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_STALE_MS) || 10 * 60_000,
tracker: trackerPath,
...options.lockOptions,
});
const maxAgeMs = options.maxAgeMs ?? MAX_SENTINEL_AGE_MS;
const now = Date.now();
let removed = 0;
try {
for (const name of readdirSync(reportsDir)) {
if (!/^\d+-RESERVED\.md$/.test(name)) continue;
const fullPath = join(reportsDir, name);
try {
if (now - statSync(fullPath).mtimeMs > maxAgeMs) {
const owner = readSentinelOwner(fullPath);
if (owner?.pid && processIsAlive(owner.pid)) continue;
unlinkSync(fullPath);
removed++;
process.stderr.write(`reserve-report-num: GC stale sentinel ${name}\n`);
}
} catch (err) {
if (err?.code !== 'ENOENT') throw err;
}
}
} finally {
lock.release();
}
if (removed > 0) {
process.stderr.write(`reserve-report-num: removed ${removed} stale sentinel(s)\n`);
}
return removed;
}
async function runCli() {
const args = process.argv.slice(2);
const [cmd, arg] = args;
const options = {};
if (cmd === '--help' || cmd === '-h') {
process.stdout.write(USAGE_LINES.join('\n'));
return 0;
}
// Each command takes a fixed number of arguments. Anything else (an unknown
// word, `--count=3`, a stray extra argument) would fall through to the
// reserve-1 path and claim a number the caller did not ask for.
const argCounts = new Map([['--count', 2], ['--release', 2], ['--gc', 1]]);
const allowed = cmd === undefined ? 0 : argCounts.get(cmd);
const offending = allowed === undefined ? cmd : args[allowed];
if (offending !== undefined) {
process.stderr.write(`reserve-report-num: unrecognized argument: ${offending}\n${USAGE_LINES.join('\n')}`);
return 1;
}
if (cmd === '--release') {
const match = (arg || '').match(/^(\d+)(?:-(\d+))?$/);
if (!match) {
process.stderr.write('Usage: node reserve-report-num.mjs --release <NNN>[-<MMM>]\n');
return 1;
}
const start = parseInt(match[1], 10);
const end = match[2] ? parseInt(match[2], 10) : start;
const rangeCount = end - start + 1;
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)
|| start < 1 || end < start || rangeCount > MAX_COUNT) {
process.stderr.write(`reserve-report-num: --release requires a safe ascending range of at most ${MAX_COUNT} slots\n`);
return 1;
}
await releaseReportNumbers(
Array.from({ length: rangeCount }, (_, index) => start + index),
{ ...options, force: true },
);
return 0;
}
if (cmd === '--gc') {
await gcStaleReportReservations(options);
return 0;
}
let count = 1;
if (cmd === '--count') {
if (!/^\d+$/.test(arg || '')) {
process.stderr.write(`Usage: node reserve-report-num.mjs --count <1-${MAX_COUNT}>\n`);
return 1;
}
count = parseInt(arg, 10);
if (count < 1 || count > MAX_COUNT) {
process.stderr.write(`Usage: node reserve-report-num.mjs --count <1-${MAX_COUNT}>\n`);
return 1;
}
}
try {
const numbers = await reserveReportNumbers(count, options);
process.stdout.write(count === 1
? `${formatReportNumber(numbers[0])}\n`
: `${formatReportNumber(numbers[0])}-${formatReportNumber(numbers[numbers.length - 1])}\n`);
return 0;
} catch (err) {
process.stderr.write(`reserve-report-num: ${err.message}\n`);
return 1;
}
}
if (isMainModule(import.meta.url)) {
process.exitCode = await runCli();
}