chore(sf): delete orphaned commands-debug + debug-session-store
`/sf debug` was ported in 360208cba but never wired up:
- handleDebug exported but no caller anywhere in the tree
- not in commands/catalog.ts
- loadPrompt("debug-session-manager") and loadPrompt("debug-diagnose")
referenced prompts that never existed in prompts/ — guaranteed
runtime crash if the dispatch path were ever hit
- debug-session-store.ts only consumed by commands-debug.ts
- no tests reference any of it
887 LOC of dead code with a latent crash. Removing both files
eliminates the orphan-prompt callsite that gap-audit kept flagging
and the broken dispatch path. Resolves sf-moohvyzc-ll5bd0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e07f2bc225
commit
1891ccbdcd
2 changed files with 0 additions and 887 deletions
|
|
@ -1,510 +0,0 @@
|
|||
import type { ExtensionAPI, ExtensionCommandContext } from "@singularity-forge/pi-coding-agent";
|
||||
|
||||
import {
|
||||
assertValidDebugSessionSlug,
|
||||
createDebugSession,
|
||||
listDebugSessions,
|
||||
loadDebugSession,
|
||||
updateDebugSession,
|
||||
type DebugTddGate,
|
||||
type DebugSpecialistReview,
|
||||
} from "./debug-session-store.js";
|
||||
import { loadPrompt } from "./prompt-loader.js";
|
||||
|
||||
export type DebugCommandIntent
|
||||
= { type: "usage" }
|
||||
| { type: "issue-start"; issue: string }
|
||||
| { type: "list" }
|
||||
| { type: "status"; slug: string }
|
||||
| { type: "continue"; slug: string }
|
||||
| { type: "diagnose"; slug?: string }
|
||||
| { type: "diagnose-issue"; issue: string }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
const SUBCOMMANDS = new Set(["list", "status", "continue", "--diagnose"]);
|
||||
|
||||
function isValidSlugCandidate(input: string): boolean {
|
||||
try {
|
||||
assertValidDebugSessionSlug(input);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSessionLine(prefix: string, session: {
|
||||
slug: string;
|
||||
mode: string;
|
||||
status: string;
|
||||
phase: string;
|
||||
issue: string;
|
||||
updatedAt: number;
|
||||
}): string {
|
||||
return `${prefix} ${session.slug} [mode=${session.mode} status=${session.status} phase=${session.phase}] — ${session.issue} (updated ${new Date(session.updatedAt).toISOString()})`;
|
||||
}
|
||||
|
||||
function usageText(): string {
|
||||
return [
|
||||
"Usage: /sf debug <issue-text>",
|
||||
" /sf debug list",
|
||||
" /sf debug status <slug>",
|
||||
" /sf debug continue <slug>",
|
||||
" /sf debug --diagnose [<slug> | <issue text>]",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function parseDebugCommand(args: string): DebugCommandIntent {
|
||||
const raw = args.trim();
|
||||
if (!raw) return { type: "usage" };
|
||||
|
||||
const parts = raw.split(/\s+/).filter(Boolean);
|
||||
const head = parts[0] ?? "";
|
||||
|
||||
if (head === "list") {
|
||||
// Strict match only; otherwise treat as issue text for deterministic fallback behavior.
|
||||
if (parts.length === 1) return { type: "list" };
|
||||
return { type: "issue-start", issue: raw };
|
||||
}
|
||||
|
||||
if (head === "status") {
|
||||
if (parts.length === 1) return { type: "error", message: "Missing slug. Usage: /sf debug status <slug>" };
|
||||
if (parts.length === 2 && isValidSlugCandidate(parts[1])) return { type: "status", slug: parts[1] };
|
||||
return { type: "issue-start", issue: raw };
|
||||
}
|
||||
|
||||
if (head === "continue") {
|
||||
if (parts.length === 1) return { type: "error", message: "Missing slug. Usage: /sf debug continue <slug>" };
|
||||
if (parts.length === 2 && isValidSlugCandidate(parts[1])) return { type: "continue", slug: parts[1] };
|
||||
return { type: "issue-start", issue: raw };
|
||||
}
|
||||
|
||||
if (head === "--diagnose") {
|
||||
if (parts.length === 1) return { type: "diagnose" };
|
||||
if (parts.length === 2 && isValidSlugCandidate(parts[1])) return { type: "diagnose", slug: parts[1] };
|
||||
if (parts.length >= 3) return { type: "diagnose-issue", issue: parts.slice(1).join(" ") };
|
||||
return { type: "error", message: "Invalid diagnose target. Usage: /sf debug --diagnose [<slug> | <issue text>]" };
|
||||
}
|
||||
|
||||
if (head.startsWith("-") && !SUBCOMMANDS.has(head)) {
|
||||
return { type: "error", message: `Unknown debug flag: ${head}.\n${usageText()}` };
|
||||
}
|
||||
|
||||
return { type: "issue-start", issue: raw };
|
||||
}
|
||||
|
||||
export async function handleDebug(args: string, ctx: ExtensionCommandContext, pi?: ExtensionAPI): Promise<void> {
|
||||
const parsed = parseDebugCommand(args);
|
||||
const basePath = process.cwd();
|
||||
|
||||
if (parsed.type === "usage") {
|
||||
ctx.ui.notify(usageText(), "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "error") {
|
||||
ctx.ui.notify(parsed.message, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "issue-start") {
|
||||
const issue = parsed.issue.trim();
|
||||
if (!issue) {
|
||||
ctx.ui.notify(`Issue text is required.\n${usageText()}`, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const created = createDebugSession(basePath, { issue });
|
||||
const s = created.session;
|
||||
const canDispatch = pi != null && typeof (pi as ExtensionAPI).sendMessage === "function";
|
||||
const dispatchNote = canDispatch ? `\ndispatchMode=find_and_fix` : "";
|
||||
ctx.ui.notify(
|
||||
[
|
||||
`Debug session started: ${s.slug}`,
|
||||
formatSessionLine("Session:", s),
|
||||
`Artifact: ${created.artifactPath}`,
|
||||
`Log: ${s.logPath}`,
|
||||
`Next: /sf debug status ${s.slug} or /sf debug continue ${s.slug}`,
|
||||
].join("\n") + dispatchNote,
|
||||
"info",
|
||||
);
|
||||
if (canDispatch) {
|
||||
try {
|
||||
const prompt = loadPrompt("debug-session-manager", {
|
||||
goal: "find_and_fix",
|
||||
issue: s.issue,
|
||||
slug: s.slug,
|
||||
mode: s.mode,
|
||||
workingDirectory: basePath,
|
||||
checkpointContext: "",
|
||||
tddContext: "",
|
||||
specialistContext: "",
|
||||
});
|
||||
pi.sendMessage(
|
||||
{ customType: "sf-debug-start", content: prompt, display: false },
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
ctx.ui.notify(
|
||||
`Debug dispatch failed: ${msg}\nSession '${s.slug}' is persisted; retry with /sf debug continue ${s.slug}`,
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.ui.notify(
|
||||
`Unable to create debug session: ${message}\nTry /sf debug --diagnose for artifact health details.`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "list") {
|
||||
try {
|
||||
const listed = listDebugSessions(basePath);
|
||||
if (listed.sessions.length === 0 && listed.malformed.length === 0) {
|
||||
ctx.ui.notify("No debug sessions found. Start one with: /sf debug <issue-text>", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (listed.sessions.length > 0) {
|
||||
lines.push("Debug sessions:");
|
||||
for (const record of listed.sessions) {
|
||||
lines.push(formatSessionLine(" -", record.session));
|
||||
}
|
||||
}
|
||||
|
||||
if (listed.malformed.length > 0) {
|
||||
lines.push("");
|
||||
lines.push(`Malformed artifacts: ${listed.malformed.length}`);
|
||||
for (const bad of listed.malformed.slice(0, 5)) {
|
||||
lines.push(` - ${bad.artifactPath} :: ${bad.message}`);
|
||||
}
|
||||
if (listed.malformed.length > 5) {
|
||||
lines.push(` ... and ${listed.malformed.length - 5} more`);
|
||||
}
|
||||
lines.push("Run /sf debug --diagnose for remediation guidance.");
|
||||
}
|
||||
|
||||
ctx.ui.notify(lines.join("\n"), "info");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.ui.notify(
|
||||
`Unable to list debug sessions: ${message}\nRun /sf debug --diagnose for details.`,
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "status") {
|
||||
try {
|
||||
const loaded = loadDebugSession(basePath, parsed.slug);
|
||||
if (!loaded) {
|
||||
ctx.ui.notify(
|
||||
`Unknown debug session slug '${parsed.slug}'. Run /sf debug list to see available sessions.`,
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const s = loaded.session;
|
||||
ctx.ui.notify(
|
||||
[
|
||||
`Debug session status: ${s.slug}`,
|
||||
`mode=${s.mode}`,
|
||||
`status=${s.status}`,
|
||||
`phase=${s.phase}`,
|
||||
`issue=${s.issue}`,
|
||||
`artifact=${loaded.artifactPath}`,
|
||||
`log=${s.logPath}`,
|
||||
`updated=${new Date(s.updatedAt).toISOString()}`,
|
||||
`lastError=${s.lastError ?? "none"}`,
|
||||
].join("\n"),
|
||||
"info",
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.ui.notify(
|
||||
`Unable to load debug session '${parsed.slug}': ${message}\nTry /sf debug --diagnose ${parsed.slug}`,
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "continue") {
|
||||
try {
|
||||
const loaded = loadDebugSession(basePath, parsed.slug);
|
||||
if (!loaded) {
|
||||
ctx.ui.notify(
|
||||
`Unknown debug session slug '${parsed.slug}'. Run /sf debug list to see available sessions.`,
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (loaded.session.status === "resolved") {
|
||||
ctx.ui.notify(
|
||||
`Session '${parsed.slug}' is resolved. Open a new session with /sf debug <issue-text> for follow-up work.`,
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine checkpoint/TDD/specialist dispatch context before updating session state.
|
||||
const checkpoint = loaded.session.checkpoint;
|
||||
const tddGate = loaded.session.tddGate;
|
||||
const specialistReview: DebugSpecialistReview | null | undefined = loaded.session.specialistReview;
|
||||
const hasCheckpoint = checkpoint != null && checkpoint.awaitingResponse;
|
||||
const hasTddGate = tddGate != null && tddGate.enabled;
|
||||
|
||||
let dispatchTemplate = "debug-diagnose";
|
||||
let goal = "find_and_fix";
|
||||
let dispatchModeLabel = "find_and_fix";
|
||||
let checkpointContext = "";
|
||||
let tddContext = "";
|
||||
let specialistContext = "";
|
||||
let tddGateUpdate: DebugTddGate | undefined;
|
||||
|
||||
if (hasCheckpoint || hasTddGate) {
|
||||
dispatchTemplate = "debug-session-manager";
|
||||
|
||||
if (hasCheckpoint) {
|
||||
const cpLines = [
|
||||
`## Active Checkpoint`,
|
||||
`- type: ${checkpoint.type}`,
|
||||
`- summary: ${checkpoint.summary}`,
|
||||
];
|
||||
if (checkpoint.userResponse) {
|
||||
cpLines.push(`- userResponse:\n\nDATA_START\n${checkpoint.userResponse}\nDATA_END`);
|
||||
} else {
|
||||
cpLines.push(`- awaitingResponse: true`);
|
||||
}
|
||||
checkpointContext = cpLines.join("\n");
|
||||
dispatchModeLabel = `checkpointType=${checkpoint.type}`;
|
||||
}
|
||||
|
||||
if (hasTddGate) {
|
||||
if (tddGate.phase === "red") {
|
||||
goal = "find_and_fix";
|
||||
const tddLines = [
|
||||
`## TDD Gate`,
|
||||
`- phase: red → green`,
|
||||
];
|
||||
if (tddGate.testFile) tddLines.push(`- testFile: ${tddGate.testFile}`);
|
||||
if (tddGate.testName) tddLines.push(`- testName: ${tddGate.testName}`);
|
||||
if (tddGate.failureOutput) tddLines.push(`- failureOutput:\n${tddGate.failureOutput}`);
|
||||
tddLines.push(`The failing test has been confirmed. Proceed to implement the fix that makes this test pass.`);
|
||||
tddContext = tddLines.join("\n");
|
||||
tddGateUpdate = { ...tddGate, phase: "green" };
|
||||
dispatchModeLabel = "tddPhase=red→green";
|
||||
} else if (tddGate.phase === "green") {
|
||||
goal = "find_and_fix";
|
||||
const tddLines = [
|
||||
`## TDD Gate`,
|
||||
`- phase: green`,
|
||||
];
|
||||
if (tddGate.testFile) tddLines.push(`- testFile: ${tddGate.testFile}`);
|
||||
if (tddGate.testName) tddLines.push(`- testName: ${tddGate.testName}`);
|
||||
tddLines.push(`The test is now passing. Continue verifying the fix.`);
|
||||
tddContext = tddLines.join("\n");
|
||||
dispatchModeLabel = "tddPhase=green";
|
||||
} else {
|
||||
// phase === "pending": investigate only, do not fix yet
|
||||
goal = "find_root_cause_only";
|
||||
const tddLines = [
|
||||
`## TDD Gate`,
|
||||
`- phase: pending`,
|
||||
`TDD mode is active. Write a failing test that captures this bug first. Do NOT fix the issue yet.`,
|
||||
];
|
||||
if (tddGate.testFile) tddLines.push(`- testFile: ${tddGate.testFile}`);
|
||||
tddContext = tddLines.join("\n");
|
||||
dispatchModeLabel = "tddPhase=pending";
|
||||
}
|
||||
} else {
|
||||
// Checkpoint only, no TDD gate — apply fix after human response
|
||||
goal = "find_and_fix";
|
||||
}
|
||||
}
|
||||
|
||||
// Build specialistContext from session's specialistReview field (null/undefined → empty string).
|
||||
if (specialistReview != null) {
|
||||
specialistContext = [
|
||||
`## Prior Specialist Review`,
|
||||
`- hint: ${specialistReview.hint}`,
|
||||
`- skill: ${specialistReview.skill ?? ""}`,
|
||||
`- verdict: ${specialistReview.verdict}`,
|
||||
`- detail: ${specialistReview.detail}`,
|
||||
].join("\n");
|
||||
dispatchModeLabel += ` specialistHint=${specialistReview.hint}`;
|
||||
}
|
||||
|
||||
// Update session state BEFORE dispatch — handler returns after sendMessage.
|
||||
const resumed = updateDebugSession(basePath, parsed.slug, {
|
||||
status: "active",
|
||||
phase: "continued",
|
||||
lastError: null,
|
||||
...(tddGateUpdate !== undefined ? { tddGate: tddGateUpdate } : {}),
|
||||
});
|
||||
|
||||
const canDispatch = pi != null && typeof (pi as ExtensionAPI).sendMessage === "function";
|
||||
const dispatchNote = canDispatch ? `\ndispatchMode=${dispatchModeLabel}` : "";
|
||||
ctx.ui.notify(
|
||||
[
|
||||
`Resumed debug session: ${resumed.session.slug}`,
|
||||
formatSessionLine("Session:", resumed.session),
|
||||
`Log: ${resumed.session.logPath}`,
|
||||
`Next: /sf debug status ${resumed.session.slug}`,
|
||||
].join("\n") + dispatchNote,
|
||||
"info",
|
||||
);
|
||||
|
||||
if (canDispatch) {
|
||||
try {
|
||||
const promptVars: Record<string, string> = {
|
||||
goal,
|
||||
issue: resumed.session.issue,
|
||||
slug: resumed.session.slug,
|
||||
mode: resumed.session.mode,
|
||||
workingDirectory: basePath,
|
||||
};
|
||||
if (dispatchTemplate === "debug-session-manager") {
|
||||
promptVars.checkpointContext = checkpointContext;
|
||||
promptVars.tddContext = tddContext;
|
||||
promptVars.specialistContext = specialistContext;
|
||||
}
|
||||
const prompt = loadPrompt(dispatchTemplate, promptVars);
|
||||
pi.sendMessage(
|
||||
{ customType: "sf-debug-continue", content: prompt, display: false },
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
ctx.ui.notify(
|
||||
`Continue dispatch failed: ${msg}\nSession '${resumed.session.slug}' is persisted; retry with /sf debug continue ${resumed.session.slug}`,
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.ui.notify(
|
||||
`Unable to continue debug session '${parsed.slug}': ${message}\nTry /sf debug --diagnose ${parsed.slug}`,
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "diagnose-issue") {
|
||||
const issue = parsed.issue.trim();
|
||||
if (!issue) {
|
||||
ctx.ui.notify(`Issue text is required.\n${usageText()}`, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const created = createDebugSession(basePath, { issue, mode: "diagnose" });
|
||||
const s = created.session;
|
||||
ctx.ui.notify(
|
||||
[
|
||||
`Diagnose session started: ${s.slug}`,
|
||||
formatSessionLine("Session:", s),
|
||||
`Artifact: ${created.artifactPath}`,
|
||||
`Log: ${s.logPath}`,
|
||||
`dispatchMode=find_root_cause_only`,
|
||||
`Next: /sf debug status ${s.slug} or /sf debug --diagnose ${s.slug}`,
|
||||
].join("\n"),
|
||||
"info",
|
||||
);
|
||||
|
||||
if (pi && typeof pi.sendMessage === "function") {
|
||||
try {
|
||||
const prompt = loadPrompt("debug-diagnose", {
|
||||
goal: "find_root_cause_only",
|
||||
issue: s.issue,
|
||||
slug: s.slug,
|
||||
mode: s.mode,
|
||||
workingDirectory: basePath,
|
||||
});
|
||||
pi.sendMessage(
|
||||
{ customType: "sf-debug-diagnose", content: prompt, display: false },
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
ctx.ui.notify(
|
||||
`Diagnose dispatch failed: ${msg}\nSession '${s.slug}' is persisted; continue manually with /sf debug continue ${s.slug}`,
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.ui.notify(
|
||||
`Unable to create diagnose session: ${message}\nTry /sf debug --diagnose for artifact health details.`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "diagnose") {
|
||||
try {
|
||||
const listed = listDebugSessions(basePath);
|
||||
|
||||
if (parsed.slug) {
|
||||
const loaded = loadDebugSession(basePath, parsed.slug);
|
||||
if (!loaded) {
|
||||
ctx.ui.notify(
|
||||
`Diagnose: session '${parsed.slug}' not found.\nRun /sf debug list to discover valid slugs.`,
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const s = loaded.session;
|
||||
ctx.ui.notify(
|
||||
[
|
||||
`Diagnose session: ${s.slug}`,
|
||||
`mode=${s.mode}`,
|
||||
`status=${s.status}`,
|
||||
`phase=${s.phase}`,
|
||||
`artifact=${loaded.artifactPath}`,
|
||||
`log=${s.logPath}`,
|
||||
`lastError=${s.lastError ?? "none"}`,
|
||||
`malformedArtifactsInStore=${listed.malformed.length}`,
|
||||
].join("\n"),
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"Debug session diagnostics:",
|
||||
`healthySessions=${listed.sessions.length}`,
|
||||
`malformedArtifacts=${listed.malformed.length}`,
|
||||
];
|
||||
|
||||
if (listed.malformed.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("Malformed artifacts (first 10):");
|
||||
for (const malformed of listed.malformed.slice(0, 10)) {
|
||||
lines.push(` - ${malformed.artifactPath}`);
|
||||
lines.push(` ${malformed.message}`);
|
||||
}
|
||||
lines.push("Remediation: repair/remove malformed JSON artifacts under .sf/debug/sessions/.");
|
||||
}
|
||||
|
||||
ctx.ui.notify(lines.join("\n"), listed.malformed.length > 0 ? "warning" : "info");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.ui.notify(`Diagnose failed: ${message}`, "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,377 +0,0 @@
|
|||
import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { atomicWriteSync, type AtomicWriteSyncOps } from "./atomic-write.js";
|
||||
import { sfRoot } from "./paths.js";
|
||||
|
||||
export type DebugSessionStatus = "active" | "paused" | "resolved" | "failed";
|
||||
|
||||
export interface DebugCheckpoint {
|
||||
type: "human-verify" | "human-action" | "decision" | "root-cause-found" | "inconclusive";
|
||||
summary: string;
|
||||
awaitingResponse: boolean;
|
||||
userResponse?: string;
|
||||
}
|
||||
|
||||
export interface DebugTddGate {
|
||||
enabled: boolean;
|
||||
phase: "pending" | "red" | "green";
|
||||
testFile?: string;
|
||||
testName?: string;
|
||||
failureOutput?: string;
|
||||
}
|
||||
|
||||
export interface DebugSpecialistReview {
|
||||
hint: string;
|
||||
skill: string | null;
|
||||
verdict: string;
|
||||
detail: string;
|
||||
reviewedAt: number;
|
||||
}
|
||||
|
||||
export interface DebugSessionArtifact {
|
||||
version: 1;
|
||||
mode: "debug" | "diagnose";
|
||||
slug: string;
|
||||
issue: string;
|
||||
status: DebugSessionStatus;
|
||||
phase: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
logPath: string;
|
||||
lastError: string | null;
|
||||
checkpoint?: DebugCheckpoint | null;
|
||||
tddGate?: DebugTddGate | null;
|
||||
specialistReview?: DebugSpecialistReview | null;
|
||||
}
|
||||
|
||||
export interface DebugSessionRecord {
|
||||
artifactPath: string;
|
||||
session: DebugSessionArtifact;
|
||||
}
|
||||
|
||||
export interface DebugMalformedSessionArtifact {
|
||||
artifactPath: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DebugSessionListResult {
|
||||
sessions: DebugSessionRecord[];
|
||||
malformed: DebugMalformedSessionArtifact[];
|
||||
}
|
||||
|
||||
export interface CreateDebugSessionInput {
|
||||
issue: string;
|
||||
mode?: "debug" | "diagnose";
|
||||
status?: DebugSessionStatus;
|
||||
phase?: string;
|
||||
createdAt?: number;
|
||||
}
|
||||
|
||||
export interface UpdateDebugSessionInput {
|
||||
status?: DebugSessionStatus;
|
||||
phase?: string;
|
||||
issue?: string;
|
||||
lastError?: string | null;
|
||||
updatedAt?: number;
|
||||
checkpoint?: DebugCheckpoint | null;
|
||||
tddGate?: DebugTddGate | null;
|
||||
specialistReview?: DebugSpecialistReview | null;
|
||||
}
|
||||
|
||||
export interface DebugSessionStoreDeps {
|
||||
atomicWrite?: (filePath: string, content: string, encoding?: BufferEncoding) => void;
|
||||
readFile?: (filePath: string, encoding: BufferEncoding) => string;
|
||||
listDir?: (dirPath: string) => string[];
|
||||
exists?: (filePath: string) => boolean;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
const DEFAULT_PHASE = "queued";
|
||||
const DEFAULT_STATUS: DebugSessionStatus = "active";
|
||||
const SESSION_FILE_SUFFIX = ".json";
|
||||
const MAX_SLUG_LENGTH = 64;
|
||||
const MAX_COLLISION_ATTEMPTS = 10_000;
|
||||
|
||||
function debugRoot(basePath: string): string {
|
||||
return join(sfRoot(basePath), "debug");
|
||||
}
|
||||
|
||||
export function debugSessionsDir(basePath: string): string {
|
||||
return join(debugRoot(basePath), "sessions");
|
||||
}
|
||||
|
||||
export function debugSessionArtifactPath(basePath: string, slug: string): string {
|
||||
assertValidDebugSessionSlug(slug);
|
||||
return join(debugSessionsDir(basePath), `${slug}${SESSION_FILE_SUFFIX}`);
|
||||
}
|
||||
|
||||
export function debugSessionLogPath(basePath: string, slug: string): string {
|
||||
assertValidDebugSessionSlug(slug);
|
||||
return join(debugRoot(basePath), `${slug}.log`);
|
||||
}
|
||||
|
||||
function ensureSessionsDir(basePath: string): string {
|
||||
const dir = debugSessionsDir(basePath);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function slugifyDebugSessionIssue(issue: string): string {
|
||||
const normalized = issue
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.slice(0, MAX_SLUG_LENGTH)
|
||||
.replace(/-+$/g, "");
|
||||
|
||||
if (!normalized) {
|
||||
throw new Error("Issue text must contain at least one alphanumeric character.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function assertValidDebugSessionSlug(slug: string): void {
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
|
||||
throw new Error(`Invalid debug session slug: ${slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isDebugSessionStatus(value: unknown): value is DebugSessionStatus {
|
||||
return value === "active" || value === "paused" || value === "resolved" || value === "failed";
|
||||
}
|
||||
|
||||
function isDebugCheckpointShape(value: unknown): value is DebugCheckpoint {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const o = value as Record<string, unknown>;
|
||||
const validTypes = ["human-verify", "human-action", "decision", "root-cause-found", "inconclusive"];
|
||||
return (
|
||||
validTypes.includes(o.type as string)
|
||||
&& typeof o.summary === "string"
|
||||
&& typeof o.awaitingResponse === "boolean"
|
||||
&& (o.userResponse === undefined || typeof o.userResponse === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function isDebugTddGateShape(value: unknown): value is DebugTddGate {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const o = value as Record<string, unknown>;
|
||||
const validPhases = ["pending", "red", "green"];
|
||||
return (
|
||||
typeof o.enabled === "boolean"
|
||||
&& validPhases.includes(o.phase as string)
|
||||
&& (o.testFile === undefined || typeof o.testFile === "string")
|
||||
&& (o.testName === undefined || typeof o.testName === "string")
|
||||
&& (o.failureOutput === undefined || typeof o.failureOutput === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function isDebugSpecialistReviewShape(value: unknown): value is DebugSpecialistReview {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const o = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof o.hint === "string"
|
||||
&& (typeof o.skill === "string" || o.skill === null)
|
||||
&& typeof o.verdict === "string"
|
||||
&& typeof o.detail === "string"
|
||||
&& typeof o.reviewedAt === "number"
|
||||
);
|
||||
}
|
||||
|
||||
function isDebugSessionArtifact(value: unknown): value is DebugSessionArtifact {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const o = value as Record<string, unknown>;
|
||||
return (
|
||||
o.version === 1
|
||||
&& (o.mode === "debug" || o.mode === "diagnose")
|
||||
&& typeof o.slug === "string"
|
||||
&& typeof o.issue === "string"
|
||||
&& isDebugSessionStatus(o.status)
|
||||
&& typeof o.phase === "string"
|
||||
&& typeof o.createdAt === "number"
|
||||
&& typeof o.updatedAt === "number"
|
||||
&& typeof o.logPath === "string"
|
||||
&& (typeof o.lastError === "string" || o.lastError === null)
|
||||
&& (o.checkpoint === undefined || o.checkpoint === null || isDebugCheckpointShape(o.checkpoint))
|
||||
&& (o.tddGate === undefined || o.tddGate === null || isDebugTddGateShape(o.tddGate))
|
||||
&& (o.specialistReview === undefined || o.specialistReview === null || isDebugSpecialistReviewShape(o.specialistReview))
|
||||
);
|
||||
}
|
||||
|
||||
function parseDebugSessionArtifact(filePath: string, raw: string): DebugSessionArtifact {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Failed to parse debug session artifact ${filePath}: ${message}`);
|
||||
}
|
||||
|
||||
if (!isDebugSessionArtifact(parsed)) {
|
||||
throw new Error(`Malformed debug session artifact ${filePath}: schema validation failed`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function defaultDeps(deps: DebugSessionStoreDeps) {
|
||||
return {
|
||||
atomicWrite: deps.atomicWrite ?? atomicWriteSync,
|
||||
readFile: deps.readFile ?? ((filePath: string, encoding: BufferEncoding) => readFileSync(filePath, encoding)),
|
||||
listDir: deps.listDir ?? ((dirPath: string) => readdirSync(dirPath)),
|
||||
exists: deps.exists ?? ((filePath: string) => existsSync(filePath)),
|
||||
now: deps.now ?? (() => Date.now()),
|
||||
};
|
||||
}
|
||||
|
||||
function nextSlug(basePath: string, baseSlug: string, deps: ReturnType<typeof defaultDeps>): string {
|
||||
const baseArtifactPath = debugSessionArtifactPath(basePath, baseSlug);
|
||||
if (!deps.exists(baseArtifactPath)) return baseSlug;
|
||||
|
||||
for (let n = 2; n < MAX_COLLISION_ATTEMPTS; n++) {
|
||||
const candidate = `${baseSlug}-${n}`;
|
||||
const candidatePath = debugSessionArtifactPath(basePath, candidate);
|
||||
if (!deps.exists(candidatePath)) return candidate;
|
||||
}
|
||||
|
||||
throw new Error(`Unable to allocate unique debug session slug for '${baseSlug}'`);
|
||||
}
|
||||
|
||||
function serializeArtifact(session: DebugSessionArtifact): string {
|
||||
return JSON.stringify(session, null, 2) + "\n";
|
||||
}
|
||||
|
||||
export function createDebugSession(
|
||||
basePath: string,
|
||||
input: CreateDebugSessionInput,
|
||||
deps: DebugSessionStoreDeps = {},
|
||||
): DebugSessionRecord {
|
||||
const d = defaultDeps(deps);
|
||||
const issue = input.issue?.trim() ?? "";
|
||||
if (!issue) {
|
||||
throw new Error("Issue text is required to create a debug session.");
|
||||
}
|
||||
|
||||
ensureSessionsDir(basePath);
|
||||
|
||||
const baseSlug = slugifyDebugSessionIssue(issue);
|
||||
const slug = nextSlug(basePath, baseSlug, d);
|
||||
const now = input.createdAt ?? d.now();
|
||||
const session: DebugSessionArtifact = {
|
||||
version: 1,
|
||||
mode: input.mode ?? "debug",
|
||||
slug,
|
||||
issue,
|
||||
status: input.status ?? DEFAULT_STATUS,
|
||||
phase: input.phase ?? DEFAULT_PHASE,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
logPath: debugSessionLogPath(basePath, slug),
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
const artifactPath = debugSessionArtifactPath(basePath, slug);
|
||||
d.atomicWrite(artifactPath, serializeArtifact(session), "utf-8");
|
||||
|
||||
return { artifactPath, session };
|
||||
}
|
||||
|
||||
export function loadDebugSession(
|
||||
basePath: string,
|
||||
slug: string,
|
||||
deps: DebugSessionStoreDeps = {},
|
||||
): DebugSessionRecord | null {
|
||||
assertValidDebugSessionSlug(slug);
|
||||
const d = defaultDeps(deps);
|
||||
|
||||
const artifactPath = debugSessionArtifactPath(basePath, slug);
|
||||
if (!d.exists(artifactPath)) return null;
|
||||
|
||||
const raw = d.readFile(artifactPath, "utf-8");
|
||||
const session = parseDebugSessionArtifact(artifactPath, raw);
|
||||
return { artifactPath, session };
|
||||
}
|
||||
|
||||
export function listDebugSessions(
|
||||
basePath: string,
|
||||
deps: DebugSessionStoreDeps = {},
|
||||
): DebugSessionListResult {
|
||||
const d = defaultDeps(deps);
|
||||
const dir = debugSessionsDir(basePath);
|
||||
if (!d.exists(dir)) return { sessions: [], malformed: [] };
|
||||
|
||||
const entries = d.listDir(dir)
|
||||
.filter(entry => entry.endsWith(SESSION_FILE_SUFFIX))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const sessions: DebugSessionRecord[] = [];
|
||||
const malformed: DebugMalformedSessionArtifact[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const artifactPath = join(dir, entry);
|
||||
try {
|
||||
const raw = d.readFile(artifactPath, "utf-8");
|
||||
const session = parseDebugSessionArtifact(artifactPath, raw);
|
||||
sessions.push({ artifactPath, session });
|
||||
} catch (error) {
|
||||
malformed.push({
|
||||
artifactPath,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => {
|
||||
if (a.session.updatedAt !== b.session.updatedAt) {
|
||||
return b.session.updatedAt - a.session.updatedAt;
|
||||
}
|
||||
if (a.session.createdAt !== b.session.createdAt) {
|
||||
return b.session.createdAt - a.session.createdAt;
|
||||
}
|
||||
return a.session.slug.localeCompare(b.session.slug);
|
||||
});
|
||||
|
||||
return { sessions, malformed };
|
||||
}
|
||||
|
||||
export function updateDebugSession(
|
||||
basePath: string,
|
||||
slug: string,
|
||||
update: UpdateDebugSessionInput,
|
||||
deps: DebugSessionStoreDeps = {},
|
||||
): DebugSessionRecord {
|
||||
const d = defaultDeps(deps);
|
||||
const loaded = loadDebugSession(basePath, slug, d);
|
||||
if (!loaded) {
|
||||
throw new Error(`Debug session not found for slug: ${slug}`);
|
||||
}
|
||||
|
||||
const nextIssue = update.issue?.trim() ?? loaded.session.issue;
|
||||
if (!nextIssue) {
|
||||
throw new Error("Issue text cannot be empty.");
|
||||
}
|
||||
|
||||
const nextStatus = update.status ?? loaded.session.status;
|
||||
if (!isDebugSessionStatus(nextStatus)) {
|
||||
throw new Error(`Invalid debug session status: ${String(update.status)}`);
|
||||
}
|
||||
|
||||
const nextUpdatedAt = update.updatedAt ?? d.now();
|
||||
const session: DebugSessionArtifact = {
|
||||
...loaded.session,
|
||||
issue: nextIssue,
|
||||
status: nextStatus,
|
||||
phase: update.phase ?? loaded.session.phase,
|
||||
lastError: update.lastError === undefined ? loaded.session.lastError : update.lastError,
|
||||
checkpoint: update.checkpoint === undefined ? loaded.session.checkpoint : update.checkpoint,
|
||||
tddGate: update.tddGate === undefined ? loaded.session.tddGate : update.tddGate,
|
||||
specialistReview: update.specialistReview === undefined ? loaded.session.specialistReview : update.specialistReview,
|
||||
updatedAt: nextUpdatedAt,
|
||||
};
|
||||
|
||||
d.atomicWrite(loaded.artifactPath, serializeArtifact(session), "utf-8");
|
||||
return { artifactPath: loaded.artifactPath, session };
|
||||
}
|
||||
|
||||
// Keep this exported for focused fault-injection tests around rename retry behavior.
|
||||
export type { AtomicWriteSyncOps };
|
||||
Loading…
Add table
Reference in a new issue