"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.VariantHistoryService = void 0;
const fs_1 = __importDefault(require("fs"));
const promises_1 = __importDefault(require("fs/promises"));
const path_1 = __importDefault(require("path"));
const logger_1 = require("../utils/logger");
const log = (0, logger_1.createLogger)('VariantHistoryService');
const HISTORY_SCHEMA_VERSION = 2;
const RIVET_DIRNAME = '.rivet';
const VARIANTS_DIRNAME = 'variants';
const SESSION_FILENAME = 'session.json';
const MANIFEST_FILENAME = 'manifest.json';
const DIFF_FILENAME = 'diff.patch';
const FILES_DIRNAME = 'files';
const DESIGN_FILENAME = 'DESIGN.md';
const STATIC_INDEX_FILENAME = 'index.html';
const GITIGNORE_FILENAME = '.gitignore';
const GITIGNORE_LINE = '.rivet/';
const COPY_EXCLUDE = new Set([
    'node_modules',
    '.git',
    '.vite',
    '.rivet',
    'dist',
    'build',
    '.next',
    '.cache',
]);
/** Statuses that are end-states from the user's perspective — once a variant
 *  reaches one of these, history must not silently regress it (e.g. a late
 *  `persistVariant` from the success path shouldn't reset a `committed`
 *  manifest to `completed`, and `markPersistedVariantsCancelled` shouldn't
 *  overwrite the chosen variant's `committed` status with `cancelled`). */
const TERMINAL_STATUSES = new Set([
    'committed',
    'rejected',
    // `removed` is terminal so a late success-path `persistVariant` /
    // `markStatus` can't silently un-remove a variant the user dismissed.
    'removed',
]);
/** Reject ids containing path separators, `..`, or NUL bytes so caller-
 *  controlled REST/MCP input can't escape `<project>/.rivet/variants/`. ids
 *  are produced by `crypto.randomUUID()` and stored slugs, both of which fit
 *  this character class. */
const SAFE_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
const assertSafeId = (value, name) => {
    if (!SAFE_ID_PATTERN.test(value)) {
        throw new Error(`VariantHistoryService: invalid ${name} '${value}' — must match ${SAFE_ID_PATTERN}`);
    }
};
const assertSafeAssetPath = (value) => {
    if (value.length === 0 ||
        value.includes('\0') ||
        path_1.default.isAbsolute(value) ||
        value.split(/[\\/]+/).some((part) => part === '..')) {
        throw new Error(`VariantHistoryService: invalid assetPath '${value}'`);
    }
};
class VariantHistoryService {
    variantsRoot(projectPath) {
        return path_1.default.join(projectPath, RIVET_DIRNAME, VARIANTS_DIRNAME);
    }
    sessionDir(projectPath, sessionId) {
        return path_1.default.join(this.variantsRoot(projectPath), sessionId);
    }
    variantDir(projectPath, sessionId, variantId) {
        return path_1.default.join(this.sessionDir(projectPath, sessionId), variantId);
    }
    async persistVariant(args) {
        assertSafeId(args.sessionId, 'sessionId');
        assertSafeId(args.variantId, 'variantId');
        if (args.kind === 'diff' && args.diff === undefined) {
            throw new Error(`persistVariant: kind='diff' requires a diff body (sessionId=${args.sessionId} variantId=${args.variantId})`);
        }
        if (args.kind === 'project-created' && !args.sourceDir) {
            throw new Error(`persistVariant: kind='project-created' requires sourceDir (sessionId=${args.sessionId} variantId=${args.variantId})`);
        }
        const sessionDir = this.sessionDir(args.projectPath, args.sessionId);
        const variantDir = this.variantDir(args.projectPath, args.sessionId, args.variantId);
        await promises_1.default.mkdir(sessionDir, { recursive: true });
        // Race guard: persistVariant runs at code_gen success and again on retries
        // / late callbacks. If the manifest has already reached a terminal status
        // via markStatus (committed/rejected), preserve that status so the late
        // persist doesn't silently reset the user's pick to `completed`.
        const existingManifestPath = path_1.default.join(variantDir, MANIFEST_FILENAME);
        let preservedStatus;
        let preservedStatusChangedAt;
        let preservedDestinationPath;
        let preservedCreatedAt;
        try {
            const raw = await promises_1.default.readFile(existingManifestPath, 'utf8');
            const prior = JSON.parse(raw);
            preservedCreatedAt = prior.createdAt;
            if (TERMINAL_STATUSES.has(prior.status)) {
                preservedStatus = prior.status;
                preservedStatusChangedAt = prior.statusChangedAt;
                preservedDestinationPath = prior.destinationPath;
            }
        }
        catch (err) {
            const code = err.code;
            if (code !== 'ENOENT') {
                log.warn(`persistVariant: prior manifest read failed at ${existingManifestPath}`, err);
            }
        }
        const sessionPath = path_1.default.join(sessionDir, SESSION_FILENAME);
        if (!fs_1.default.existsSync(sessionPath)) {
            const sessionFile = {
                schemaVersion: HISTORY_SCHEMA_VERSION,
                sessionId: args.sessionId,
                createdAt: new Date().toISOString(),
                sessionPrompt: args.sessionPrompt,
                projectKind: args.projectKind,
            };
            await promises_1.default.writeFile(sessionPath, `${JSON.stringify(sessionFile, null, 2)}\n`, 'utf8');
        }
        const preview = resolvePreview(args);
        if (preview.kind === 'static' && !args.sourceDir) {
            throw new Error(`persistVariant: preview.kind='static' requires sourceDir (sessionId=${args.sessionId} variantId=${args.variantId})`);
        }
        const shouldCopyFiles = args.kind === 'project-created' || args.sourceDir !== undefined;
        const artifact = args.kind === 'diff'
            ? {
                kind: 'diff',
                diffPath: DIFF_FILENAME,
                ...(shouldCopyFiles ? { filesPath: FILES_DIRNAME } : {}),
            }
            : {
                kind: 'project',
                filesPath: FILES_DIRNAME,
                ...(preview.kind === 'dev-server' && preview.runnablePath
                    ? { runnablePath: preview.runnablePath }
                    : {}),
            };
        const stagingDir = path_1.default.join(sessionDir, `${args.variantId}.tmp-${process.pid}-${Date.now()}`);
        await promises_1.default.rm(stagingDir, { recursive: true, force: true });
        await promises_1.default.mkdir(stagingDir, { recursive: true });
        const now = new Date().toISOString();
        const manifest = {
            schemaVersion: HISTORY_SCHEMA_VERSION,
            variantId: args.variantId,
            sessionId: args.sessionId,
            label: args.label,
            brief: args.brief,
            sessionPrompt: args.sessionPrompt,
            runLabel: args.runLabel,
            projectKind: args.projectKind,
            status: preservedStatus ?? args.initialStatus ?? 'completed',
            createdAt: preservedCreatedAt ?? now,
            statusChangedAt: preservedStatusChangedAt ?? now,
            baseSha: args.baseSha,
            destinationPath: preservedDestinationPath ?? args.destinationPath,
            changedFilesCount: args.changedFilesCount,
            artifact,
            preview,
            designSource: args.designSource,
        };
        await promises_1.default.writeFile(path_1.default.join(stagingDir, MANIFEST_FILENAME), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
        if (args.kind === 'diff') {
            await promises_1.default.writeFile(path_1.default.join(stagingDir, DIFF_FILENAME), args.diff ?? '', 'utf8');
        }
        if (shouldCopyFiles) {
            const filesDir = path_1.default.join(stagingDir, FILES_DIRNAME);
            await copyDirFiltered(args.sourceDir, filesDir);
        }
        if (args.designMarkdown !== undefined) {
            await promises_1.default.writeFile(path_1.default.join(stagingDir, DESIGN_FILENAME), args.designMarkdown, 'utf8');
        }
        await promises_1.default.rm(variantDir, { recursive: true, force: true });
        await promises_1.default.rename(stagingDir, variantDir);
        await this.ensureGitignore(args.projectPath);
        log.info(`Persisted variant ${args.variantId} (session ${args.sessionId}, kind ${args.kind}, status ${manifest.status})`);
    }
    async markStatus(args) {
        assertSafeId(args.sessionId, 'sessionId');
        assertSafeId(args.variantId, 'variantId');
        const manifestPath = path_1.default.join(this.variantDir(args.projectPath, args.sessionId, args.variantId), MANIFEST_FILENAME);
        let manifest;
        try {
            const raw = await promises_1.default.readFile(manifestPath, 'utf8');
            manifest = JSON.parse(raw);
        }
        catch (err) {
            // ENOENT is the expected "variant was never persisted" case — any
            // caller that drives the state machine via stubs skips the persistence
            // hook. Only surface unexpected errors (permission denied, corrupt JSON).
            const code = err.code;
            if (code !== 'ENOENT') {
                log.warn(`markStatus: manifest read failed at ${manifestPath} (session ${args.sessionId} variant ${args.variantId})`, err);
            }
            return;
        }
        // Refuse to downgrade a terminal status. Specifically blocks
        // markPersistedVariantsCancelled from rewriting a committed/rejected
        // variant as `cancelled` when the session tears down post-commit, and
        // prevents stray late-cancels from clobbering a finalized pick.
        if (TERMINAL_STATUSES.has(manifest.status) &&
            manifest.status !== args.status) {
            return;
        }
        const destinationChanged = args.destinationPath !== undefined &&
            manifest.destinationPath !== args.destinationPath;
        const runnablePathChanged = args.runnablePath !== undefined &&
            manifest.preview.kind === 'dev-server' &&
            manifest.preview.runnablePath !== args.runnablePath;
        if (manifest.status === args.status &&
            !destinationChanged &&
            !runnablePathChanged)
            return;
        manifest.status = args.status;
        manifest.statusChangedAt = new Date().toISOString();
        if (args.destinationPath !== undefined) {
            manifest.destinationPath = args.destinationPath;
        }
        if (args.runnablePath !== undefined) {
            if (manifest.artifact.kind === 'project') {
                manifest.artifact.runnablePath = args.runnablePath;
            }
            if (manifest.preview.kind === 'dev-server') {
                manifest.preview.runnablePath = args.runnablePath;
            }
        }
        await writeManifestFile(manifestPath, manifest);
    }
    /**
     * Rename a persisted variant: rewrites `manifest.label` on disk so the
     * history list (and any future session that reads it) shows the new title.
     * Pure metadata edit — `status`, `statusChangedAt`, and everything else are
     * left untouched, and a rename is allowed regardless of lifecycle status (a
     * committed or rejected variant can still be relabeled). No-ops (ENOENT) when
     * the variant was never persisted, and short-circuits when the label is
     * unchanged so we don't churn the file.
     */
    async renameVariant(args) {
        assertSafeId(args.sessionId, 'sessionId');
        assertSafeId(args.variantId, 'variantId');
        const manifestPath = path_1.default.join(this.variantDir(args.projectPath, args.sessionId, args.variantId), MANIFEST_FILENAME);
        let manifest;
        try {
            const raw = await promises_1.default.readFile(manifestPath, 'utf8');
            manifest = JSON.parse(raw);
        }
        catch (err) {
            const code = err.code;
            if (code !== 'ENOENT') {
                log.warn(`renameVariant: manifest read failed at ${manifestPath} (session ${args.sessionId} variant ${args.variantId})`, err);
            }
            return;
        }
        if (manifest.label === args.label)
            return;
        manifest.label = args.label;
        await writeManifestFile(manifestPath, manifest);
    }
    /**
     * Append a public deployment to a variant's manifest. Unlike `markStatus`,
     * this never touches `status` — a deployed variant keeps whatever lifecycle
     * status it already has (a user can share an uncommitted prototype). The
     * read-patch-write is atomic via `writeManifestFile`; concurrent deploys of
     * the same variant are not expected (the UI disables the button while busy).
     */
    async recordDeployment(args) {
        assertSafeId(args.sessionId, 'sessionId');
        assertSafeId(args.variantId, 'variantId');
        assertSafeId(args.deployment.deployId, 'deployId');
        const manifestPath = path_1.default.join(this.variantDir(args.projectPath, args.sessionId, args.variantId), MANIFEST_FILENAME);
        const manifest = await readManifest(manifestPath);
        if (!manifest) {
            throw new Error(`recordDeployment: no manifest for ${args.sessionId}/${args.variantId}`);
        }
        manifest.deployments = [
            ...(manifest.deployments ?? []),
            args.deployment,
        ];
        await writeManifestFile(manifestPath, manifest);
    }
    async listVariants(args) {
        const root = this.variantsRoot(args.projectPath);
        if (!fs_1.default.existsSync(root))
            return [];
        let sessionIds;
        try {
            sessionIds = await promises_1.default.readdir(root);
        }
        catch (err) {
            log.warn(`listVariants: failed to read ${root}`, err);
            return [];
        }
        const entries = [];
        for (const sessionId of sessionIds) {
            const sessionDir = path_1.default.join(root, sessionId);
            const stat = await safeStat(sessionDir);
            if (!stat?.isDirectory())
                continue;
            let variantIds;
            try {
                variantIds = await promises_1.default.readdir(sessionDir);
            }
            catch {
                continue;
            }
            for (const variantId of variantIds) {
                const variantDir = path_1.default.join(sessionDir, variantId);
                const vStat = await safeStat(variantDir);
                if (!vStat?.isDirectory())
                    continue;
                const manifest = await readManifest(path_1.default.join(variantDir, MANIFEST_FILENAME));
                if (!manifest)
                    continue;
                if (args.status && manifest.status !== args.status)
                    continue;
                const hasStaticPreview = await hasStaticPreviewFile(variantDir, manifest);
                entries.push({ manifest, variantDir, hasStaticPreview });
            }
        }
        entries.sort((a, b) => b.manifest.createdAt.localeCompare(a.manifest.createdAt));
        if (args.limit !== undefined && args.limit > 0) {
            return entries.slice(0, args.limit);
        }
        return entries;
    }
    async getVariant(args) {
        assertSafeId(args.sessionId, 'sessionId');
        assertSafeId(args.variantId, 'variantId');
        const variantDir = this.variantDir(args.projectPath, args.sessionId, args.variantId);
        const manifest = await readManifest(path_1.default.join(variantDir, MANIFEST_FILENAME));
        if (!manifest)
            return null;
        const hasStaticPreview = await hasStaticPreviewFile(variantDir, manifest);
        const detail = {
            manifest,
            variantDir,
            hasStaticPreview,
        };
        if (manifest.artifact.kind === 'diff') {
            try {
                detail.diff = await promises_1.default.readFile(path_1.default.join(variantDir, DIFF_FILENAME), 'utf8');
            }
            catch {
                detail.diff = '';
            }
        }
        if (manifest.artifact.filesPath) {
            detail.files = await listFilesRecursive(path_1.default.join(variantDir, FILES_DIRNAME));
        }
        try {
            detail.designMarkdown = await promises_1.default.readFile(path_1.default.join(variantDir, DESIGN_FILENAME), 'utf8');
        }
        catch (err) {
            const code = err.code;
            if (code !== 'ENOENT') {
                log.warn(`getVariant: DESIGN.md read failed at ${variantDir}`, err);
            }
        }
        return detail;
    }
    /**
     * Read the persisted static-preview HTML for a variant. Returns null when
     * the variant isn't on disk or doesn't have a `files/index.html`. Used by
     * the REST history-static endpoint that backs the AgentVariantsPanel
     * iframe when a past variant is clicked.
     */
    async readStaticPreview(args) {
        assertSafeId(args.sessionId, 'sessionId');
        assertSafeId(args.variantId, 'variantId');
        try {
            const result = await this.readStaticPreviewFile({
                ...args,
                assetPath: null,
            });
            return result ? result.body.toString('utf8') : null;
        }
        catch (err) {
            const code = err.code;
            if (code !== 'ENOENT') {
                log.warn(`readStaticPreview: failed for ${args.sessionId}/${args.variantId}`, err);
            }
            return null;
        }
    }
    /**
     * Read a file from a variant's saved static preview snapshot.
     * Asset paths are resolved under `files/` and rejected when they attempt
     * to escape the snapshot root.
     */
    async readStaticPreviewFile(args) {
        assertSafeId(args.sessionId, 'sessionId');
        assertSafeId(args.variantId, 'variantId');
        const variantDir = this.variantDir(args.projectPath, args.sessionId, args.variantId);
        const manifest = await readManifest(path_1.default.join(variantDir, MANIFEST_FILENAME));
        if (!manifest || manifest.preview.kind !== 'static')
            return null;
        const requestedPath = args.assetPath ?? manifest.preview.entryPath;
        assertSafeAssetPath(requestedPath);
        const filesRoot = path_1.default.join(variantDir, manifest.preview.rootPath);
        const absolutePath = path_1.default.resolve(filesRoot, requestedPath);
        const relative = path_1.default.relative(filesRoot, absolutePath);
        if (relative.startsWith('..') || path_1.default.isAbsolute(relative)) {
            throw new Error(`VariantHistoryService: invalid assetPath '${requestedPath}'`);
        }
        try {
            const stat = await promises_1.default.stat(absolutePath);
            if (!stat.isFile())
                return null;
            return {
                body: await promises_1.default.readFile(absolutePath),
                absolutePath,
            };
        }
        catch (err) {
            const code = err.code;
            if (code !== 'ENOENT') {
                log.warn(`readStaticPreviewFile: failed at ${absolutePath}`, err);
            }
            return null;
        }
    }
    async ensureGitignore(projectPath) {
        const gitignorePath = path_1.default.join(projectPath, GITIGNORE_FILENAME);
        let current = '';
        try {
            current = await promises_1.default.readFile(gitignorePath, 'utf8');
        }
        catch (err) {
            const code = err.code;
            if (code !== 'ENOENT') {
                log.warn(`ensureGitignore: read ${gitignorePath} failed`, err);
                return;
            }
        }
        // Any line that, on its own, already excludes the whole `.rivet/` tree.
        // Narrower patterns like `.rivet/variants/` (what older Rivet versions
        // wrote) do NOT count — we want `.rivet/<slug>/` snapshots, manifests, and
        // history all covered by a single broad rule, so we append the broader
        // pattern alongside.
        const lines = current.split(/\r?\n/);
        const alreadyCoversAll = lines.some((line) => {
            const trimmed = line.trim();
            return (trimmed === '.rivet' ||
                trimmed === '.rivet/' ||
                trimmed === '/.rivet' ||
                trimmed === '/.rivet/' ||
                trimmed === '**/.rivet' ||
                trimmed === '**/.rivet/');
        });
        if (alreadyCoversAll)
            return;
        const needsNewline = current.length > 0 && !current.endsWith('\n');
        const appended = `${current}${needsNewline ? '\n' : ''}${GITIGNORE_LINE}\n`;
        await promises_1.default.writeFile(gitignorePath, appended, 'utf8');
        log.info(`Appended ${GITIGNORE_LINE} to ${gitignorePath}`);
    }
}
exports.VariantHistoryService = VariantHistoryService;
async function readManifest(manifestPath) {
    try {
        const raw = await promises_1.default.readFile(manifestPath, 'utf8');
        const parsed = JSON.parse(raw);
        if (typeof parsed.variantId !== 'string')
            return null;
        if (parsed.schemaVersion !== HISTORY_SCHEMA_VERSION)
            return null;
        if (!parsed.artifact || !parsed.preview)
            return null;
        return parsed;
    }
    catch {
        return null;
    }
}
/** Writes a manifest so readers never observe a partially written JSON file. */
const writeManifestFile = async (manifestPath, manifest) => {
    const tempPath = path_1.default.join(path_1.default.dirname(manifestPath), `${MANIFEST_FILENAME}.${process.pid}.${Date.now()}.${Math.random()
        .toString(36)
        .slice(2)}.tmp`);
    try {
        await promises_1.default.writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
        await promises_1.default.rename(tempPath, manifestPath);
    }
    catch (error) {
        await promises_1.default.rm(tempPath, { force: true });
        throw error;
    }
};
const resolvePreview = (args) => {
    if (args.preview?.kind === 'static') {
        return {
            kind: 'static',
            rootPath: FILES_DIRNAME,
            entryPath: args.preview.entryPath,
        };
    }
    if (args.preview?.kind === 'dev-server') {
        return {
            kind: 'dev-server',
            rootPath: FILES_DIRNAME,
            command: args.preview.command,
            ...(args.preview.runnablePath
                ? { runnablePath: args.preview.runnablePath }
                : {}),
        };
    }
    if (args.preview?.kind === 'none') {
        return args.preview;
    }
    if (args.kind === 'diff') {
        return { kind: 'none', reason: 'diff_only' };
    }
    if (args.sourceDir &&
        fs_1.default.existsSync(path_1.default.join(args.sourceDir, STATIC_INDEX_FILENAME))) {
        return {
            kind: 'static',
            rootPath: FILES_DIRNAME,
            entryPath: STATIC_INDEX_FILENAME,
        };
    }
    return { kind: 'none', reason: 'missing_entry' };
};
const hasStaticPreviewFile = async (variantDir, manifest) => {
    if (manifest.preview.kind !== 'static')
        return false;
    return fileExists(path_1.default.join(variantDir, manifest.preview.rootPath, manifest.preview.entryPath));
};
async function safeStat(p) {
    try {
        return await promises_1.default.stat(p);
    }
    catch {
        return null;
    }
}
async function fileExists(p) {
    try {
        const s = await promises_1.default.stat(p);
        return s.isFile();
    }
    catch {
        return false;
    }
}
async function copyDirFiltered(src, dest) {
    await promises_1.default.mkdir(dest, { recursive: true });
    const entries = await promises_1.default.readdir(src, { withFileTypes: true });
    for (const entry of entries) {
        if (COPY_EXCLUDE.has(entry.name))
            continue;
        const from = path_1.default.join(src, entry.name);
        const to = path_1.default.join(dest, entry.name);
        if (entry.isDirectory()) {
            await copyDirFiltered(from, to);
        }
        else if (entry.isFile()) {
            await promises_1.default.copyFile(from, to);
        }
        // Symlinks are deliberately dropped. Source dirs are agent-controlled
        // (variant worktrees) and `readStaticPreview` later serves whatever
        // `files/index.html` points at through the history static route —
        // preserving a symlink to e.g. ~/.ssh/id_rsa would turn that route into
        // a host-file read primitive.
    }
}
async function listFilesRecursive(root) {
    if (!fs_1.default.existsSync(root))
        return [];
    const out = [];
    const walk = async (dir, prefix) => {
        const entries = await promises_1.default.readdir(dir, { withFileTypes: true });
        for (const entry of entries) {
            const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
            if (entry.isDirectory()) {
                await walk(path_1.default.join(dir, entry.name), rel);
            }
            else if (entry.isFile()) {
                out.push(rel);
            }
        }
    };
    await walk(root, '');
    return out.sort();
}
//# sourceMappingURL=VariantHistoryService.js.map