diff --git a/src/resources/extensions/gsd/auto-recovery.ts b/src/resources/extensions/gsd/auto-recovery.ts index 4b2155921..e7705e077 100644 --- a/src/resources/extensions/gsd/auto-recovery.ts +++ b/src/resources/extensions/gsd/auto-recovery.ts @@ -39,6 +39,7 @@ import { import { isValidationTerminal } from "./state.js"; import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; import { atomicWriteSync } from "./atomic-write.js"; +import { loadJsonFileOrNull } from "./json-persistence.js"; import { dirname, join } from "node:path"; // ─── Artifact Resolution & Verification ─────────────────────────────────────── @@ -354,6 +355,10 @@ export function skipExecuteTask( // ─── Disk-backed completed-unit helpers ─────────────────────────────────────── +function isStringArray(data: unknown): data is string[] { + return Array.isArray(data) && data.every(item => typeof item === "string"); +} + /** Path to the persisted completed-unit keys file. */ export function completedKeysPath(base: string): string { return join(base, ".gsd", "completed-units.json"); @@ -362,12 +367,7 @@ export function completedKeysPath(base: string): string { /** Write a completed unit key to disk (read-modify-write append to set). */ export function persistCompletedKey(base: string, key: string): void { const file = completedKeysPath(base); - let keys: string[] = []; - try { - if (existsSync(file)) { - keys = JSON.parse(readFileSync(file, "utf-8")); - } - } catch (e) { /* corrupt file — start fresh */ void e; } + const keys = loadJsonFileOrNull(file, isStringArray) ?? []; const keySet = new Set(keys); if (!keySet.has(key)) { keys.push(key); @@ -378,27 +378,21 @@ export function persistCompletedKey(base: string, key: string): void { /** Remove a stale completed unit key from disk. */ export function removePersistedKey(base: string, key: string): void { const file = completedKeysPath(base); - try { - if (existsSync(file)) { - const keys: string[] = JSON.parse(readFileSync(file, "utf-8")); - const filtered = keys.filter(k => k !== key); - // Only write if the key was actually present - if (filtered.length !== keys.length) { - atomicWriteSync(file, JSON.stringify(filtered)); - } - } - } catch (e) { /* non-fatal: removePersistedKey failure */ void e; } + const keys = loadJsonFileOrNull(file, isStringArray); + if (!keys) return; + const filtered = keys.filter(k => k !== key); + if (filtered.length !== keys.length) { + atomicWriteSync(file, JSON.stringify(filtered)); + } } /** Load all completed unit keys from disk into the in-memory set. */ export function loadPersistedKeys(base: string, target: Set): void { const file = completedKeysPath(base); - try { - if (existsSync(file)) { - const keys: string[] = JSON.parse(readFileSync(file, "utf-8")); - for (const k of keys) target.add(k); - } - } catch (e) { /* non-fatal: loadPersistedKeys failure */ void e; } + const keys = loadJsonFileOrNull(file, isStringArray); + if (keys) { + for (const k of keys) target.add(k); + } } // ─── Merge State Reconciliation ─────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/auto-worktree-sync.ts b/src/resources/extensions/gsd/auto-worktree-sync.ts index 5231917c7..d855438ef 100644 --- a/src/resources/extensions/gsd/auto-worktree-sync.ts +++ b/src/resources/extensions/gsd/auto-worktree-sync.ts @@ -11,6 +11,7 @@ */ import { existsSync, mkdirSync, readFileSync, cpSync, unlinkSync, readdirSync } from "node:fs"; +import { loadJsonFileOrNull } from "./json-persistence.js"; import { join, sep as pathSep } from "node:path"; import { homedir } from "node:os"; import { safeCopy, safeCopyRecursive } from "./safe-fs.js"; @@ -112,15 +113,15 @@ export function syncStateToProjectRoot(worktreePath: string, projectRoot: string * Uses gsdVersion instead of syncedAt so that launching a second session * doesn't falsely trigger staleness (#804). */ +function isManifestWithVersion(data: unknown): data is { gsdVersion: string } { + return data !== null && typeof data === "object" && "gsdVersion" in data! && typeof (data as Record).gsdVersion === "string"; +} + export function readResourceVersion(): string | null { const agentDir = process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent"); const manifestPath = join(agentDir, "managed-resources.json"); - try { - const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); - return typeof manifest?.gsdVersion === "string" ? manifest.gsdVersion : null; - } catch { - return null; - } + const manifest = loadJsonFileOrNull(manifestPath, isManifestWithVersion); + return manifest?.gsdVersion ?? null; } /** diff --git a/src/resources/extensions/gsd/commands-logs.ts b/src/resources/extensions/gsd/commands-logs.ts index bd2fa958b..379c7aef2 100644 --- a/src/resources/extensions/gsd/commands-logs.ts +++ b/src/resources/extensions/gsd/commands-logs.ts @@ -14,6 +14,7 @@ import type { ExtensionCommandContext } from "@gsd/pi-coding-agent"; import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { gsdRoot } from "./paths.js"; +import { loadJsonFileOrNull } from "./json-persistence.js"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -331,20 +332,18 @@ async function handleLogsList(basePath: string, ctx: ExtensionCommandContext): P // Metrics summary const metricsPath = join(gsdRoot(basePath), "metrics.json"); - if (existsSync(metricsPath)) { - try { - const metrics = JSON.parse(readFileSync(metricsPath, "utf-8")); - const units = metrics?.units; - if (Array.isArray(units) && units.length > 0) { - const totalCost = units.reduce((sum: number, u: Record) => sum + ((u.cost as number) ?? 0), 0); - const totalTokens = units.reduce((sum: number, u: Record) => { - const t = u.tokens as Record | undefined; - return sum + (t?.total ?? 0); - }, 0); - lines.push(""); - lines.push(`Metrics: ${units.length} units tracked · $${totalCost.toFixed(2)} · ${(totalTokens / 1000).toFixed(0)}K tokens`); - } - } catch { /* ignore */ } + const isMetrics = (d: unknown): d is { units: Array> } => + d !== null && typeof d === "object" && "units" in d! && Array.isArray((d as Record).units); + const metrics = loadJsonFileOrNull(metricsPath, isMetrics); + if (metrics && metrics.units.length > 0) { + const units = metrics.units; + const totalCost = units.reduce((sum: number, u) => sum + ((u.cost as number) ?? 0), 0); + const totalTokens = units.reduce((sum: number, u) => { + const t = u.tokens as Record | undefined; + return sum + (t?.total ?? 0); + }, 0); + lines.push(""); + lines.push(`Metrics: ${units.length} units tracked · $${totalCost.toFixed(2)} · ${(totalTokens / 1000).toFixed(0)}K tokens`); } lines.push(""); diff --git a/src/resources/extensions/gsd/json-persistence.ts b/src/resources/extensions/gsd/json-persistence.ts index 8cb9da14b..c58c28cf1 100644 --- a/src/resources/extensions/gsd/json-persistence.ts +++ b/src/resources/extensions/gsd/json-persistence.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs"; import { dirname } from "node:path"; /** @@ -50,3 +50,18 @@ export function saveJsonFile(filePath: string, data: T): void { // Non-fatal — don't let persistence failures break operation } } + +/** + * Write a JSON file atomically (write to .tmp, then rename). + * Creates parent directories as needed. Non-fatal on error. + */ +export function writeJsonFileAtomic(filePath: string, data: T): void { + try { + mkdirSync(dirname(filePath), { recursive: true }); + const tmp = filePath + ".tmp"; + writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8"); + renameSync(tmp, filePath); + } catch { + // Non-fatal — don't let persistence failures break operation + } +} diff --git a/src/resources/extensions/gsd/queue-order.ts b/src/resources/extensions/gsd/queue-order.ts index de8ef6cf9..fa87a1a80 100644 --- a/src/resources/extensions/gsd/queue-order.ts +++ b/src/resources/extensions/gsd/queue-order.ts @@ -9,10 +9,10 @@ * survives branch switches and is shared across sessions. */ -import { readFileSync, writeFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { gsdRoot } from "./paths.js"; import { milestoneIdSort } from "./milestone-ids.js"; +import { loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -45,6 +45,12 @@ function queueOrderPath(basePath: string): string { return join(gsdRoot(basePath), "QUEUE-ORDER.json"); } +// ─── Type Guards ───────────────────────────────────────────────────────────── + +function isQueueOrderFile(data: unknown): data is QueueOrderFile { + return data !== null && typeof data === "object" && "order" in data! && Array.isArray((data as QueueOrderFile).order); +} + // ─── Read / Write ──────────────────────────────────────────────────────────── /** @@ -52,15 +58,8 @@ function queueOrderPath(basePath: string): string { * the file is corrupt/unreadable. */ export function loadQueueOrder(basePath: string): string[] | null { - const p = queueOrderPath(basePath); - if (!existsSync(p)) return null; - try { - const data: QueueOrderFile = JSON.parse(readFileSync(p, "utf-8")); - if (!Array.isArray(data.order)) return null; - return data.order; - } catch { - return null; - } + const data = loadJsonFileOrNull(queueOrderPath(basePath), isQueueOrderFile); + return data?.order ?? null; } /** @@ -71,7 +70,7 @@ export function saveQueueOrder(basePath: string, order: string[]): void { order, updatedAt: new Date().toISOString(), }; - writeFileSync(queueOrderPath(basePath), JSON.stringify(data, null, 2) + "\n", "utf-8"); + saveJsonFile(queueOrderPath(basePath), data); } // ─── Sorting ───────────────────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/session-status-io.ts b/src/resources/extensions/gsd/session-status-io.ts index 452d7201f..7d8558a59 100644 --- a/src/resources/extensions/gsd/session-status-io.ts +++ b/src/resources/extensions/gsd/session-status-io.ts @@ -11,9 +11,6 @@ */ import { - writeFileSync, - readFileSync, - renameSync, unlinkSync, readdirSync, mkdirSync, @@ -21,6 +18,7 @@ import { } from "node:fs"; import { join } from "node:path"; import { gsdRoot } from "./paths.js"; +import { loadJsonFileOrNull, writeJsonFileAtomic } from "./json-persistence.js"; // ─── Types ───────────────────────────────────────────────────────────────── @@ -49,9 +47,16 @@ export interface SignalMessage { const PARALLEL_DIR = "parallel"; const STATUS_SUFFIX = ".status.json"; const SIGNAL_SUFFIX = ".signal.json"; -const TMP_SUFFIX = ".tmp"; const DEFAULT_STALE_TIMEOUT_MS = 30_000; +function isSessionStatus(data: unknown): data is SessionStatus { + return data !== null && typeof data === "object" && "milestoneId" in data && "pid" in data; +} + +function isSignalMessage(data: unknown): data is SignalMessage { + return data !== null && typeof data === "object" && "signal" in data && "sentAt" in data; +} + // ─── Helpers ─────────────────────────────────────────────────────────────── function parallelDir(basePath: string): string { @@ -86,25 +91,13 @@ function isPidAlive(pid: number): boolean { /** Write session status atomically (write to .tmp, then rename). */ export function writeSessionStatus(basePath: string, status: SessionStatus): void { - try { - ensureParallelDir(basePath); - const dest = statusPath(basePath, status.milestoneId); - const tmp = dest + TMP_SUFFIX; - writeFileSync(tmp, JSON.stringify(status, null, 2), "utf-8"); - renameSync(tmp, dest); - } catch { /* non-fatal */ } + ensureParallelDir(basePath); + writeJsonFileAtomic(statusPath(basePath, status.milestoneId), status); } /** Read a specific milestone's session status. */ export function readSessionStatus(basePath: string, milestoneId: string): SessionStatus | null { - try { - const p = statusPath(basePath, milestoneId); - if (!existsSync(p)) return null; - const raw = readFileSync(p, "utf-8"); - return JSON.parse(raw) as SessionStatus; - } catch { - return null; - } + return loadJsonFileOrNull(statusPath(basePath, milestoneId), isSessionStatus); } /** Read all session status files from .gsd/parallel/. */ @@ -114,13 +107,10 @@ export function readAllSessionStatuses(basePath: string): SessionStatus[] { const results: SessionStatus[] = []; try { - const entries = readdirSync(dir); - for (const entry of entries) { + for (const entry of readdirSync(dir)) { if (!entry.endsWith(STATUS_SUFFIX)) continue; - try { - const raw = readFileSync(join(dir, entry), "utf-8"); - results.push(JSON.parse(raw) as SessionStatus); - } catch { /* skip corrupt files */ } + const status = loadJsonFileOrNull(join(dir, entry), isSessionStatus); + if (status) results.push(status); } } catch { /* non-fatal */ } return results; @@ -138,27 +128,19 @@ export function removeSessionStatus(basePath: string, milestoneId: string): void /** Write a signal file for a worker to consume. */ export function sendSignal(basePath: string, milestoneId: string, signal: SessionSignal): void { - try { - ensureParallelDir(basePath); - const dest = signalPath(basePath, milestoneId); - const tmp = dest + TMP_SUFFIX; - const msg: SignalMessage = { signal, sentAt: Date.now(), from: "coordinator" }; - writeFileSync(tmp, JSON.stringify(msg, null, 2), "utf-8"); - renameSync(tmp, dest); - } catch { /* non-fatal */ } + ensureParallelDir(basePath); + const msg: SignalMessage = { signal, sentAt: Date.now(), from: "coordinator" }; + writeJsonFileAtomic(signalPath(basePath, milestoneId), msg); } /** Read and delete a signal file (atomic consume). Returns null if no signal pending. */ export function consumeSignal(basePath: string, milestoneId: string): SignalMessage | null { - try { - const p = signalPath(basePath, milestoneId); - if (!existsSync(p)) return null; - const raw = readFileSync(p, "utf-8"); - unlinkSync(p); - return JSON.parse(raw) as SignalMessage; - } catch { - return null; + const p = signalPath(basePath, milestoneId); + const msg = loadJsonFileOrNull(p, isSignalMessage); + if (msg) { + try { unlinkSync(p); } catch { /* non-fatal */ } } + return msg; } // ─── Stale Detection ───────────────────────────────────────────────────────