952e8584f0
- git (#50): stage-hunk/discard-hunk take an optional `block` — one run of consecutive +/- lines inside an @@ hunk. `onlyBlock` (server/lib/git-diff.js) cuts git's fresh hunk down to it, folding the other blocks into the side being checked; LaneDiffView hangs one review bar per block (unified + split) and its arrows walk blocks. - git exclude: entries AgentHub writes for a lane go to the COMMON git dir with the lane's path prefix (server/lib/git-exclude.js, used by router-apply and lane-mcp) — a linked worktree's own info/exclude is never read by git, and a subdir lane has no .git. MCP sync no longer fails with ENOTDIR on a worktree lane or creates a stray .git in a non-repo lane. - codex lanes carry no pipeline: codex-ingest mirrors only idle/running, codex-nudge is removed, agents install writes nothing for codex, the Workspace hides stage/pipeline/feature pickers/PipelineMap for them, and every server start clears leftover codex stage data and the old AGENTS.md stage block. - Changes panel: a whole file opens for reading and enters the editor on a click on its text (caret at the clicked line), not automatically. - macOS: LaunchAgent start-at-login (scripts/launchd-service.js, picked by scripts/service-manager.js). - Adds the AgentHub introduction slides and their image-embedding script.
189 lines
6.5 KiB
JavaScript
189 lines
6.5 KiB
JavaScript
/**
|
|
* @file Codex lane status mirror. Codex emits no hooks, so nothing else can
|
|
* tell the dashboard that a `codex` lane's agent is working. Its on-disk
|
|
* signal is the rollout journal it appends live, one file per thread:
|
|
*
|
|
* $CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-<ts>-<threadId>.jsonl
|
|
* line 1: {"type":"session_meta","payload":{"cwd":…,"session_id":…}}
|
|
*
|
|
* This module maps each journal to the `codex` lane owning its cwd and mirrors
|
|
* idle↔running from how recently the journal was written. It reads nothing but
|
|
* the first line (for the cwd) and never touches a lane's stage — codex lanes
|
|
* carry no pipeline. Fail-safe like workflow-ingest.js: a malformed journal is
|
|
* skipped, and a poll pass never throws.
|
|
*
|
|
* Codex 0.149 also mirrors threads into version-suffixed SQLite files
|
|
* (`state_5.sqlite`, `thread_history_1.sqlite`), but every thread row still
|
|
* points at a live rollout file, so reading the JSONL costs no coupling to a
|
|
* schema the next Codex release renames. `rolloutFiles()` is the one function
|
|
* to re-point if that ever stops being true.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const os = require("node:os");
|
|
const { getCodexHome } = require("./codex-home");
|
|
|
|
const CODEX_ACTIVE_MS = 120_000; // a journal written within 2 min means the agent is working
|
|
const MAX_FILES = 40; // newest-first cap so a years-deep sessions/ tree can't stall a pass
|
|
const MAX_DEPTH = 4; // sessions/<YYYY>/<MM>/<DD>/<file>
|
|
|
|
/** Every rollout journal under sessions/, newest mtime first, capped at MAX_FILES. */
|
|
function rolloutFiles() {
|
|
const out = [];
|
|
const walk = (dir, depth) => {
|
|
let entries = [];
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
return; // absent or unreadable — Codex may simply not be installed
|
|
}
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (depth < MAX_DEPTH) walk(full, depth + 1);
|
|
} else if (entry.isFile() && /^rollout-.*\.jsonl$/.test(entry.name)) {
|
|
try {
|
|
const stat = fs.statSync(full);
|
|
out.push({ path: full, mtime: stat.mtimeMs, size: stat.size });
|
|
} catch {
|
|
/* vanished mid-scan */
|
|
}
|
|
}
|
|
}
|
|
};
|
|
walk(path.join(getCodexHome(), "sessions"), 0);
|
|
return out.sort((a, b) => b.mtime - a.mtime).slice(0, MAX_FILES);
|
|
}
|
|
|
|
const cwdCache = new Map(); // rollout path → thread cwd (written once, at thread creation)
|
|
|
|
/** The thread's working directory, from the session_meta on the file's first line. */
|
|
function rolloutCwd(file) {
|
|
if (cwdCache.has(file)) return cwdCache.get(file);
|
|
let cwd = null;
|
|
try {
|
|
const fd = fs.openSync(file, "r");
|
|
try {
|
|
const buf = Buffer.alloc(64 * 1024);
|
|
const read = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
const head = buf.toString("utf8", 0, read).split("\n")[0];
|
|
cwd = JSON.parse(head).payload.cwd || null;
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
}
|
|
} catch {
|
|
cwd = null;
|
|
}
|
|
if (typeof cwd !== "string" || !cwd) cwd = null;
|
|
cwdCache.set(file, cwd);
|
|
return cwd;
|
|
}
|
|
|
|
/** Test seam: forget every cached cwd. */
|
|
function __resetCache() {
|
|
cwdCache.clear();
|
|
}
|
|
|
|
// Keyed by file path, so in a process that runs for months it would otherwise
|
|
// hold one dead entry per journal Codex ever wrote. Pruned only once well past
|
|
// the per-pass scan size, so ordinary churn keeps its entries.
|
|
const MAX_TRACKED = MAX_FILES * 4;
|
|
|
|
function pruneTracking(seen) {
|
|
if (cwdCache.size <= MAX_TRACKED) return;
|
|
for (const key of cwdCache.keys()) if (!seen.has(key)) cwdCache.delete(key);
|
|
}
|
|
|
|
/**
|
|
* One poll pass: mirror each codex lane's idle↔running from its journals.
|
|
*
|
|
* @param {{broadcast?: (laneId: number) => void}} [options]
|
|
* @returns {number} How many lanes actually changed (and were broadcast).
|
|
*/
|
|
function ingestCodexOnce({ broadcast } = {}) {
|
|
const lanesLib = require("./lanes");
|
|
|
|
let codexLanes = [];
|
|
try {
|
|
codexLanes = lanesLib.listLanes().filter((l) => l.tool === "codex");
|
|
} catch {
|
|
return 0;
|
|
}
|
|
if (!codexLanes.length) return 0;
|
|
|
|
const touched = new Set();
|
|
const files = rolloutFiles();
|
|
pruneTracking(new Set(files.map((f) => f.path)));
|
|
for (const file of files) {
|
|
const cwd = rolloutCwd(file.path);
|
|
if (!cwd) continue;
|
|
const lane = codexLanes
|
|
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
|
|
.sort((a, b) => b.cwd.length - a.cwd.length)[0];
|
|
if (!lane) continue;
|
|
|
|
// An mtime in the FUTURE (clock stepped back, NTP correction, a restore that
|
|
// preserved timestamps) makes the raw difference negative, which reads as
|
|
// "written milliseconds ago". A file that claims to be from the future is
|
|
// not evidence of an agent working now, so treat it as idle.
|
|
const age = Date.now() - file.mtime;
|
|
const active = age >= 0 && age < CODEX_ACTIVE_MS;
|
|
|
|
// Status mirror, under the same guard hook ingestion uses: only idle↔running,
|
|
// and only for a lane the dashboard did not launch — provisioning and failed
|
|
// are lifecycle states this path must never stomp on. `updateLane` filters
|
|
// through PATCHABLE, which allows `status` and forbids `tool`.
|
|
const next = active ? "running" : "idle";
|
|
if (
|
|
!lane.run_id &&
|
|
(lane.status === "idle" || lane.status === "running") &&
|
|
next !== lane.status
|
|
) {
|
|
try {
|
|
lanesLib.updateLane(lane.id, { status: next });
|
|
touched.add(lane.id);
|
|
} catch {
|
|
/* lane deleted mid-pass, or sqlite busy — a status mirror is never worth throwing for */
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const id of touched) {
|
|
try {
|
|
if (broadcast) broadcast(id);
|
|
} catch {
|
|
/* a broadcast failure must not abort the pass */
|
|
}
|
|
}
|
|
return touched.size;
|
|
}
|
|
|
|
/**
|
|
* Unref'd interval so it never blocks shutdown; disable with
|
|
* DASHBOARD_CODEX_POLL_MS=0. Codex appends its journal continuously, so a 15s
|
|
* beat keeps a codex card's running/idle within one beat of live.
|
|
*/
|
|
function startCodexPoll(broadcast) {
|
|
const POLL_MS = process.env.DASHBOARD_CODEX_POLL_MS
|
|
? Number(process.env.DASHBOARD_CODEX_POLL_MS)
|
|
: 15_000;
|
|
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
|
|
const timer = setInterval(() => {
|
|
try {
|
|
ingestCodexOnce({ broadcast });
|
|
} catch {
|
|
/* a poll pass never throws into the event loop */
|
|
}
|
|
}, POLL_MS);
|
|
if (timer.unref) timer.unref();
|
|
}
|
|
|
|
module.exports = {
|
|
rolloutCwd,
|
|
ingestCodexOnce,
|
|
startCodexPoll,
|
|
__resetCache,
|
|
};
|