lib/status-hooks.mjs
Rama main · réplica de lectura del repositorio original.
/**
* status-hooks.mjs — what runs around a tracker status change (#5536).
*
* set-status.mjs is the canonical status writer, and these hooks used to be
* top-level code inside it: the advisory session-activity claim (#4532), the
* status-log.tsv ledger row, follow-up seeding on a move into Applied (#1430),
* and the JD archive on a move into Interview (#4506). A script that has to
* write the Status cell inside its own transaction — dedup-tracker.mjs moves a
* removed duplicate's status onto the kept row in the same write that removes
* the duplicate — could only get them by spawning set-status.mjs, which it
* cannot do without splitting that write in two. So it got none of them, and
* every hook added to set-status later would have been missed the same way.
*
* Callers that CAN delegate (reply-watch.mjs, the web status route) should
* still spawn set-status.mjs: it also validates the state, resolves the row
* and guards the reviewed snapshot. This module is for a writer that has
* already committed the status itself.
*
* Every hook is advisory and never fails the status change: the write has
* already committed, so a hook failure is a warning and a structured result,
* never a thrown error.
*/
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname, resolve, sep, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync, spawn } from 'node:child_process';
import { extractTrackerReportNumbers, firstLinkTarget } from '../tracker-parse.mjs';
import { normalizeCompany, resolveWorkspaceRoot, appendLedgerRows } from '../tracker-utils.mjs';
import { claimActivity, releaseActivity, describeActiveOwner } from '../session-activity.mjs';
import { hasEmbeddedJdArchive } from '../check-jd-archive.mjs';
import { captureSlug, findCaptureForReport } from '../jd-capture.mjs';
const CODE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
export const JD_ARCHIVE_TIMEOUT_MS = Math.min(
300_000,
Math.max(100, Number(process.env.CAREER_OPS_JD_ARCHIVE_TIMEOUT_MS) || 120_000),
);
/**
* The transition ledger sits beside the tracker file, so CAREER_OPS_TRACKER
* redirects (tests, custom layouts) keep it next to the tracker it describes.
*
* @param {string} trackerPath
* @returns {string}
*/
export function statusLogPath(trackerPath) {
return join(dirname(trackerPath), 'status-log.tsv');
}
/**
* Take the advisory session-activity claim for one tracker row (#4532).
*
* Keyed by report number when the row has one (the stable identifier a
* concurrent session reading the same report is most likely to key on too);
* falling back to normalized company+role for a backfilled row with no
* report link.
*
* The claim is process-bound because the caller releases it in the same
* process. A confirmed-dead PID can therefore reclaim a claim left behind by
* an early process exit; raw CLI claims remain TTL-bound.
*
* @param {{company: string, role: string, report?: string, notes?: string}} row
* @param {string} label - Shown to a concurrent session that finds the claim.
* @returns {() => void} Releases the claim; a no-op when none was taken.
*/
export function claimStatusActivity(row, label) {
const activityReportNums = extractTrackerReportNumbers(row.report, row.notes);
const key = activityReportNums.length
? `report:${activityReportNums[0]}`
: `tracker-row:${normalizeCompany(row.company)}|${row.role}`;
let token = null;
try {
const claim = claimActivity(key, { label, processBound: true });
if (claim.claimed) {
token = claim.token;
} else if (claim.owner) {
console.warn(`⚠ ${describeActiveOwner(claim.owner)}`);
}
} catch (err) {
console.warn(`⚠ session-activity claim failed (status change proceeding anyway): ${err.message}`);
}
return () => {
if (token === null) return;
// A leaked claim self-heals once this process is no longer alive (the
// stale-sentinel handling in session-activity.mjs), so a release failure
// is only ever a warning.
try {
releaseActivity(key, { token });
} catch (err) {
console.warn(`⚠ session-activity release failed (status change already committed): ${err.message}`);
} finally {
token = null;
}
};
}
/**
* Append one transition to status-log.tsv (read by funnel-velocity.mjs).
*
* Observation trail only: the tracker stays the source of truth for STATE,
* the ledger records WHEN transitions happened. A failed append is a warning,
* never a failure — the status write already succeeded. Call it inside the
* tracker lock window, so concurrent writers can't interleave lines.
*
* The source must be a member of VALID_SOURCES in funnel-velocity.mjs, and an
* unknown from- or to-state must be the sentinel "-" (see set-status.mjs).
*
* @param {string} trackerPath
* @param {{num: number, date: string, from: string, to: string, source: string, note?: string}} entry
* @returns {boolean} Whether the row was written.
*/
export function appendStatusTransition(trackerPath, { num, date, from, to, source, note = '' }) {
try {
appendLedgerRows(statusLogPath(trackerPath), `${num}\t${date}\t${from}\t${to}\t${source}\t${note}\n`);
return true;
} catch (err) {
console.error(`⚠ status-log append failed (status change itself succeeded): ${err.message}`);
return false;
}
}
/**
* Seed the first follow-up for a row that just moved into Applied (#1430).
*
* Run it AFTER the tracker lock is released. seedFollowup() re-reads the
* tracker to resolve the applied date, and it must read the row the caller
* just wrote. It takes its own followups lock, never the tracker lock, so
* there is no lock ordering to get wrong.
*
* Idempotent (`already-seeded` → seeded:false), so a re-run cannot stack
* duplicate pins.
*
* @param {number} num - Tracker row number.
* @param {object} options
* @param {string} options.trackerPath
* @param {string} [options.date] - The day the user says the application was sent.
* @param {string} [options.statusChangeDate] - Today, on a fresh transition.
* @param {boolean} [options.dryRun]
* @param {boolean} [options.quiet] - Suppress the success line (machine output).
* @returns {Promise<{seeded: boolean, nextDate?: string|null, reason?: string, error?: string}>}
*/
export async function seedFollowupOnApplied(num, { trackerPath, date, statusChangeDate, dryRun = false, quiet = false }) {
try {
const { seedFollowup } = await import('../followup-seed.mjs');
// followupsPath is derived from the tracker's own directory, not left to
// followup-seed's default. Its default is the REPO's data/follow-ups.md,
// so with CAREER_OPS_TRACKER pointing elsewhere — tests, and any install
// whose data lives outside the checkout — the status would be written to
// one tracker and the follow-up seeded next to a different one. The
// status-log append derives its path the same way.
//
// An explicit CAREER_OPS_FOLLOWUPS still wins, as it does for
// followup-seed.mjs itself and for followup-cadence.mjs, the reader that
// decides what is due. Passed as an explicit path, the tracker-derived one
// used to override it, so a pin seeded by set-status, dedup or outcome
// landed in a file the cadence never reads.
const seed = await seedFollowup(num, {
trackerPath,
followupsPath: process.env.CAREER_OPS_FOLLOWUPS || join(dirname(trackerPath), 'follow-ups.md'),
date,
statusChangeDate,
dryRun,
// assumeApplied on a DRY RUN only, and deliberately not `force`.
// seedFollowup refuses a row that is not Applied, and on a dry run the
// tracker was not written — so the row it re-reads still holds the old
// status and the preview would report a failure for the one thing the
// real run is about to do. `force` would fix that by ALSO suppressing
// the already-seeded check, which is the opposite of a preview: a row
// that already has a pin would be promised a new one here and refused
// on the real run. assumeApplied relaxes the status guard only. In a
// real run the row IS Applied by this point and neither is needed.
...(dryRun ? { assumeApplied: true } : {}),
});
if (!quiet && seed.seeded) {
console.log(`📅 Follow-up ${dryRun ? 'would be seeded' : 'seeded'} for #${num}: next ${seed.nextDate}`);
}
return { seeded: seed.seeded, nextDate: seed.nextDate ?? null, ...(seed.reason ? { reason: seed.reason } : {}) };
} catch (err) {
console.error(`⚠ follow-up seeding failed (status change itself succeeded): ${err.message}`);
return { seeded: false, reason: 'error', error: err.message };
}
}
/**
* Archive the JD of a row that just moved into Interview (#4506-direction-2).
*
* AGENTS.md requires every application to carry an archived JD, but that
* requirement is only ever ENFORCED at `oferta`/`pdf` evaluation time — a row
* entered through `add` (a referral, a recruiter reach-out, a posting that
* skipped the pipeline) never gets one written, and nothing notices until
* much later. By the time `interview-prep` goes looking for the JD to build
* prep material, the posting has often closed, and its fetch ladder's last
* resort is "tell the user and ask them to paste the JD text instead" — which
* fails silently if the user never saved a copy either.
*
* The transition into Interview is the last reliable moment the posting is
* still likely to be live (an active interview loop implies the req hasn't
* been pulled yet) AND the first moment prep material will actually be
* wanted, so it is the right point to archive automatically rather than
* leaving it to a human to remember. Detection reuses the same functions
* check-jd-archive.mjs uses to decide "already covered" (#2789), so this
* never re-archives a row that already has a `## Job Description` section
* or a resolvable jds/ capture.
*
* Call it only on a real transition (an idempotent re-run into an
* already-Interview row must not re-archive) and never on a dry run (nothing
* was written yet for archive-posting.mjs to attach a --report to).
*
* @param {{num: number, company: string, report?: string, notes?: string, url?: string}} row
* @param {object} options
* @param {string} options.trackerPath
* @param {boolean} [options.detach] - Start the archive and do not wait for it.
* @param {boolean} [options.json] - Keep the child's progress off stdout.
* @returns {{attempted: boolean, detached?: boolean, pid?: number, reason?: string, error?: string}}
*/
export function archiveJdOnInterview(row, { trackerPath, detach = false, json = false }) {
try {
const workspaceRoot = resolveWorkspaceRoot(trackerPath);
const reportsDir = join(workspaceRoot, 'reports');
const jdsDir = join(workspaceRoot, 'jds');
// Resolve the report cell to a real file path under reportsDir, the same
// containment discipline merge-tracker.mjs's resolveReportPath uses for
// the same reason: the tracker is user-editable, and a report cell is
// untrusted input until it is proven to land inside reports/. The link is
// written relative to the TRACKER's own directory (docs: "../reports/..."
// at data/applications.md, "reports/..." at root), so the leading "../" is
// stripped and the remainder resolved against the WORKSPACE root, not
// reportsDir itself — resolving "../reports/x.md" against reportsDir would
// double the "reports" segment.
const reportCell = String(row.report ?? '').trim();
const rawReportPath = (firstLinkTarget(reportCell) ?? reportCell).trim().replace(/^<|>$/g, '');
let reportPath = null;
if (rawReportPath && rawReportPath !== '—' && rawReportPath !== '-') {
const normalized = rawReportPath.replace(/\\/g, '/').replace(/^(\.\.\/)+/, '');
const candidate = resolve(normalized.includes('/') ? workspaceRoot : reportsDir, normalized);
if (candidate.startsWith(reportsDir + sep) && existsSync(candidate)) reportPath = candidate;
}
// A Markdown label is presentation, not identity: `[7](.../008-acme.md)`
// belongs to report 8. Once the path has passed containment + existence,
// its filename is authoritative for the embedded-JD check and archive.
const reportPathNum = reportPath
? Number.parseInt(/^([0-9]+)-/.exec(basename(reportPath))?.[1] ?? '', 10)
: NaN;
const reportNums = extractTrackerReportNumbers(row.report, row.notes);
const reportNum = Number.isInteger(reportPathNum) && reportPathNum > 0
? reportPathNum
: (reportNums[0] ?? null);
const alreadyEmbedded = reportPath ? hasEmbeddedJdArchive(readFileSync(reportPath, 'utf-8')) : false;
const alreadyCaptured = !alreadyEmbedded && reportNum != null && existsSync(jdsDir)
? findCaptureForReport(jdsDir, reportNum, { companySlug: captureSlug(row.company) }) !== null
: false;
if (alreadyEmbedded || alreadyCaptured) {
return { attempted: false, reason: alreadyEmbedded ? 'already-embedded' : 'already-captured' };
}
if (reportNum == null) return { attempted: false, reason: 'no-report-number' };
if (!row.url) return { attempted: false, reason: 'no-url' };
const archiveArgs = [
join(CODE_ROOT, 'archive-posting.mjs'),
`--report=${reportNum}`,
`--company=${row.company}`,
row.url,
];
// Keep the capture beside the selected tracker even when
// CAREER_OPS_TRACKER redirects it outside the checkout.
const archiveEnv = { ...process.env, CAREER_OPS_ROOT: workspaceRoot };
if (detach) {
// The caller has a fixed budget (30s in the web UI and the dashboard)
// and archive-posting can spend more than that on one page load, so
// waiting here turned a saved status into a reported timeout.
// lib/run-bounded.mjs keeps the timeout, since no parent is left to
// enforce it. stdio 'ignore' matters: an inherited pipe would hold
// the caller's read open until the archive exits.
if (!json) console.log(`📄 No JD archived for #${row.num} yet — archiving report ${reportNum} in the background, before the posting can close.`);
const child = spawn(
process.execPath,
[join(CODE_ROOT, 'lib', 'run-bounded.mjs'), String(JD_ARCHIVE_TIMEOUT_MS), ...archiveArgs],
{ detached: true, stdio: 'ignore', windowsHide: true, env: archiveEnv },
);
// A spawn failure arrives as an event after the status is written;
// unhandled, it would crash the caller after its result printed.
child.on('error', (err) => console.warn(`⚠ JD archive could not start (status change itself succeeded): ${err.message}`));
child.unref();
// The runner's pid, not the archive's: the runner holds the bound and
// exits only once the archive has, so a caller can watch it (#5931).
return { attempted: true, detached: true, ...(child.pid ? { pid: child.pid } : {}) };
}
if (!json) console.log(`📄 No JD archived for #${row.num} yet — archiving report ${reportNum} now, before the posting can close:`);
execFileSync(process.execPath, archiveArgs, {
// stdout is a protocol surface under --json: capture child
// progress so the parent emits exactly one JSON document.
stdio: json ? ['ignore', 'pipe', 'inherit'] : 'inherit',
timeout: JD_ARCHIVE_TIMEOUT_MS,
env: archiveEnv,
});
return { attempted: true };
} catch (err) {
const archiveTimedOut = err?.code === 'ETIMEDOUT'
|| err?.errno === 'ETIMEDOUT'
|| err?.signal === 'SIGTERM';
const archiveError = archiveTimedOut
? `JD archive timed out after ${JD_ARCHIVE_TIMEOUT_MS}ms`
: err.message;
console.warn(`⚠ JD archive trigger failed (status change itself succeeded): ${archiveError}`);
return { attempted: true, error: archiveError };
}
}