import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, relative } from "node:path";
import type { DiscoveryPlanIndex, DiscoveryPlanRecord, DiscoveryPlanStatus } from "../protocol/types.js";
import { planMarkdownPath, type AlwaysOnPaths } from "./AlwaysOnPaths.js";
const DEFAULT_INDEX: DiscoveryPlanIndex = { schemaVersion: 1, plans: [] };
export class DiscoveryPlanStore {
constructor(private readonly paths: AlwaysOnPaths) {}
async readIndex(): Promise<DiscoveryPlanIndex> {
let raw: string;
try {
raw = await readFile(this.paths.planIndexFile, "utf-8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return cloneIndex(DEFAULT_INDEX);
}
throw error;
}
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && parsed.schemaVersion === 1 && Array.isArray(parsed.plans)) {
return parsed as DiscoveryPlanIndex;
}
} catch {
}
return cloneIndex(DEFAULT_INDEX);
}
async writeIndex(index: DiscoveryPlanIndex): Promise<void> {
await mkdir(dirname(this.paths.planIndexFile), { recursive: true });
await writeFile(this.paths.planIndexFile, JSON.stringify(index, null, 2), "utf-8");
}
async writePlanMarkdown(planId: string, markdown: string): Promise<string> {
const filePath = planMarkdownPath(this.paths, planId);
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, markdown, "utf-8");
return filePath;
}
async readPlanMarkdown(planId: string): Promise<string | undefined> {
const filePath = planMarkdownPath(this.paths, planId);
try {
return await readFile(filePath, "utf-8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return undefined;
}
throw error;
}
}
async upsert(record: DiscoveryPlanRecord): Promise<DiscoveryPlanRecord> {
const index = await this.readIndex();
const existingIndex = index.plans.findIndex((entry) => entry.id === record.id);
const stored = freezeRecord(toRelativePaths(record, this.paths));
if (existingIndex >= 0) {
index.plans[existingIndex] = stored;
} else {
index.plans.push(stored);
}
await this.writeIndex(index);
return stored;
}
async updateStatus(
planId: string,
update: {
status?: DiscoveryPlanStatus;
reportFilePath?: string;
workCycleId?: string;
},
): Promise<DiscoveryPlanRecord | undefined> {
const index = await this.readIndex();
const target = index.plans.find((entry) => entry.id === planId);
if (!target) return undefined;
if (update.status !== undefined) {
target.status = update.status;
const raw = target as Record<string, unknown>;
if ("executionStatus" in raw && (update.status === "completed" || update.status === "failed")) {
raw.executionStatus = update.status;
}
}
if (update.reportFilePath !== undefined) {
target.reportFilePath = relativeIfInsideRoot(update.reportFilePath, this.paths.projectDir);
}
if (update.workCycleId !== undefined) {
target.workCycleId = update.workCycleId;
delete target.workspace;
}
await this.writeIndex(index);
return target;
}
async getRecord(planId: string): Promise<DiscoveryPlanRecord | undefined> {
const index = await this.readIndex();
return index.plans.find((entry) => entry.id === planId);
}
}
function cloneIndex(index: DiscoveryPlanIndex): DiscoveryPlanIndex {
return { schemaVersion: 1, plans: index.plans.map((entry) => ({ ...entry })) };
}
function toRelativePaths(record: DiscoveryPlanRecord, paths: AlwaysOnPaths): DiscoveryPlanRecord {
return {
...record,
planFilePath: relativeIfInsideRoot(record.planFilePath, paths.projectDir),
reportFilePath: record.reportFilePath
? relativeIfInsideRoot(record.reportFilePath, paths.projectDir)
: undefined,
};
}
function relativeIfInsideRoot(filePath: string, root: string): string {
const rel = relative(root, filePath);
if (rel.startsWith("..") || rel === "") {
return filePath;
}
return rel;
}
function freezeRecord(record: DiscoveryPlanRecord): DiscoveryPlanRecord {
return { ...record };
}