#!/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 [-]] [--gc]', '', ' (no flags) Reserve 1 report number (default)', ` --count <1-${MAX_COUNT}> Reserve N report numbers, printed as a range`, ' --release [-] 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} 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 [-]\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(); }