UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

234 lines (233 loc) • 10.2 kB
/** * Cross-process touched-files record (#492). * * #485 (clients/agent-nudge.ts) delivers an inline "these files were * autoformatted by pi-lens" context nudge, fed by the IN-PROCESS * `pilens:files:touched` bus event (#482, clients/bus-publish.ts). That * covers every run/turn of the SAME pi process, but subagents spawn real * child `pi` processes — a separate process, a separate in-memory bus, a * separate accumulator. Nothing about #485 crosses that boundary in either * direction: * * - Child blind to parent: parent's turn_end autoformat dirties the tree; * a child asked to commit runs `git status`, finds unexplained `M` * files, burns turns investigating. * - Parent blind to child: the child is ephemeral, but the parent keeps * working in the same tree after the child returns and its pi-lens * autoformatted on top of the child's edits. * * This module is the shared substrate: a single project-scoped * `recent-touches.json` (via `getProjectDataDir(cwd)` — NEVER a hardcoded * `.pi-lens` path) that every pi-lens instance (parent or child) both * appends to (on publish) and reads from (session_start / turn_start). Ring * buffer capped at ~50 entries; atomic tmp+rename writes, same pattern as * the instance registry (#474, clients/instance-registry.ts). `pid` is what * makes consumption self-excluding: a process must never see its own writes * come back around as a "cross-process" nudge. * * Non-goals (#492): no IPC, no daemon, no `fs.watch` — a passive file, * appended on publish and read at session_start / mtime-gated at * turn_start. Not a general cross-process event bus; one record for one * nudge feed. */ import * as fs from "node:fs"; import * as path from "node:path"; import { getProjectDataDir } from "./file-utils.js"; import { normalizeFilePath } from "./path-utils.js"; export const RECENT_TOUCHES_MAX_ENTRIES = 50; /** Freshness window for the CHILD session_start consumer (15 minutes). */ export const RECENT_TOUCHES_FRESHNESS_MS = 15 * 60 * 1000; function recentTouchesPath(cwd) { return path.join(getProjectDataDir(cwd), "recent-touches.json"); } // --- Kill switch: reuse the #485 flag (house style per clients/agent-nudge.ts) --- // This module does not define its own env var — #492 is explicitly a feed // INTO the same nudge pipeline, so one switch governs the whole path // (producer, both consumers) as specified in the issue. let _enabledCache; export function isRecentTouchesEnabled() { if (_enabledCache === undefined) { _enabledCache = process.env.PI_LENS_AGENT_NUDGE !== "0"; } return _enabledCache; } /** Test-only: reset all module state between test files/cases. */ export function _resetRecentTouchesForTests() { _enabledCache = undefined; _lastSeenMtimeMs = undefined; _lastConsumedCursor.clear(); } // --- Read (never throws — missing file, corrupt JSON, or wrong shape ⇒ empty) --- async function readRecentTouchesAsync(cwd) { try { const raw = await fs.promises.readFile(recentTouchesPath(cwd), "utf-8"); const parsed = JSON.parse(raw); if (parsed && Array.isArray(parsed.entries)) { return parsed; } return { entries: [] }; } catch { return { entries: [] }; } } // --- Write (atomic tmp + rename, same pattern as instance-registry.ts) --- async function writeRecentTouchesAsync(cwd, file) { const dir = getProjectDataDir(cwd); const target = recentTouchesPath(cwd); const tmpPath = `${target}.tmp-${process.pid}`; try { await fs.promises.mkdir(dir, { recursive: true }); await fs.promises.writeFile(tmpPath, JSON.stringify(file), "utf-8"); await fs.promises.rename(tmpPath, target); } catch { // Best-effort record — a failed write just means this batch of touches // never reaches other instances; never throw for the caller (the // producer seam is the fire-and-forget bus-publish path). try { await fs.promises.rm(tmpPath, { force: true }); } catch { // ignore } } } /** * Append this process's touched paths to the shared record (read-modify- * write-whole-file, ring-buffer capped at `RECENT_TOUCHES_MAX_ENTRIES` — * oldest entries drop first). Called from the SAME seam as * `publishFilesTouched` (clients/bus-publish.ts) so parent and child run * identical code. Never throws — callers must swallow/log failures * themselves (this function already does internally via the write helper). */ export async function appendRecentTouches(args) { if (!isRecentTouchesEnabled()) return; if (args.paths.length === 0) return; const now = Date.now(); const pid = process.pid; const newEntries = args.paths.map((p) => ({ path: normalizeFilePath(p), reason: args.reason, ts: now, pid, sessionId: args.sessionId, })); const file = await readRecentTouchesAsync(args.cwd); const merged = [...file.entries, ...newEntries]; // Ring buffer: keep the newest N (oldest-first array, so slice from the tail). const capped = merged.length > RECENT_TOUCHES_MAX_ENTRIES ? merged.slice(merged.length - RECENT_TOUCHES_MAX_ENTRIES) : merged; await writeRecentTouchesAsync(args.cwd, { entries: capped }); } // --- Shared foreign-entry filter (both consumers) --- /** * The baseline relevance filter EVERY consumer applies: not our own pid, * within the freshness window, and the file still exists on disk. Shared so * the turn_start reader can never drift from the session_start reader again * — the original turn_start implementation only filtered pid + cursor, which * meant a fresh process's FIRST turn_start (unset mtime watermark, cursor 0) * would surface every entry in the record, including days-old touches of * since-deleted files (the ring buffer caps count, not age — nothing ever * expires entries in place). */ function passesForeignEntryFilter(entry, selfPid, now, freshnessMs) { if (entry.pid === selfPid) return false; if (now - entry.ts > freshnessMs) return false; try { return fs.existsSync(entry.path); } catch { return false; } } /** * Read entries for the CHILD-at-session_start consumer: `pid !== self`, * newer than the freshness window, and whose file still exists on disk. * The child has no read-guard history yet (it just started), so relevance * here is recency + existence only — the read-guard-based relevance filter * is applied later, upstream, by the caller feeding these into #485's * accumulator (agent-nudge.ts intentionally has no session-agnostic * "cross-process" bypass of its own relevance filter — this function is * the point where that decision is made, per #492 point 6). * * Never throws: ENOENT / EACCES / parse errors on the record file already * collapse to an empty read inside `readRecentTouchesAsync`; a * `fs.existsSync` per candidate is wrapped defensively too. */ export async function readCrossProcessTouchesForSessionStart(args) { if (!isRecentTouchesEnabled()) return []; try { const selfPid = args.selfPid ?? process.pid; const now = args.now ?? Date.now(); const freshnessMs = args.freshnessMs ?? RECENT_TOUCHES_FRESHNESS_MS; const file = await readRecentTouchesAsync(args.cwd); return file.entries.filter((e) => passesForeignEntryFilter(e, selfPid, now, freshnessMs)); } catch { return []; } } // --- Consumer: parent at turn_start (mtime-gated hot path) --- // Module state: the last mtime we've already consumed, and a per-cwd cursor // (max ts already surfaced) so repeated reads of an unchanged-since-last- // check file never re-emit the same entries into the accumulator twice. let _lastSeenMtimeMs; const _lastConsumedCursor = new Map(); /** * Parent-at-turn_start consumer. Hot path: ONE `fs.stat` per call; when the * mtime matches the last-seen mtime, returns `[]` immediately — no read, no * JSON parse. On a changed mtime, reads the file and applies TWO filters: * 1. the shared foreign-entry filter (`pid !== self`, within the * freshness window, file still exists) — the same baseline the * session_start reader applies, so a fresh process's first turn_start * can never nudge about days-old touches of since-deleted files; * 2. `ts` newer than this cwd's last-consumed cursor (dedupes identical * path+ts across repeated reads — a touch already surfaced once must * never come back), then advances both the mtime watermark and the * cursor. * * Wrapped so ENOENT/EACCES/parse errors NEVER throw into turn_start — a * missing record file (no cross-process activity yet) is the common case * and must be silent, not logged as an error. */ export async function readCrossProcessTouchesForTurnStart(args) { if (!isRecentTouchesEnabled()) return []; try { const target = recentTouchesPath(args.cwd); let mtimeMs; try { mtimeMs = (await fs.promises.stat(target)).mtimeMs; } catch { // No record file yet (ENOENT) or inaccessible (EACCES) — nothing to // report; do not disturb the mtime watermark. return []; } if (_lastSeenMtimeMs === mtimeMs) return []; _lastSeenMtimeMs = mtimeMs; const selfPid = args.selfPid ?? process.pid; const now = args.now ?? Date.now(); const freshnessMs = args.freshnessMs ?? RECENT_TOUCHES_FRESHNESS_MS; const file = await readRecentTouchesAsync(args.cwd); const cursorKey = normalizeFilePath(args.cwd); const cursor = _lastConsumedCursor.get(cursorKey) ?? 0; const fresh = file.entries.filter((e) => e.ts > cursor && passesForeignEntryFilter(e, selfPid, now, freshnessMs)); if (file.entries.length > 0) { const maxTs = Math.max(...file.entries.map((e) => e.ts)); _lastConsumedCursor.set(cursorKey, Math.max(cursor, maxTs)); } return fresh; } catch { return []; } }