Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/bak/implementation.ts

Raw
import type {
	ExtensionContext,
	SessionInfo,
} from "@earendil-works/pi-coding-agent";
import { SessionManager } from "@earendil-works/pi-coding-agent";
import type { BakArchive } from "./archive.js";
import { type S3ConfigResolution, s3Config } from "./config.js";
import { closeDebug, dbg } from "./src/debug.ts";
import {
	type CatalogCache,
	clearPending,
	type HostIdentity,
	markPending,
	type PendingEntry,
	readPending,
	type SessionRecord,
	statePaths,
} from "./state.js";

const ICON = "󰁯";
const STATUS_KEY = "bak";
const SHUTDOWN_DRAIN_MS = 2_000;
const SUBCOMMANDS = [
	"init",
	"backup",
	"restore",
	"search",
	"refresh",
	"status",
] as const;
const SEARCH_LIMIT = 10;

type ArchiveModule = typeof import("./archive.js");

interface Backend {
	archive: BakArchive;
	archiveModule: ArchiveModule;
}

function currentSession(ctx: ExtensionContext): SessionInfo | undefined {
	const path = ctx.sessionManager.getSessionFile();
	const header = ctx.sessionManager.getHeader();
	if (!path || !header) return undefined;
	return {
		path,
		id: header.id,
		cwd: header.cwd,
		name: ctx.sessionManager.getSessionName(),
		parentSessionPath: header.parentSession,
		created: new Date(header.timestamp),
		modified: new Date(),
		messageCount: ctx.sessionManager
			.getEntries()
			.filter((entry) => entry.type === "message").length,
		firstMessage: "",
		allMessagesText: "",
	};
}

function errorText(error: unknown): string {
	const text = error instanceof Error ? error.message : String(error);
	return text.replace(/\s+/g, " ").trim().slice(0, 240);
}

function formatAge(timestamp: string | undefined): string {
	if (!timestamp) return "never";
	const age = Date.now() - new Date(timestamp).getTime();
	if (!Number.isFinite(age) || age < 0) return "unknown";
	if (age < 60_000) return "now";
	if (age < 3_600_000) return `${Math.floor(age / 60_000)}m ago`;
	if (age < 86_400_000) return `${Math.floor(age / 3_600_000)}h ago`;
	return `${Math.floor(age / 86_400_000)}d ago`;
}

function destinationArgs(args: string): { target: string; path?: string } {
	const match = /^(\S+)(?:\s+(.+))?$/.exec(args.trim());
	return { target: match?.[1] ?? "", path: match?.[2]?.trim() || undefined };
}

function selectRecords(cache: CatalogCache, target: string): SessionRecord[] {
	if (target === "all") return cache.indexes.flatMap((index) => index.sessions);
	const slash = target.indexOf("/");
	const alias = slash >= 0 ? target.slice(0, slash) : undefined;
	const prefix = slash >= 0 ? target.slice(slash + 1) : target;
	if (!prefix) return [];
	return cache.indexes
		.filter((index) => !alias || index.alias === alias)
		.flatMap((index) => index.sessions)
		.filter((record) => record.id.startsWith(prefix));
}

export function searchRecords(
	cache: CatalogCache,
	query: string,
): { alias: string; record: SessionRecord; prefix: string }[] {
	const needle = query.trim().toLowerCase();
	if (!needle) return [];
	const matches: { alias: string; record: SessionRecord; ids: string[] }[] = [];
	for (const index of cache.indexes) {
		const ids = index.sessions.map((record) => record.id);
		for (const record of index.sessions) {
			if (
				!record.name?.toLowerCase().includes(needle) &&
				!record.cwd.toLowerCase().includes(needle)
			)
				continue;
			matches.push({ alias: index.alias, record, ids });
		}
	}
	matches.sort((a, b) => b.record.created.localeCompare(a.record.created));
	return matches.slice(0, SEARCH_LIMIT).map(({ alias, record, ids }) => ({
		alias,
		record,
		prefix: uniquePrefix(record.id, ids),
	}));
}

function uniquePrefix(id: string, ids: string[]): string {
	for (let length = Math.min(8, id.length); length < id.length; length++) {
		const prefix = id.slice(0, length);
		if (
			!ids.some((candidate) => candidate !== id && candidate.startsWith(prefix))
		)
			return prefix;
	}
	return id;
}

async function localSessions(ctx: ExtensionContext): Promise<SessionInfo[]> {
	const manager = ctx.sessionManager as typeof ctx.sessionManager & {
		usesDefaultSessionDir?: () => boolean;
	};
	// Non-persistent sessions report an empty directory; scanning it would read
	// the working directory instead of the session store.
	const directory = ctx.sessionManager.getSessionDir();
	return !directory || manager.usesDefaultSessionDir?.()
		? SessionManager.listAll()
		: SessionManager.listAll(directory);
}

export function createBakRuntime(
	resolved: S3ConfigResolution = s3Config(),
	missingWarningShown = false,
) {
	const paths = statePaths();
	let backendPromise: Promise<Backend> | undefined;
	const loadBackend = (): Promise<Backend | undefined> => {
		const config = resolved.config;
		if (!config) return Promise.resolve(undefined);
		if (backendPromise) return backendPromise;
		backendPromise = Promise.all([
			import("./archive.js"),
			import("./s3.js"),
		]).then(([archiveModule, { S3Store }]) => ({
			archive: new archiveModule.BakArchive(new S3Store(config), paths),
			archiveModule,
		}));
		return backendPromise;
	};
	let identity: HostIdentity | undefined;
	let cache: CatalogCache | undefined;
	let lastBackupAt: string | undefined;
	let lastError: string | undefined;
	let warnedFailure = false;
	let warnedSetup = missingWarningShown;
	let lifecycle = 0;
	let loadedLifecycle = 0;
	let backgroundLifecycle = 0;
	let serial: Promise<void> = Promise.resolve();
	let readiness: Promise<void> = Promise.resolve();
	let lifecycleController: AbortController | undefined;
	let pending = new Map<string, PendingEntry>();

	const notify = (
		ctx: ExtensionContext,
		message: string,
		level: "info" | "warning" | "error" = "info",
	) => {
		if (ctx.hasUI) ctx.ui.notify(`bak: ${message}`, level);
	};

	const runSerial = <T>(operation: () => Promise<T>): Promise<T> => {
		const result = serial.then(operation, operation);
		serial = result.then(
			() => undefined,
			() => undefined,
		);
		return result;
	};

	const requireArchive = async (
		ctx: ExtensionContext,
	): Promise<BakArchive | undefined> => {
		const backend = await loadBackend();
		if (backend) return backend.archive;
		notify(ctx, `disabled; missing ${resolved.missing.join(", ")}`, "error");
		return undefined;
	};

	const setProgress = (ctx: ExtensionContext, text?: string) => {
		if (ctx.hasUI)
			ctx.ui.setStatus(
				STATUS_KEY,
				text ? ctx.ui.theme.fg("dim", `${ICON} ${text}`) : undefined,
			);
	};

	const addPending = async (path: string): Promise<PendingEntry> => {
		const entry = await markPending(paths.pendingDir, path);
		pending.set(entry.id, entry);
		return entry;
	};

	const removePending = async (entries: PendingEntry[]) => {
		await Promise.all(
			entries.map(async (entry) => {
				await clearPending(paths.pendingDir, entry.id);
				pending.delete(entry.id);
			}),
		);
	};

	const ensureLoaded = (
		ctx: ExtensionContext,
		epoch = lifecycle,
	): Promise<void> => {
		if (!resolved.config || loadedLifecycle === epoch) return readiness;
		loadedLifecycle = epoch;
		readiness = (async () => {
			const backend = await loadBackend();
			if (!backend) return;
			const [savedIdentity, savedPending] = await Promise.all([
				backend.archive.identity(),
				readPending(paths.pendingDir),
			]);
			let savedCache: CatalogCache | undefined;
			try {
				savedCache = await backend.archive.cachedCatalog();
			} catch {
				savedCache = undefined;
			}
			if (epoch !== lifecycle) return;
			identity = savedIdentity;
			cache = savedCache;
			pending = new Map(savedPending.map((entry) => [entry.id, entry]));
			if (!identity && !warnedSetup) {
				notify(ctx, "run /bak init <alias>", "warning");
				warnedSetup = true;
			}
		})();
		return readiness;
	};

	const refresh = async (
		ctx: ExtensionContext,
		signal?: AbortSignal,
	): Promise<CatalogCache | undefined> => {
		const client = await requireArchive(ctx);
		if (!client) return undefined;
		const next = await client.refresh(signal);
		cache = next;
		return next;
	};

	const backupBatch = async (
		infos: SessionInfo[],
		onProgress?: (done: number, total: number) => void,
		replayEntries?: PendingEntry[],
	) => {
		if (infos.length === 0) return { uploaded: 0, indexed: 0 };
		const backend = await loadBackend();
		if (!backend) return { uploaded: 0, indexed: 0 };
		const archive = backend.archive;
		await readiness;
		if (!replayEntries)
			await Promise.all(infos.map((info) => addPending(info.path)));
		const paths = new Set(infos.map((info) => info.path));
		const covered = (replayEntries ?? [...pending.values()]).filter((entry) =>
			paths.has(entry.path),
		);
		if (!identity) throw new Error("Run /bak init <alias> first");
		try {
			const result = await archive.backup(
				infos,
				onProgress,
				lifecycleController?.signal,
			);
			await removePending(covered);
			lastBackupAt = new Date().toISOString();
			lastError = undefined;
			return result;
		} catch (error) {
			if (error instanceof backend.archiveModule.PartialBackupError) {
				const succeeded = new Set(error.succeededPaths);
				await removePending(
					covered.filter((entry) => succeeded.has(entry.path)),
				);
			}
			throw error;
		}
	};

	const startBackground = (ctx: ExtensionContext) => {
		const epoch = lifecycle;
		if (!resolved.config || backgroundLifecycle === epoch) return;
		backgroundLifecycle = epoch;
		void ensureLoaded(ctx, epoch)
			.then(async () => {
				if (!identity || epoch !== lifecycle) return;
				const backend = await loadBackend();
				if (!backend) return;
				try {
					await backend.archive.ensureInitialized(
						identity,
						lifecycleController?.signal,
					);
				} catch (error) {
					if (epoch === lifecycle)
						notify(
							ctx,
							`background init failed: ${errorText(error)}`,
							"warning",
						);
				}
				void refresh(ctx, lifecycleController?.signal).catch((error) => {
					if (epoch === lifecycle && !cache)
						notify(
							ctx,
							`catalog refresh failed: ${errorText(error)}`,
							"warning",
						);
				});
				await retryPending(epoch);
			})
			.catch((error) => {
				if (epoch === lifecycle)
					notify(
						ctx,
						`background state failed: ${errorText(error)}`,
						"warning",
					);
			});
	};

	const automaticBackup = (ctx: ExtensionContext) => {
		startBackground(ctx);
		const info = currentSession(ctx);
		if (!resolved.config || !info) {
			dbg?.("operation.finish", {
				operation: "automatic-backup",
				outcome: "skipped",
			});
			return;
		}
		const epoch = lifecycle;
		void runSerial(async () => {
			const statusTimer = setTimeout(() => {
				if (epoch === lifecycle) setProgress(ctx, "↑");
			}, 300);
			try {
				await backupBatch([info]);
				dbg?.("operation.finish", {
					operation: "automatic-backup",
					outcome: "success",
				});
				if (warnedFailure && epoch === lifecycle) {
					notify(ctx, "backup recovered");
					warnedFailure = false;
				}
			} catch (error) {
				dbg?.("operation.finish", {
					operation: "automatic-backup",
					outcome: "failed",
				});
				lastError = errorText(error);
				if (!warnedFailure && epoch === lifecycle) {
					notify(ctx, `backup failed: ${lastError}; retry pending`, "warning");
					warnedFailure = true;
				}
			} finally {
				clearTimeout(statusTimer);
				if (epoch === lifecycle) setProgress(ctx);
			}
		});
	};

	const retryPending = async (epoch: number) => {
		if (!resolved.config || pending.size === 0 || epoch !== lifecycle) return;
		const backend = await loadBackend();
		if (!backend) return;
		const replayEntries = [...pending.values()];
		const infos: SessionInfo[] = [];
		for (const path of new Set(replayEntries.map((entry) => entry.path))) {
			try {
				infos.push(await backend.archiveModule.sessionInfoFromPath(path));
			} catch (error) {
				lastError = `pending ${path}: ${errorText(error)}`;
			}
		}
		if (infos.length === 0) return;
		await runSerial(() => backupBatch(infos, undefined, replayEntries));
	};

	const sessionStart = (ctx: ExtensionContext) => {
		lifecycle++;
		lifecycleController?.abort();
		lifecycleController = new AbortController();
		loadedLifecycle = 0;
		backgroundLifecycle = 0;
		readiness = Promise.resolve();
		identity = undefined;
		cache = undefined;
		pending = new Map();
		if (!resolved.config && !warnedSetup) {
			notify(
				ctx,
				`disabled; missing ${resolved.missing.join(", ")}`,
				"warning",
			);
			warnedSetup = true;
		}
	};

	/** With automatic=false no upload starts; an explicit /bak backup still drains. */
	const sessionShutdown = async (ctx: ExtensionContext, automatic = true) => {
		const info = currentSession(ctx);
		if (automatic && resolved.config && info) {
			await addPending(info.path).catch((error) => {
				lastError = errorText(error);
			});
		}
		if (automatic) automaticBackup(ctx);
		await Promise.race([
			serial,
			new Promise((resolve) => setTimeout(resolve, SHUTDOWN_DRAIN_MS)),
		]);
		lifecycleController?.abort();
		lifecycle++;
		setProgress(ctx);
	};

	const command = {
		getArgumentCompletions(prefix: string) {
			const normalized = prefix.trimStart();
			const restore = /^restore\s+(\S*)$/i.exec(normalized);
			if (restore) {
				const query = restore[1]?.toLowerCase() ?? "";
				const records =
					cache?.indexes.flatMap((index) => {
						const ids = index.sessions.map((record) => record.id);
						return index.sessions.map((record) => {
							const prefix = uniquePrefix(record.id, ids);
							return {
								value: `restore ${index.alias}/${prefix}`,
								label: `${index.alias}/${prefix}`,
								description: record.name ?? record.cwd,
							};
						});
					}) ?? [];
				const items = [
					{ value: "restore all", label: "all", description: "all sessions" },
					...records,
				].filter((item) => item.label.toLowerCase().startsWith(query));
				return items.length > 0 ? items.slice(0, 100) : null;
			}
			if (normalized.includes(" ")) return null;
			const items = SUBCOMMANDS.filter((command) =>
				command.startsWith(normalized.toLowerCase()),
			).map((command) => ({ value: command, label: command }));
			return items.length > 0 ? items : null;
		},
		async handler(args: string, ctx: ExtensionContext) {
			const trimmed = args.trim();
			const [command = "status", ...rest] = trimmed.split(/\s+/);
			const subcommand =
				SUBCOMMANDS.find((candidate) => candidate === command) ?? "invalid";
			let outcome: "success" | "failed" | "skipped" = "success";
			try {
				if (command === "status") {
					if (!resolved.config) outcome = "skipped";
					else
						await ensureLoaded(ctx).catch((error) => {
							outcome = "failed";
							lastError = errorText(error);
						});
					const indexed = cache?.indexes.reduce(
						(total, index) => total + index.sessions.length,
						0,
					);
					const state = resolved.config
						? identity
							? "ready"
							: "not initialized"
						: `disabled; missing ${resolved.missing.join(", ")}`;
					notify(
						ctx,
						[
							identity?.alias,
							state,
							indexed === undefined ? undefined : `${indexed} indexed`,
							`${new Set([...pending.values()].map((entry) => entry.path)).size} pending`,
							`last backup ${formatAge(lastBackupAt)}`,
							`catalog ${formatAge(cache?.refreshedAt)}`,
							lastError ? `error ${lastError}` : undefined,
						]
							.filter(Boolean)
							.join(" · "),
						lastError ? "warning" : "info",
					);
					return;
				}
				const client = await requireArchive(ctx);
				if (!client) {
					outcome = "skipped";
					return;
				}
				await ensureLoaded(ctx);
				if (command === "init") {
					if (rest.length !== 1) throw new Error("Usage: /bak init <alias>");
					setProgress(ctx, "initializing");
					identity = await runSerial(() =>
						client.init(rest[0] ?? "", lifecycleController?.signal),
					);
					cache = await refresh(ctx, lifecycleController?.signal);
					warnedSetup = false;
					void retryPending(lifecycle).catch((error) => {
						lastError = errorText(error);
						notify(ctx, `pending retry failed: ${lastError}`, "warning");
					});
					notify(ctx, `initialized as ${identity.alias}`);
					return;
				}
				if (command === "refresh") {
					if (rest.length > 0) throw new Error("Usage: /bak refresh");
					setProgress(ctx, "refreshing");
					const next = await runSerial(() =>
						client.refresh(lifecycleController?.signal),
					);
					cache = next;
					notify(ctx, `refreshed ${next.indexes.length} host(s)`);
					return;
				}
				if (command === "backup") {
					if (rest.length > 0) throw new Error("Usage: /bak backup");
					setProgress(ctx, "scanning");
					const infos = await localSessions(ctx);
					const result = await runSerial(() =>
						backupBatch(infos, (done, total) =>
							setProgress(ctx, `↑ ${done}/${total}`),
						),
					);
					notify(
						ctx,
						`backed up ${result.uploaded}; ${result.indexed} indexed`,
					);
					return;
				}
				if (command === "search") {
					const query = rest.join(" ").trim();
					if (!query) throw new Error("Usage: /bak search <name or path>");
					if (!cache) {
						setProgress(ctx, "refreshing");
						cache = await runSerial(() =>
							client.refresh(lifecycleController?.signal),
						);
					}
					const results = searchRecords(cache, query);
					notify(
						ctx,
						results.length
							? results
									.map(
										({ alias, record, prefix }) =>
											`${alias}/${prefix} ${record.name ?? record.cwd} (${record.created.slice(0, 10)})`,
									)
									.join("\n") +
									(results.length === SEARCH_LIMIT
										? "\nShowing first 10; narrow query for more"
										: "")
							: `no sessions match ${query}`,
					);
					return;
				}
				if (command === "restore") {
					const parsed = destinationArgs(rest.join(" "));
					if (!parsed.target)
						throw new Error(
							"Usage: /bak restore <alias>/<id-prefix>|all [path]",
						);
					setProgress(ctx, "refreshing");
					const current = await runSerial(() =>
						client.refresh(lifecycleController?.signal),
					);
					cache = current;
					const records = selectRecords(current, parsed.target);
					if (records.length === 0)
						throw new Error(`No session matches ${parsed.target}`);
					if (parsed.target !== "all" && records.length !== 1)
						throw new Error(`Ambiguous session: ${parsed.target}`);
					const result = await runSerial(() =>
						client.restore(
							records,
							parsed.path ?? ctx.sessionManager.getSessionDir(),
							(done, total) => setProgress(ctx, `↓ ${done}/${total}`),
							lifecycleController?.signal,
						),
					);
					notify(
						ctx,
						`restored ${result.restored}, skipped ${result.skipped}, conflicts ${result.conflicts}`,
						result.conflicts > 0 ? "warning" : "info",
					);
					return;
				}
				throw new Error(
					"Usage: /bak init <alias> | backup | restore <alias>/<id-prefix>|all [path] | search <name or path> | refresh | status",
				);
			} catch (error) {
				outcome = "failed";
				lastError = errorText(error);
				notify(ctx, lastError, "error");
			} finally {
				dbg?.("operation.finish", {
					operation: "command",
					outcome,
					subcommand,
				});
				if (command !== "status") setProgress(ctx);
			}
		},
	};

	return {
		automaticBackup,
		closeDebugWhenIdle() {
			void serial.finally(closeDebug);
		},
		command,
		sessionShutdown,
		sessionStart,
	};
}