#!/usr/bin/env node /** * session-activity.mjs - advisory "another session may already be on this" * signal for concurrent career-ops sessions (#4532). * * reserve-report-num.mjs already makes report-number allocation collision-SAFE * (two sessions can never claim the same number) via O_CREAT|O_EXCL sentinel * files. But nothing makes a session collision-AWARE: two sessions can each * independently pick up the same tracker row / report / URL and do fully * redundant (or conflicting) work, with the collision only surfacing after * the fact when merge-tracker.mjs or a status write has to reconcile it. * #4506/#4507 was exactly this: two sessions evaluated the same report and * each wrote a batch/tracker-additions/*.tsv for it under a differently * spelled company name, so the report-number match (which requires the * company to also agree, #912) missed it and a duplicate row got appended. * * This module is deliberately advisory, not a hard lock: it never blocks or * refuses an operation, and it never replaces the correctness-critical locks * (acquireTrackerLock, reserve-report-num.mjs's atomic slot claims). Its only * job is to let a caller ask "is anyone already active on this key?" before * starting work, and to answer honestly, so a session can choose to warn the * user instead of silently duplicating work another session already started. * The TTL is a ceiling on advisory claims even while their recorded PID is * alive: a PID can be reused after its original process exits. This can expire * a genuinely long-running claim, which is acceptable for a warning-only * signal; correctness-critical work must continue to use its own hard lock. * * Sentinels live at {activityDir}/{sha1(key)}.json rather than being named * from the key directly, so an arbitrary key (a full URL, a company+role * string) never has to be filesystem-safe. The stored payload keeps the * original key for a human-readable message. * * Usage: * node session-activity.mjs claim [--label text] [--ttl-ms N] * node session-activity.mjs check * node session-activity.mjs release --token * node session-activity.mjs gc */ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync, } from 'fs'; import { createHash, randomUUID } from 'crypto'; import { join, resolve } from 'path'; import { getCareerOpsRoot } from './path-resolver.mjs'; import { isMainModule } from './lib/is-main-module.mjs'; const ROOT = getCareerOpsRoot(); const DEFAULT_TTL_MS = 30 * 60 * 1000; function activityDirFor(options = {}) { const dir = resolve(options.activityDir || process.env.CAREER_OPS_ACTIVITY_DIR || join(options.rootDir || ROOT, '.career-ops-locks', 'active')); mkdirSync(dir, { recursive: true }); return dir; } function keyFingerprint(key) { return createHash('sha1').update(String(key)).digest('hex'); } function sentinelPath(activityDir, key) { return join(activityDir, `${keyFingerprint(key)}.json`); } function readSentinel(path) { try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; } } function processIsAlive(pid) { if (!Number.isSafeInteger(pid) || pid <= 0) return null; try { process.kill(pid, 0); return true; } catch (err) { if (err?.code === 'ESRCH') return false; return err?.code === 'EPERM' ? true : null; } } function isStale(entry, path, ttlMs) { if (!entry) return true; // PID liveness cannot establish process identity: after the original owner // exits, its PID may be reused and keep an advisory claim alive forever. // TTL is therefore a ceiling over liveness, as well as the fallback when // there is no usable PID. try { if (Date.now() - statSync(path).mtimeMs > ttlMs) return true; } catch { return true; } if (entry.pid) { const alive = processIsAlive(entry.pid); if (entry.process_bound === true && alive === false) return true; } return false; } /** * Report whether `key` currently has a live claim, without taking one. * * @returns {{active: boolean, owner: object|null}} */ export function checkActivity(key, options = {}) { const activityDir = activityDirFor(options); const path = sentinelPath(activityDir, key); if (!existsSync(path)) return { active: false, owner: null }; const entry = readSentinel(path); const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; if (isStale(entry, path, ttlMs)) return { active: false, owner: null }; return { active: true, owner: entry }; } /** * Claim `key` as active. Never blocks: if another live claim already exists, * returns it instead of throwing, so the caller can decide how to proceed * (this is advisory, not a hard lock — see module header). * * @returns {{claimed: boolean, owner: object|null, token: string|null}} */ export function claimActivity(key, options = {}) { const activityDir = activityDirFor(options); const path = sentinelPath(activityDir, key); const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; const existing = readSentinel(path); if (existing && !isStale(existing, path, ttlMs)) { return { claimed: false, owner: existing, token: null }; } if (existing) { // Stale (dead pid or past TTL) — safe to reclaim. try { unlinkSync(path); } catch (err) { if (err?.code !== 'ENOENT') throw err; } } const token = randomUUID(); const entry = { key: String(key), token, pid: process.pid, process_bound: options.processBound === true, label: options.label || null, claimed_at: new Date().toISOString(), }; try { writeFileSync(path, JSON.stringify(entry), { flag: 'wx' }); return { claimed: true, owner: entry, token }; } catch (err) { if (err?.code !== 'EEXIST') throw err; // Lost the race to another session claiming the same key at the same // instant — report their claim rather than erroring. return { claimed: false, owner: readSentinel(path), token: null }; } } /** Release a claim this caller owns (matched by token, unless force: true). */ export function releaseActivity(key, options = {}) { const activityDir = activityDirFor(options); const path = sentinelPath(activityDir, key); const force = options.force === true; if (!force && !options.token) throw new Error('Claim ownership token is required for release'); try { const entry = readSentinel(path); if (!force && entry?.token !== options.token) return false; unlinkSync(path); return true; } catch (err) { if (err?.code === 'ENOENT') return false; throw err; } } /** Remove sentinels past their TTL or process-bound claims whose PID is dead. */ export function gcStaleActivity(options = {}) { const activityDir = activityDirFor(options); const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; let removed = 0; for (const name of readdirSync(activityDir)) { if (!name.endsWith('.json')) continue; const path = join(activityDir, name); const entry = readSentinel(path); if (isStale(entry, path, ttlMs)) { try { unlinkSync(path); removed++; } catch (err) { if (err?.code !== 'ENOENT') throw err; } } } return removed; } /** Human-readable advisory line for a `check`/failed-`claim` owner, or null if none. */ export function describeActiveOwner(owner) { if (!owner) return null; const started = owner.claimed_at ? new Date(owner.claimed_at) : null; const ageMin = started ? Math.max(0, Math.round((Date.now() - started.getTime()) / 60_000)) : null; const when = ageMin == null ? 'a little while ago' : ageMin < 1 ? 'just now' : `${ageMin} min ago`; const label = owner.label ? ` (${owner.label})` : ''; return `another session appears to already be active on "${owner.key}"${label}, started ${when} — continuing anyway`; } function runCli() { const [,, cmd, arg] = process.argv; const labelIdx = process.argv.indexOf('--label'); const label = labelIdx > -1 ? process.argv[labelIdx + 1] : undefined; const tokenIdx = process.argv.indexOf('--token'); const token = tokenIdx > -1 ? process.argv[tokenIdx + 1] : undefined; const ttlIdx = process.argv.indexOf('--ttl-ms'); const ttlMs = ttlIdx > -1 ? Number(process.argv[ttlIdx + 1]) : undefined; if (cmd === '--help' || cmd === '-h' || !cmd) { process.stdout.write([ 'Usage: node session-activity.mjs [key] [options]', '', ' claim [--label text] [--ttl-ms N] Claim key as active (never blocks)', ' check Report whether key is currently claimed', ' release --token Release a claim this caller owns', ' gc Remove stale sentinels', '', ].join('\n')); return 0; } if (cmd === 'claim') { if (!arg) { process.stderr.write('Usage: node session-activity.mjs claim \n'); return 1; } const result = claimActivity(arg, { label, ttlMs }); process.stdout.write(`${JSON.stringify(result)}\n`); return 0; } if (cmd === 'check') { if (!arg) { process.stderr.write('Usage: node session-activity.mjs check \n'); return 1; } const result = checkActivity(arg, { ttlMs }); process.stdout.write(`${JSON.stringify(result)}\n`); return 0; } if (cmd === 'release') { if (!arg) { process.stderr.write('Usage: node session-activity.mjs release --token \n'); return 1; } const released = releaseActivity(arg, { token }); process.stdout.write(`${JSON.stringify({ released })}\n`); return 0; } if (cmd === 'gc') { const removed = gcStaleActivity({ ttlMs }); process.stdout.write(`${JSON.stringify({ removed })}\n`); return 0; } process.stderr.write(`session-activity: unknown command "${cmd}"\n`); return 1; } if (isMainModule(import.meta.url)) { process.exitCode = runCli(); }