From 86fe759a7cd6ebffa8435c6d7964da01ef87374d Mon Sep 17 00:00:00 2001 From: arko33 <221298156+arko33@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:37:25 +0700 Subject: [PATCH] feat: real probe latency measurement + city client polish + shadowgit hardening (author edits, as-is) --- packages/ghost/lib/city-client.mjs | 180 ++++++++++++++++++++++++----- packages/ghost/lib/city.css | 31 +++++ packages/ghost/lib/probes.mjs | 6 +- packages/ghost/lib/shadowgit.mjs | 38 ++++-- 4 files changed, 216 insertions(+), 39 deletions(-) diff --git a/packages/ghost/lib/city-client.mjs b/packages/ghost/lib/city-client.mjs index 632117a..8d8dbb8 100644 --- a/packages/ghost/lib/city-client.mjs +++ b/packages/ghost/lib/city-client.mjs @@ -31,6 +31,7 @@ const ICON = { file: icon(''), x: icon(''), undo: icon(''), + copy: icon(''), }; const state = { @@ -105,9 +106,21 @@ function normalize(raw) { const anomaly = detect(samples); let blamedPath = null; if (anomaly) { - const cp = checkpoints.filter((c) => c.turnNumber === anomaly.turn); + // Probes sample every few seconds, so a spike is often first seen one turn + // after the write that caused it. Blame the window [turn-1, turn], weighted + // so the current turn still wins a tie — otherwise the fix prompt points at + // whichever stylesheet happened to be edited next. let best = null; - for (const c of cp) for (const [p, v] of Object.entries(c.churn || {})) if (!best || v > best.v) best = { p, v }; + for (const turn of [anomaly.turn, anomaly.turn - 1]) { + const weight = turn === anomaly.turn ? 1 : 0.7; + for (const c of checkpoints) { + if (c.turnNumber !== turn) continue; + for (const [p, v] of Object.entries(c.churn || {})) { + const score = v * weight; + if (!best || score > best.score) best = { p, score }; + } + } + } blamedPath = best ? best.p : null; } @@ -254,39 +267,123 @@ function chartSvg(samples) { if (!samples.length) { return '
' + GHOST(24, "ghost-mark--glow") + "

0 samples yet — point the recorder at your app (--app-url) so health samples arrive.

"; } - const w = 320, h = 92, pad = 8; - const rss = samples.map((s) => (s.metrics || {}).rss || 0); - const cpu = samples.map((s) => (s.metrics || {}).cpu || 0); - const maxR = Math.max(...rss, 0); - const maxC = Math.max(...cpu, 0); - if (maxR <= 0 && maxC <= 0) { - return '
' + GHOST(24, "ghost-mark--glow") + "

the probe answered, but returned no rss/cpu numbers yet — check /api/health on your app.

"; - } - const scaleR = Math.max(maxR, 1); - const scaleC = Math.max(maxC, 1); - const x = (i) => pad + (i / Math.max(1, samples.length - 1)) * (w - pad * 2); - const yR = (v) => h - pad - (v / scaleR) * (h - pad * 2); - const yC = (v) => h - pad - (v / scaleC) * (h - pad * 2); - const line = (arr, y) => arr.map((v, i) => (i ? "L" : "M") + x(i).toFixed(1) + " " + y(v).toFixed(1)).join(" "); - const threshold = yR(scaleR * 0.82); + const W = 560, H = 250, padL = 44, padR = 46, padT = 30, padB = 30; + const mem = samples.map((s) => Math.round(((s.metrics || {}).rss || 0) / 1048576)); + const lat = samples.map((s) => Math.round((s.metrics || {}).latencyMs || 0)); + const maxMem = Math.max(400, Math.max.apply(null, mem)) * 1.02; + const maxLat = Math.max(300, Math.max.apply(null, lat)) * 1.08; + const threshold = 200; + const x = (i) => padL + (i / Math.max(1, samples.length - 1)) * (W - padL - padR); + const yMem = (v) => H - padB - (v / maxMem) * (H - padT - padB); + const yLat = (v) => H - padB - (v / maxLat) * (H - padT - padB); + const smooth = (pts) => { + if (!pts.length) return ""; + let d = "M " + pts[0][0].toFixed(1) + " " + pts[0][1].toFixed(1); + for (let i = 0; i < pts.length - 1; i += 1) { + const mid = (pts[i][0] + pts[i + 1][0]) / 2; + d += " C " + mid.toFixed(1) + " " + pts[i][1].toFixed(1) + ", " + mid.toFixed(1) + " " + pts[i + 1][1].toFixed(1) + ", " + pts[i + 1][0].toFixed(1) + " " + pts[i + 1][1].toFixed(1); + } + return d; + }; + const memPath = smooth(mem.map((v, i) => [x(i), yMem(v)])); + const latPath = smooth(lat.map((v, i) => [x(i), yLat(v)])); + const areaPath = memPath + " L " + x(samples.length - 1).toFixed(1) + " " + (H - padB) + " L " + x(0).toFixed(1) + " " + (H - padB) + " Z"; + + const anomalyIdx = state.anomaly ? Math.min(samples.length - 1, state.anomaly.index) : -1; + const anomalyEnd = anomalyIdx >= 0 ? Math.min(samples.length - 1, anomalyIdx + 4) : -1; + const delta = anomalyIdx > 0 ? mem[anomalyIdx] - mem[anomalyIdx - 1] : 0; const activeN = state.turns[state.active] ? state.turns[state.active].n : 0; - let activeIdx = -1; + let activeIdx = 0; samples.forEach((s, i) => { if ((s.turnNumber || 0) <= activeN) activeIdx = i; }); + + const grid = [0, 0.25, 0.5, 0.75, 1].map((f) => { + const value = Math.round(maxMem * f); + const y = yMem(value); + return '' + + '' + value + ""; + }).join(""); + const right = [0, 0.5, 1].map((f) => { + const value = Math.round(maxLat * f); + return '' + value + ""; + }).join(""); + + const region = anomalyIdx >= 0 + ? '' + + '' + : ""; + + const leader = anomalyIdx > 0 + ? '' + + '+' + delta + " MB / sample" + : ""; + return '
' + - 'rss' + - 'cpu' + + 'rss (MB)' + + 'p95 (ms)' + 'threshold' + - '' + samples.length + " samples
" + - '' + - '' + - (activeIdx >= 0 ? '' : "") + - '' + - '' + - '' + Math.round(scaleR / 1024 / 1024) + " MB peak" + - '' + Math.round(scaleC) + "% cpu" + + "" + + '' + + '' + + '' + + region + grid + right + + '' + + 'threshold ' + threshold + " MB" + + '' + + '' + + '' + + '' + + 't' + activeN + "" + + leader + + '' + + '' + ""; } +function bindChart() { + const hit = $("chartHit"); + const layer = $("chartHover"); + if (!hit || !layer) return; + const W = 560, H = 250, padL = 44, padR = 46, padT = 30, padB = 30; + const samples = state.samples; + const mem = samples.map((s) => Math.round(((s.metrics || {}).rss || 0) / 1048576)); + const lat = samples.map((s) => Math.round((s.metrics || {}).latencyMs || 0)); + const maxMem = Math.max(400, Math.max.apply(null, mem)) * 1.02; + const maxLat = Math.max(300, Math.max.apply(null, lat)) * 1.08; + const x = (i) => padL + (i / Math.max(1, samples.length - 1)) * (W - padL - padR); + const yMem = (v) => H - padB - (v / maxMem) * (H - padT - padB); + const yLat = (v) => H - padB - (v / maxLat) * (H - padT - padB); + + const indexAt = (event) => { + const rect = hit.ownerSVGElement.getBoundingClientRect(); + const local = ((event.clientX - rect.left) / rect.width) * W; + const ratio = (local - padL) / (W - padL - padR); + return Math.max(0, Math.min(samples.length - 1, Math.round(ratio * (samples.length - 1)))); + }; + + hit.addEventListener("pointermove", (event) => { + const i = indexAt(event); + const s = samples[i]; + const tooltipW = 156; + const tx = Math.min(W - tooltipW - 6, Math.max(6, x(i) - tooltipW / 2)); + const metrics = s.metrics || {}; + layer.innerHTML = + '' + + '' + + '' + + '' + + '' + + 't' + (s.turnNumber || 0) + " · " + mem[i] + " MB" + + '' + lat[i] + " ms p95 · " + Math.round(metrics.cpu || 0) + "% cpu" + + '' + (metrics.errors || 0) + " errors"; + }); + hit.addEventListener("pointerleave", () => { layer.innerHTML = ""; }); + hit.addEventListener("click", (event) => { + const s = samples[indexAt(event)]; + const idx = state.turns.findIndex((t) => t.n === (s.turnNumber || 0)); + if (idx >= 0) selectTurn(idx); + }); +} + function renderAutopsy() { const turn = state.turns[state.active] || null; const anomaly = state.anomaly; @@ -349,13 +446,36 @@ function renderAutopsy() { (turn.sha ? '
checkpoint ' + esc(String(turn.sha).slice(0, 10)) + '
loading patch…
' : '

No patch for this turn — the recorder only stores diffs when git shadow checkpoints exist.

') + ""; } - html += '
Runtime' + state.samples.length + " samples
" + chartSvg(state.samples) + "
"; + html += '
Runtime metrics' + state.samples.length + " samples
" + chartSvg(state.samples) + "
"; + + const fixPrompt = state.anomaly + ? "Add a bounded LRU eviction (e.g. clear or shift when size exceeds a cap) to " + (state.blamedPath || "the hottest module") + " and re-run the memory regression check." + : "No anomaly detected — nothing to heal yet. Keep recording; the engine drafts the heal prompt if a sample breaches a threshold."; + html += '
Fix prompt' + + (state.anomaly ? '" : "") + + '

' + esc(fixPrompt) + "

"; $("autopsy").innerHTML = html; const jump = $("jumpBlame"); if (jump) jump.onclick = () => { const i = state.turns.findIndex((t) => t.n === (state.anomaly ? state.anomaly.turn : 0)); if (i >= 0) selectTurn(i); state.preset = "blame"; renderAll(); }; const clear = $("clearSel"); if (clear) clear.onclick = () => { state.selected = null; renderAll(); }; + const copyFix = $("copyFix"); + if (copyFix) copyFix.onclick = async () => { + try { + await navigator.clipboard.writeText(fixPrompt); + copyFix.dataset.copied = "true"; + copyFix.innerHTML = ICON.check + "Copied"; + setTimeout(() => { + if (!copyFix.isConnected) return; + copyFix.dataset.copied = "false"; + copyFix.innerHTML = ICON.copy + "Copy"; + }, 1800); + } catch { + copyFix.dataset.copied = "false"; + } + }; + bindChart(); const viewPatch = $("viewPatch"); if (viewPatch) viewPatch.onclick = () => { const d = document.querySelector(".pg-details"); @@ -630,7 +750,7 @@ function overviewDist() { const ext = state.towers.length ? Math.max(...state.towers.map((t) => Math.max(Math.abs(t.x), Math.abs(t.z)))) : 12; - return clamp(ext * 2.4 + 16, 36, 86); + return clamp(ext * 3.0 + 20, 44, 100); } function pick(e) { diff --git a/packages/ghost/lib/city.css b/packages/ghost/lib/city.css index eb4f725..a5515ae 100644 --- a/packages/ghost/lib/city.css +++ b/packages/ghost/lib/city.css @@ -1582,3 +1582,34 @@ canvas{display:block;max-width:100%} } @media (min-width:901px){.pg-stage__empty p{max-width:420px}} +.copy-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 26px; + padding: 0 8px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--fg-2); + font-family: var(--font-mono-stack); + font-size: 9px; + letter-spacing: 0.06em; + text-transform: uppercase; + transition: color .25s, border-color .25s, background-color .25s; +} + +.copy-btn:hover { + color: var(--fg); + border-color: var(--line-strong); + background: rgba(255, 255, 255, .04); +} + +.copy-btn[data-copied="true"] { + color: var(--emerald); + border-color: rgba(94, 234, 212, .28); +} + +.copy-btn svg { + width: 12px; + height: 12px; +} diff --git a/packages/ghost/lib/probes.mjs b/packages/ghost/lib/probes.mjs index ad707e4..5cbd342 100644 --- a/packages/ghost/lib/probes.mjs +++ b/packages/ghost/lib/probes.mjs @@ -11,8 +11,10 @@ export function createProbes({ url, intervalMs = 4000, fetchImpl = globalThis.fe async function sample() { const ts = new Date().toISOString(); try { + const started = Date.now(); const res = await fetchImpl(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(5000) }); - if (!res.ok) return push(ts, { errors: 1, httpStatus: res.status }, true); + const latencyMs = Date.now() - started; + if (!res.ok) return push(ts, { errors: 1, httpStatus: res.status, latencyMs }, true); const body = await res.json().catch(() => ({})); const cpu = Number(body.cpuPercent ?? body.cpu ?? 0) || 0; let cpuDelta = cpu; @@ -22,7 +24,7 @@ export function createProbes({ url, intervalMs = 4000, fetchImpl = globalThis.fe cpu: Number((cpuDelta * 100 + cpu).toFixed(2)), rss: Number(body.rss ?? 0), heap: Number(body.heap ?? 0), - latencyMs: null, + latencyMs, errors: 0, uptime: Number(body.uptime ?? 0), }, false); diff --git a/packages/ghost/lib/shadowgit.mjs b/packages/ghost/lib/shadowgit.mjs index 6fead45..ed8bd9f 100644 --- a/packages/ghost/lib/shadowgit.mjs +++ b/packages/ghost/lib/shadowgit.mjs @@ -17,26 +17,50 @@ const IGNORE_SET = new Set([ * diff is a unified diff patch versus HEAD ("" when the worktree matches HEAD). */ export function commitSnapshot(repo, message = "ghost checkpoint") { - const idx = path.join(repo, ".ghost", "shadow-index"); + // One index file per recorder process. Two recorders watching the same repo + // (a leftover demo run + a manual `ghost watch`, say) would otherwise fight + // over a single .ghost/shadow-index.lock, and every `git add` after the first + // would die with "File exists" — the city silently froze at a couple of towers. + const idx = path.join(repo, ".ghost", `shadow-index-${process.pid}`); fs.mkdirSync(path.dirname(idx), { recursive: true }); + // A killed recorder leaves its lock behind; this pid is ours now, so anything + // left under this exact name is dead weight. + for (const stale of [idx, `${idx}.lock`]) { + try { fs.unlinkSync(stale); } catch { /* nothing to clean */ } + } const env = { ...process.env, GIT_INDEX_FILE: idx }; const git = (args, extraEnv = {}) => - execFileSync("git", args, { cwd: repo, env: { ...env, ...extraEnv }, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + execFileSync("git", args, { + cwd: repo, + env: { ...env, ...extraEnv }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 1024 * 1024 * 64, + }).trim(); + // execFileSync only exposes stderr when we ask for it; without this the CLI + // printed a bare "Command failed: git add -A -- ." and hid the real reason. + const run = (args, extraEnv) => { + try { return git(args, extraEnv); } + catch (e) { + const detail = String(e.stderr || "").trim().split(/\r?\n/).filter(Boolean).pop(); + throw new Error(`git ${args[0]} failed: ${detail || e.message}`); + } + }; - try { git(["read-tree", "HEAD"]); } catch { /* empty repo without HEAD */ } + try { run(["read-tree", "HEAD"]); } catch { /* empty repo without HEAD */ } // .ghost is the recorder's own bookkeeping (shadow index + patches). Staging // it would grow every snapshot by megabytes and eventually blow maxBuffer — // the city would silently stop growing. Add it to the repo-local exclude // (never committed, never touches the user's .gitignore) so a plain add // skips it everywhere, including repos that do not ignore .ghost. ensureExcluded(repo); - git(["add", "-A", "--", "."]); - const tree = git(["write-tree"]); + run(["add", "-A", "--", "."]); + const tree = run(["write-tree"]); let parent = null; - try { parent = git(["rev-parse", "HEAD"]); } catch { /* first snapshot */ } + try { parent = run(["rev-parse", "HEAD"]); } catch { /* first snapshot */ } const commitArgs = parent ? ["commit-tree", tree, "-p", parent, "-m", message] : ["commit-tree", tree, "-m", message]; - const sha = git(commitArgs, { GIT_AUTHOR_DATE: new Date().toISOString(), GIT_COMMITTER_DATE: new Date().toISOString() }); + const sha = run(commitArgs, { GIT_AUTHOR_DATE: new Date().toISOString(), GIT_COMMITTER_DATE: new Date().toISOString() }); const diffOut = execFileSync( "git", ["diff", "--binary", ...(parent ? [parent] : ["4b825dc642cb6eb9a060e54bf8d69288fbee4904"]), sha], { cwd: repo, encoding: "utf8", maxBuffer: 1024 * 1024 * 64 },