Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/the-system/board-state.ts

Raw
import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { BOARD_NOTE_LIMIT } from "./board-constants.ts";
import type { BundleDocumentRecord, DocumentMetadata } from "./documents.ts";
import { resolveGovernedPath, SystemDocumentError } from "./project-root.ts";
import { parseDocumentId } from "./system-config.ts";

export const BOARD_SCHEMA_VERSION = 0;
export const BOARD_PATH = ".system/board.json";

export { BOARD_NOTE_LIMIT } from "./board-constants.ts";
export type BoardItemState = "todo" | "done" | "open";
export type BoardItemSource = "manual" | "assess" | "agent";
export type BoardEntry = Readonly<{
	state: BoardItemState;
	updatedAt: string;
	source: BoardItemSource;
	note?: string;
}>;
export type BoardProposal = Readonly<{
	id: string;
	state: BoardItemState;
	rationale: string;
}>;
export type BoardState = Readonly<{
	schemaVersion: number;
	items: Readonly<Record<string, BoardEntry>>;
}>;

const objectValue = (
	value: unknown,
	label: string,
): Record<string, unknown> => {
	if (typeof value !== "object" || value === null || Array.isArray(value))
		throw new SystemDocumentError(
			"board.shape",
			`${label} must be a JSON object`,
		);
	return value as Record<string, unknown>;
};

const exactKeys = (
	value: Record<string, unknown>,
	required: readonly string[],
	label: string,
	optional: readonly string[] = [],
) => {
	const actual = Object.keys(value).sort();
	if (
		required.some((key) => !(key in value)) ||
		actual.some((key) => !required.includes(key) && !optional.includes(key))
	)
		throw new SystemDocumentError(
			"board.keys",
			`${label} expected keys ${[...required].sort().join(", ")}; received ${actual.join(", ")}`,
		);
};

const validState = (type: DocumentMetadata["type"], state: string): boolean => {
	switch (type) {
		case "spec":
		case "plan":
			return state === "todo" || state === "done";
		case "issue":
			return state === "open" || state === "done";
		default:
			return false;
	}
};

export function parseBoardState(source: string, prefix: string): BoardState {
	let parsed: unknown;
	try {
		parsed = JSON.parse(source);
	} catch {
		throw new SystemDocumentError("board.invalid-json", "Invalid board JSON");
	}
	const value = objectValue(parsed, "Board");
	if (value.schemaVersion !== BOARD_SCHEMA_VERSION)
		throw new SystemDocumentError(
			"board.version",
			`Board schema version ${String(value.schemaVersion)} does not match ${BOARD_SCHEMA_VERSION}; run /system init`,
		);
	exactKeys(value, ["items", "schemaVersion"], "Board");
	const rawItems = objectValue(value.items, "Board items");
	const items: Record<string, BoardEntry> = {};
	for (const [id, rawEntry] of Object.entries(rawItems)) {
		const parsedId = parseDocumentId(id, prefix);
		if (!["spec", "plan", "issue"].includes(parsedId.type))
			throw new SystemDocumentError(
				"board.item-type",
				`Board item must reference a spec, plan, or issue: ${id}`,
			);
		const entry = objectValue(rawEntry, `Board item ${id}`);
		exactKeys(entry, ["source", "state", "updatedAt"], `Board item ${id}`, [
			"note",
		]);
		if (typeof entry.state !== "string")
			throw new SystemDocumentError(
				"board.state",
				`Board item state must be a string: ${id}`,
			);
		if (!validState(parsedId.type, entry.state))
			throw new SystemDocumentError(
				"board.state",
				`Invalid ${parsedId.type} board state ${entry.state}: ${id}`,
			);
		if (
			entry.source !== "manual" &&
			entry.source !== "assess" &&
			entry.source !== "agent"
		)
			throw new SystemDocumentError(
				"board.source",
				`Invalid board source for ${id}`,
			);
		if (
			entry.note !== undefined &&
			(typeof entry.note !== "string" ||
				entry.note.trim().length === 0 ||
				entry.note.length > BOARD_NOTE_LIMIT)
		)
			throw new SystemDocumentError(
				"board.note",
				`Board note must be 1-${BOARD_NOTE_LIMIT} characters: ${id}`,
			);
		if (
			typeof entry.updatedAt !== "string" ||
			Number.isNaN(Date.parse(entry.updatedAt)) ||
			new Date(entry.updatedAt).toISOString() !== entry.updatedAt
		)
			throw new SystemDocumentError(
				"board.updated-at",
				`Board updatedAt must be a canonical ISO timestamp: ${id}`,
			);
		items[id] = {
			state: entry.state as BoardItemState,
			updatedAt: entry.updatedAt,
			source: entry.source,
			...(entry.note === undefined ? {} : { note: entry.note as string }),
		};
	}
	return { schemaVersion: BOARD_SCHEMA_VERSION, items };
}

export function boardStateForInitialization(
	source: string,
	prefix: string,
): { state: BoardState; sourceSchemaVersion?: number } {
	try {
		return { state: parseBoardState(source, prefix) };
	} catch (error) {
		if (
			!(error instanceof SystemDocumentError) ||
			error.code !== "board.version"
		)
			throw error;
		const parsed = objectValue(JSON.parse(source), "Board");
		if (
			typeof parsed.schemaVersion !== "number" ||
			!Number.isInteger(parsed.schemaVersion)
		)
			throw error;
		const state = parseBoardState(
			JSON.stringify({
				schemaVersion: BOARD_SCHEMA_VERSION,
				items: parsed.items,
			}),
			prefix,
		);
		return { state, sourceSchemaVersion: parsed.schemaVersion };
	}
}

export const emptyBoardState = (): BoardState => ({
	schemaVersion: BOARD_SCHEMA_VERSION,
	items: {},
});

export function boardStateSource(state: BoardState): string {
	return `${JSON.stringify(
		{
			schemaVersion: BOARD_SCHEMA_VERSION,
			items: Object.fromEntries(
				Object.entries(state.items).sort(([left], [right]) =>
					left.localeCompare(right),
				),
			),
		},
		null,
		"\t",
	)}\n`;
}

export function readBoardState(root: string, prefix: string): BoardState {
	return parseBoardState(
		readFileSync(resolveGovernedPath(root, BOARD_PATH), "utf8"),
		prefix,
	);
}

export function defaultBoardState(metadata: DocumentMetadata): BoardItemState {
	return metadata.type === "issue" ? "open" : "todo";
}

export function boardStateFor(
	board: BoardState,
	metadata: DocumentMetadata,
): BoardItemState {
	return board.items[metadata.id]?.state ?? defaultBoardState(metadata);
}

export function updateBoardState(
	board: BoardState,
	documents: readonly BundleDocumentRecord[],
	id: string,
	state: BoardItemState,
	source: BoardItemSource,
	updatedAt = new Date().toISOString(),
	note?: string,
): BoardState {
	const document = documents.find((candidate) => candidate.metadata.id === id);
	if (!document)
		throw new SystemDocumentError(
			"board.missing-item",
			`No System board item: ${id}`,
		);
	if (!validState(document.metadata.type, state))
		throw new SystemDocumentError(
			"board.state",
			`Invalid ${document.metadata.type} board state ${state}: ${id}`,
		);
	const trimmedNote = note?.trim();
	if (trimmedNote !== undefined && trimmedNote.length > BOARD_NOTE_LIMIT)
		throw new SystemDocumentError(
			"board.note",
			`Board note must be 1-${BOARD_NOTE_LIMIT} characters: ${id}`,
		);
	const items: Record<string, BoardEntry> = { ...board.items };
	items[id] = {
		state,
		source,
		updatedAt,
		...(trimmedNote ? { note: trimmedNote } : {}),
	};
	if (document.metadata.type === "spec" && state === "done") {
		const unfinished = documents.filter(
			(candidate) =>
				candidate.metadata.type === "plan" &&
				candidate.metadata.spec === id &&
				(items[candidate.metadata.id]?.state ?? "todo") !== "done",
		);
		if (unfinished.length)
			throw new SystemDocumentError(
				"board.spec-plans",
				`Spec ${id} cannot be done while plans remain todo: ${unfinished.map((item) => item.metadata.id).join(", ")}`,
			);
	}
	if (document.metadata.type === "plan" && state === "todo") {
		const specId = document.metadata.spec;
		const spec = documents.find(
			(candidate) =>
				candidate.metadata.type === "spec" && candidate.metadata.id === specId,
		);
		if (spec && (items[spec.metadata.id]?.state ?? "todo") === "done")
			items[spec.metadata.id] = { state: "todo", source, updatedAt };
	}
	return { schemaVersion: BOARD_SCHEMA_VERSION, items };
}

export function writeBoardState(root: string, state: BoardState): void {
	const target = resolveGovernedPath(root, BOARD_PATH);
	const temporary = path.join(
		path.dirname(target),
		`.board-${process.pid}-${Date.now()}.json`,
	);
	writeFileSync(temporary, boardStateSource(state), {
		encoding: "utf8",
		flag: "wx",
	});
	try {
		renameSync(temporary, target);
	} finally {
		rmSync(temporary, { force: true });
	}
}