"use strict";
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2024-2026 Gracker (Chris)
// This file is part of SmartPerfetto. See LICENSE for details.
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.__testing = exports.ReviewOutboxHandle = void 0;
exports.openReviewOutbox = openReviewOutbox;
/**
 * SQLite-backed outbox for the background review agent.
 *
 * The review agent runs asynchronously after every analysis: a backend
 * crash, deploy, or process restart in between must NOT lose review jobs.
 * Filesystem-based queues (one JSON file per state) lose race exclusivity
 * once two workers contend for the same job. SQLite + WAL gives us atomic
 * `UPDATE … WHERE state='pending' RETURNING …` lease semantics in a single
 * statement, plus partial unique indexes for `dedupe_key` so a flapping
 * caller can't enqueue the same job twice.
 *
 * The DB lives at `<cwd>/data/self_improve/self_improve.db` — separate from
 * the sessions DB so a corrupt outbox never takes the analysis path down
 * with it.
 *
 * See docs/architecture/self-improving-design.md §7 (SQLite Outbox).
 */
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
const path = __importStar(require("path"));
const fs = __importStar(require("fs"));
const runtimePaths_1 = require("../../runtimePaths");
/** Default lease + retry parameters; callers can override per-call. */
const DEFAULT_LEASE_MS = 5 * 60 * 1000;
const DEFAULT_MAX_ATTEMPTS = 3;
const SCHEMA_VERSION_LATEST = 1;
const MIGRATIONS = [
    {
        version: 1,
        up: (db) => {
            db.exec(`
        CREATE TABLE review_jobs (
          id TEXT PRIMARY KEY,
          state TEXT NOT NULL CHECK(state IN ('pending','leased','done','failed')),
          dedupe_key TEXT NOT NULL,
          priority INTEGER NOT NULL DEFAULT 0,
          attempts INTEGER NOT NULL DEFAULT 0,
          lease_owner TEXT,
          lease_until INTEGER,
          created_at INTEGER NOT NULL,
          updated_at INTEGER NOT NULL,
          payload_json TEXT NOT NULL,
          last_error TEXT
        );
        CREATE INDEX idx_review_jobs_state_priority ON review_jobs(state, priority DESC, created_at);
        CREATE UNIQUE INDEX idx_review_jobs_dedupe_active
          ON review_jobs(dedupe_key) WHERE state IN ('pending','leased');
      `);
        },
    },
];
function defaultDbPath() {
    return (0, runtimePaths_1.backendDataPath)('self_improve', 'self_improve.db');
}
/**
 * Open the outbox DB, run migrations, and return a closure that exposes the
 * supported operations. The DB connection is owned by the caller — call
 * `close()` to release it.
 */
function openReviewOutbox(opts = {}) {
    const dbPath = opts.dbPath || defaultDbPath();
    if (dbPath !== ':memory:') {
        const dir = path.dirname(dbPath);
        if (!fs.existsSync(dir))
            fs.mkdirSync(dir, { recursive: true });
    }
    const db = new better_sqlite3_1.default(dbPath);
    db.pragma('journal_mode = WAL');
    db.pragma('busy_timeout = 5000');
    db.pragma('foreign_keys = ON');
    initializeMigrationsTable(db);
    applyPendingMigrations(db);
    return new ReviewOutboxHandle(db);
}
function initializeMigrationsTable(db) {
    db.exec(`
    CREATE TABLE IF NOT EXISTS schema_migrations (
      version INTEGER PRIMARY KEY,
      applied_at INTEGER NOT NULL
    );
  `);
}
function applyPendingMigrations(db) {
    const appliedRows = db.prepare('SELECT version FROM schema_migrations').all();
    const applied = new Set(appliedRows.map(r => r.version));
    const pending = MIGRATIONS.filter(m => !applied.has(m.version)).sort((a, b) => a.version - b.version);
    for (const step of pending) {
        const tx = db.transaction(() => {
            step.up(db);
            db.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)').run(step.version, Date.now());
        });
        tx();
    }
}
function generateJobId() {
    return `job-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
}
function rowToJob(row) {
    return {
        id: row.id,
        state: row.state,
        dedupeKey: row.dedupe_key,
        priority: row.priority,
        attempts: row.attempts,
        leaseOwner: row.lease_owner,
        leaseUntil: row.lease_until,
        createdAt: row.created_at,
        updatedAt: row.updated_at,
        payload: JSON.parse(row.payload_json),
        lastError: row.last_error,
    };
}
class ReviewOutboxHandle {
    constructor(db) {
        this.db = db;
    }
    /**
     * Enqueue a new job. Returns `{ enqueued: false, reason: 'duplicate_active' }`
     * if a pending or leased job with the same dedupe key already exists; this is
     * a successful no-op, not an error, so callers don't need a try/catch.
     */
    enqueue(input) {
        const start = Date.now();
        const id = generateJobId();
        const now = Date.now();
        const priority = input.priority ?? 0;
        try {
            this.db.prepare(`
        INSERT INTO review_jobs (id, state, dedupe_key, priority, attempts, created_at, updated_at, payload_json)
        VALUES (?, 'pending', ?, ?, 0, ?, ?, ?)
      `).run(id, input.dedupeKey, priority, now, now, JSON.stringify(input.payload));
            return { enqueued: true, jobId: id, latencyMs: Date.now() - start };
        }
        catch (err) {
            // Partial unique index on (dedupe_key) WHERE state IN ('pending','leased')
            // surfaces SQLITE_CONSTRAINT_UNIQUE for active duplicates.
            if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
                return { enqueued: false, reason: 'duplicate_active', latencyMs: Date.now() - start };
            }
            console.error('[ReviewOutbox] enqueue failed:', err.message);
            return { enqueued: false, reason: 'error', latencyMs: Date.now() - start };
        }
    }
    /**
     * Atomically pick the highest-priority pending job and mark it leased.
     * Returns null if the queue is empty (or every pending job is gated by
     * attempts ≥ maxAttempts).
     *
     * The lease is owned by `workerOwner` until `lease_until` — past that the
     * caller should call `expireStaleLeases()` to recycle the job back to
     * pending.
     */
    leaseNext(input) {
        const leaseDurationMs = input.leaseDurationMs ?? DEFAULT_LEASE_MS;
        const maxAttempts = input.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
        const now = Date.now();
        const leaseUntil = now + leaseDurationMs;
        const tx = this.db.transaction(() => {
            const candidate = this.db.prepare(`
        SELECT id FROM review_jobs
        WHERE state = 'pending' AND attempts < ?
        ORDER BY priority DESC, created_at ASC
        LIMIT 1
      `).get(maxAttempts);
            if (!candidate)
                return null;
            this.db.prepare(`
        UPDATE review_jobs
        SET state = 'leased',
            lease_owner = ?,
            lease_until = ?,
            attempts = attempts + 1,
            updated_at = ?
        WHERE id = ? AND state = 'pending'
      `).run(input.workerOwner, leaseUntil, now, candidate.id);
            return this.db.prepare('SELECT * FROM review_jobs WHERE id = ?').get(candidate.id);
        });
        const row = tx();
        return row ? rowToJob(row) : null;
    }
    /** Mark a leased job as completed. Idempotent on a re-call (no-op). */
    markDone(jobId) {
        this.db.prepare(`
      UPDATE review_jobs SET state = 'done', updated_at = ?, lease_owner = NULL, lease_until = NULL
      WHERE id = ? AND state = 'leased'
    `).run(Date.now(), jobId);
    }
    /**
     * Mark a leased job failed. If `attempts < maxAttempts`, the job goes back
     * to pending so a future poll can retry; otherwise it lands in 'failed'
     * permanently. `lastError` is truncated to keep DB size bounded.
     */
    markFailed(jobId, lastError, maxAttempts = DEFAULT_MAX_ATTEMPTS) {
        const truncated = lastError.substring(0, 1000);
        const now = Date.now();
        const tx = this.db.transaction(() => {
            const row = this.db.prepare('SELECT attempts FROM review_jobs WHERE id = ?').get(jobId);
            if (!row)
                return;
            const nextState = row.attempts >= maxAttempts ? 'failed' : 'pending';
            this.db.prepare(`
        UPDATE review_jobs
        SET state = ?, last_error = ?, updated_at = ?, lease_owner = NULL, lease_until = NULL
        WHERE id = ?
      `).run(nextState, truncated, now, jobId);
        });
        tx();
    }
    /**
     * Voluntarily release a lease without consuming an attempt. Used by the
     * worker when it leased a job but immediately determined the job has to
     * wait (cooldown, blocked dependency). The job goes back to pending and
     * keeps its existing attempts counter.
     */
    releaseLease(jobId) {
        this.db.prepare(`
      UPDATE review_jobs
      SET state = 'pending', lease_owner = NULL, lease_until = NULL,
          attempts = MAX(0, attempts - 1), updated_at = ?
      WHERE id = ? AND state = 'leased'
    `).run(Date.now(), jobId);
    }
    /**
     * Recycle leased jobs whose lease has expired back to pending. Called by
     * the worker poll loop on each tick — cheap because the lease_until index
     * keeps the scan tight.
     */
    expireStaleLeases(now = Date.now()) {
        const result = this.db.prepare(`
      UPDATE review_jobs
      SET state = 'pending', lease_owner = NULL, lease_until = NULL, updated_at = ?
      WHERE state = 'leased' AND lease_until < ?
    `).run(now, now);
        return result.changes;
    }
    /** Return counts grouped by state — used by the monitoring PR. */
    countByState() {
        const rows = this.db.prepare('SELECT state, COUNT(*) as n FROM review_jobs GROUP BY state').all();
        const out = { pending: 0, leased: 0, done: 0, failed: 0 };
        for (const row of rows) {
            out[row.state] = row.n;
        }
        return out;
    }
    /**
     * Number of jobs created in the last 24h — feeds the daily-budget cap.
     * Uses created_at so retries/leases don't double-count.
     */
    dailyJobCount(now = Date.now()) {
        const cutoff = now - 24 * 60 * 60 * 1000;
        const row = this.db.prepare('SELECT COUNT(*) as n FROM review_jobs WHERE created_at >= ?').get(cutoff);
        return row?.n ?? 0;
    }
    /** Test helper — returns the full job row for assertions. */
    getJob(jobId) {
        const row = this.db.prepare('SELECT * FROM review_jobs WHERE id = ?').get(jobId);
        return row ? rowToJob(row) : null;
    }
    /** Latest applied schema version — for migration tests. */
    schemaVersion() {
        const row = this.db.prepare('SELECT MAX(version) as v FROM schema_migrations').get();
        return row?.v ?? 0;
    }
    close() {
        this.db.close();
    }
}
exports.ReviewOutboxHandle = ReviewOutboxHandle;
/** Surfaced for assertions without exporting the constants individually. */
exports.__testing = { SCHEMA_VERSION_LATEST };
//# sourceMappingURL=reviewOutbox.js.map