// Job Board — persistent async job queue backed by SQLite
//
// Jobs survive restarts. Each job executes an agent invocation and stores the result.
// Supports: create, list, get, cancel.
//
// Ownership (H5/M6/H7, code-review-2026-05-06):
//   - Each row carries `creator_id` (the IM userId that originated the job)
//     and `workspace_id`. Commands that the user runs through IM
//     (`/job list|check|cancel|run`) MUST pass the caller's userId via
//     opts.creatorId so users can't peek at or stomp on each others' jobs.
//     Internal callers (REST `/api/jobs`, ACP server, scheduler) pass no
//     filter and see every row — that's intentional, those entry-points
//     are guarded by the web / API token.
//   - Legacy rows (created before the migration) have creator_id = ''.
//     They're treated as "ownerless / system" and visible to every owner-
//     scoped query (so an upgrade doesn't appear to vanish the user's
//     prior history).
//   - pruneOldJobs() walks completed/failed/cancelled rows older than
//     `IM_HUB_JOB_RETENTION_DAYS` (default 30) and deletes them; runs every
//     6h via setInterval, mirroring audit-log.
import { join } from 'node:path';
import { logger as rootLogger } from './logger.js';
import { eventBus } from './event-bus.js';
import { createSqliteHelper } from './sqlite-helper.js';
import { AGIM_HOME } from './agim-paths.js';
const BOARD_DB = join(AGIM_HOME, 'jobs.db');
/** Hard cap on a single job's accumulated result. A misbehaving agent (or a
 * runaway tool that dumps a giant file) could otherwise keep filling the
 * Buffer in {@link runJob} until the host OOMs and rage-stuff the SQLite TEXT
 * column with the same payload. We cap at 1 MiB by default; chunks that would
 * push past the cap are dropped and a truncation footer is appended. Override
 * via env IMHUB_JOB_RESULT_MAX_BYTES. */
function resolveMaxResultBytes() {
    const raw = process.env.IMHUB_JOB_RESULT_MAX_BYTES;
    if (raw) {
        const n = parseInt(raw, 10);
        if (Number.isFinite(n) && n > 0)
            return n;
    }
    return 1 * 1024 * 1024;
}
/** Hard cap on concurrent runJob() invocations. Spawning more than this
 * would let a few /job run X requests OOM the host (each agent process
 * carries hundreds of MB). Override via env IM_HUB_MAX_CONCURRENT_JOBS. */
function resolveMaxConcurrent() {
    const raw = process.env.IM_HUB_MAX_CONCURRENT_JOBS;
    if (raw) {
        const n = parseInt(raw, 10);
        if (Number.isFinite(n) && n > 0)
            return n;
    }
    return 3;
}
// Running job controllers (for cancellation)
const runningControllers = new Map();
// Wait queue for jobs that arrived while at capacity. Each entry is the
// resolver of a Promise the caller is awaiting on.
const waitQueue = [];
let activeJobs = 0;
/**
 * Reset jobs that were 'running' when the previous agim process died.
 * Without this, those rows stay marked 'running' forever and every
 * /job list shows ghosts.
 */
function reapStaleRunning(d) {
    try {
        const stmt = d.prepare("UPDATE jobs SET status='failed', error='agim restarted while running', completed_at=datetime('now') WHERE status='running'");
        const info = stmt.run();
        if (info.changes > 0) {
            rootLogger.warn({ event: 'job-board.reap', stale: info.changes }, `Marked ${info.changes} stale running job(s) as failed`);
        }
    }
    catch {
        // best-effort; missing table is fine on first start
    }
}
/**
 * Idempotent column-adds. Run on every helper init — SQLite's ALTER TABLE
 * ADD COLUMN throws "duplicate column name" if the column already exists,
 * which we silently swallow. Cheaper than the full pragma table_info()
 * inspection round-trip.
 *
 * Columns added over time:
 *   - creator_id / workspace_id  : ownership (v1.0.x)
 *   - thread_key                 : `${platform}:${channelId}:${threadId}` — needed
 *                                   so Phase 3 can recover an interrupted inline
 *                                   job back to the right IM thread.
 *   - kind                       : 'job' (explicit /job, retention 30d) vs
 *                                   'inline' (auto-tracked inbound, retention 24h).
 *   - started_at / delivered_at  : finer-grained lifecycle stamps.
 *   - replaced_by                : when user retries an interrupted job, the
 *                                   old row points at the new one.
 *   - last_outbox_id             : link to the outbox row that delivered (or is
 *                                   trying to deliver) this job's result.
 *   - parent_id                  : Phase A2A-L1 — when one agent calls another via
 *                                   mcp__imhub__call_agent, the callee's row points
 *                                   back at the caller's row. NULL on user-originated
 *                                   rows.
 *   - call_depth                 : Phase A2A-L1 — 0 for user-originated rows; +1 per
 *                                   nesting level. Lets us reject runaway chains
 *                                   (IMHUB_A2A_MAX_DEPTH, default 3).
 */
function migrateColumns(d) {
    // [column, type] — type must include any NOT NULL / DEFAULT clauses.
    const adds = [
        ['creator_id', `TEXT    NOT NULL DEFAULT ''`],
        ['workspace_id', `TEXT    NOT NULL DEFAULT ''`],
        ['thread_key', `TEXT    NOT NULL DEFAULT ''`],
        ['kind', `TEXT    NOT NULL DEFAULT 'job'`],
        ['started_at', `TEXT`],
        ['delivered_at', `TEXT`],
        ['replaced_by', `INTEGER`],
        ['last_outbox_id', `INTEGER`],
        ['parent_id', `INTEGER`],
        ['call_depth', `INTEGER NOT NULL DEFAULT 0`],
    ];
    for (const [col, type] of adds) {
        try {
            d.prepare(`ALTER TABLE jobs ADD COLUMN ${col} ${type}`).run();
            rootLogger.info({ component: 'job-board', column: col, event: 'job-board.migration' }, `Added ${col} column to jobs table`);
        }
        catch {
            // duplicate column — already migrated
        }
    }
    // All indexes that reference columns added by migrateColumns above. Each
    // create is wrapped individually so a single failure (corrupt index, etc.)
    // doesn't kill the rest. IF NOT EXISTS handles idempotency on warm starts.
    const indexes = [
        // v1.0 ownership indexes — moved out of the schema string because the
        // schema runs before migrateColumns, and a pre-v1.0 jobs.db doesn't have
        // creator_id yet at schema-exec time. Putting them here makes them run
        // AFTER the ALTER TABLE that adds the column.
        `CREATE INDEX IF NOT EXISTS idx_jobs_creator         ON jobs(creator_id)`,
        `CREATE INDEX IF NOT EXISTS idx_jobs_status_created  ON jobs(status, created_at)`,
        // Phase 2 indexes — retention split (kind) + recovery sweep (kind +
        // status + thread_key).
        `CREATE INDEX IF NOT EXISTS idx_jobs_kind_status     ON jobs(kind, status)`,
        `CREATE INDEX IF NOT EXISTS idx_jobs_thread_key      ON jobs(thread_key)`,
        `CREATE INDEX IF NOT EXISTS idx_jobs_last_outbox     ON jobs(last_outbox_id) WHERE last_outbox_id IS NOT NULL`,
        // A2A-L1 — fast "children of parent_id" lookup for /a2a stats + audit
        // join. parent_id is sparse (only rows from mcp__imhub__call_agent) so
        // the partial index keeps it small.
        `CREATE INDEX IF NOT EXISTS idx_jobs_parent          ON jobs(parent_id) WHERE parent_id IS NOT NULL`,
    ];
    for (const sql of indexes) {
        try {
            d.prepare(sql).run();
        }
        catch (err) {
            rootLogger.warn({ component: 'job-board', event: 'job-board.index_create_failed', sql, err: err instanceof Error ? err.message : String(err) }, 'index create failed (continuing)');
        }
    }
}
/** Default retention (days) for terminal explicit /job rows. */
function resolveRetentionDays() {
    const raw = process.env.IM_HUB_JOB_RETENTION_DAYS;
    if (raw) {
        const n = parseInt(raw, 10);
        if (Number.isFinite(n) && n > 0)
            return n;
    }
    return 30;
}
/** Retention (hours) for terminal inline jobs. Inline rows are auto-tracked
 *  from every inbound IM message — they accrue 100s of rows/day even on a
 *  quiet thread, so the default is much shorter than the explicit /job 30-day
 *  retention. Phase 3's recovery sweep needs at least the user's "回 1 重发"
 *  window, so don't drop below ~6h in production. */
function resolveInlineRetentionHours() {
    const raw = process.env.IMHUB_INLINE_JOB_RETENTION_HOURS;
    if (raw) {
        const n = parseInt(raw, 10);
        if (Number.isFinite(n) && n > 0)
            return n;
    }
    return 24;
}
/** How often pruneOldJobs() is fired by the auto-sweep timer. 0 disables. */
const RETENTION_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
let retentionSweepTimer = null;
const helper = createSqliteHelper({
    file: BOARD_DB,
    component: 'job-board',
    logger: rootLogger,
    // Schema only declares columns that have ALWAYS existed (v0.x onward).
    // creator_id / workspace_id / Phase-2 columns are added in migrateColumns
    // via idempotent ALTER TABLE, because legacy production dbs predate the
    // v1.0 ownership migration. Indexes that reference any of those columns
    // also live in migrateColumns — running them here against a pre-v1.0
    // table throws "no such column" and broke the entire helper:
    //   sqlite-helper enters the "broken" state on any schema exec error
    //   → every subsequent get() returns null → job-board permanently disabled.
    // That bug shipped from v1.0 onward but only surfaced once Phase 2
    // landed and someone ran a fresh agim binary against a legacy jobs.db.
    schema: `
    CREATE TABLE IF NOT EXISTS jobs (
      id            INTEGER PRIMARY KEY AUTOINCREMENT,
      agent         TEXT    NOT NULL,
      prompt        TEXT    NOT NULL,
      status        TEXT    NOT NULL DEFAULT 'pending',
      result        TEXT,
      error         TEXT,
      created_at    TEXT    NOT NULL DEFAULT (datetime('now')),
      completed_at  TEXT
    );
    CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
  `,
    init: (d) => {
        migrateColumns(d);
        // First-time / cold-start cleanup: any 'running' rows are leftover from a
        // prior process that didn't get to finalize.
        reapStaleRunning(d);
        // Initial retention sweep + periodic timer. Mirrors audit-log shape so
        // both tables stay bounded without operator intervention.
        pruneOldJobs(d);
        if (RETENTION_SWEEP_INTERVAL_MS > 0 && retentionSweepTimer === null) {
            retentionSweepTimer = setInterval(() => {
                const live = helper.get();
                if (live)
                    pruneOldJobs(live);
            }, RETENTION_SWEEP_INTERVAL_MS);
            if (typeof retentionSweepTimer === 'object' && retentionSweepTimer && 'unref' in retentionSweepTimer) {
                retentionSweepTimer.unref();
            }
        }
    },
});
/** Phase 3 — shutdown step. Mark every running inline job as 'interrupted'
 *  so the next startup can offer the user a retry. Returns the row count for
 *  logging. Safe to call on a degraded DB (returns 0). */
export function markRunningInlineJobsInterrupted(reason = 'agim shutdown') {
    const d = getDb();
    if (!d)
        return 0;
    try {
        const info = d.prepare(`UPDATE jobs
          SET status = 'interrupted',
              completed_at = datetime('now'),
              error = ?
        WHERE kind = 'inline'
          AND status IN ('pending', 'running')`).run(reason.slice(0, 1024));
        if (info.changes > 0) {
            rootLogger.info({ component: 'job-board', event: 'job-board.interrupted', count: info.changes, reason }, `Marked ${info.changes} inline job(s) as interrupted (${reason})`);
        }
        return info.changes;
    }
    catch (err) {
        rootLogger.warn({ component: 'job-board', event: 'job-board.interrupt_failed', err: err instanceof Error ? err.message : String(err) }, 'markRunningInlineJobsInterrupted failed');
        return 0;
    }
}
/** Phase 3 — startup scan. Return interrupted inline jobs that finished
 *  within the recovery window (default 10 min via IMHUB_RECOVERY_WINDOW_MS).
 *  Caller iterates the result, sends a per-thread "retry / cancel" prompt,
 *  and tracks the user's reply via the in-memory pending-retry map.
 *
 *  Older interrupted rows are NOT returned — they get swept to 'abandoned'
 *  by sweepAbandonedInterrupted() so they don't pile up in the table. */
export function findRecoverableInterrupted(windowMs) {
    const d = getDb();
    if (!d)
        return [];
    const ms = windowMs ?? resolveRecoveryWindowMs();
    const seconds = Math.max(1, Math.floor(ms / 1000));
    try {
        return d.prepare(`SELECT * FROM jobs
        WHERE kind = 'inline'
          AND status = 'interrupted'
          AND completed_at >= datetime('now', '-' || ? || ' seconds')
          AND thread_key <> ''
        ORDER BY completed_at ASC`).all(seconds);
    }
    catch (err) {
        rootLogger.warn({ component: 'job-board', event: 'job-board.recover_query_failed', err: err instanceof Error ? err.message : String(err) }, 'findRecoverableInterrupted failed');
        return [];
    }
}
/** Phase 3 — startup sweep. Any 'interrupted' inline row past the recovery
 *  window transitions to 'abandoned' (preserved for audit but never
 *  notified). Returns the row count. */
export function sweepAbandonedInterrupted(windowMs) {
    const d = getDb();
    if (!d)
        return 0;
    const ms = windowMs ?? resolveRecoveryWindowMs();
    const seconds = Math.max(1, Math.floor(ms / 1000));
    try {
        const info = d.prepare(`UPDATE jobs
          SET status = 'abandoned'
        WHERE kind = 'inline'
          AND status = 'interrupted'
          AND completed_at < datetime('now', '-' || ? || ' seconds')`).run(seconds);
        if (info.changes > 0) {
            rootLogger.info({ component: 'job-board', event: 'job-board.abandoned', count: info.changes }, `Swept ${info.changes} stale interrupted inline job(s) to 'abandoned'`);
        }
        return info.changes;
    }
    catch (err) {
        rootLogger.warn({ component: 'job-board', event: 'job-board.abandon_failed', err: err instanceof Error ? err.message : String(err) }, 'sweepAbandonedInterrupted failed');
        return 0;
    }
}
/** Phase 3 — link an old interrupted row to its replacement once the user
 *  picks "1 重发". The new row's id lands in `replaced_by`; the old row
 *  transitions interrupted → replaced so it stops showing up in any future
 *  recovery scan. */
export function markJobReplacedBy(oldId, newId) {
    if (oldId <= 0 || newId <= 0)
        return;
    const d = getDb();
    if (!d)
        return;
    try {
        d.prepare(`UPDATE jobs
          SET status = 'replaced',
              replaced_by = ?
        WHERE id = ? AND status = 'interrupted'`).run(newId, oldId);
    }
    catch (err) {
        rootLogger.debug({ component: 'job-board', event: 'markJobReplacedBy_failed', oldId, newId, err: String(err) });
    }
}
/** Phase 3 — user picked "2 取消" on the retry prompt. Interrupted → cancelled. */
export function markInterruptedCancelled(id) {
    if (id <= 0)
        return;
    const d = getDb();
    if (!d)
        return;
    try {
        d.prepare(`UPDATE jobs
          SET status = 'cancelled'
        WHERE id = ? AND status = 'interrupted'`).run(id);
    }
    catch (err) {
        rootLogger.debug({ component: 'job-board', event: 'markInterruptedCancelled_failed', id, err: String(err) });
    }
}
/** Recovery window in milliseconds. Default 10 minutes. Env override
 *  IMHUB_RECOVERY_WINDOW_MS. */
function resolveRecoveryWindowMs() {
    const raw = process.env.IMHUB_RECOVERY_WINDOW_MS;
    if (raw) {
        const n = parseInt(raw, 10);
        if (Number.isFinite(n) && n > 0)
            return n;
    }
    return 10 * 60 * 1000;
}
/** Exported for the recovery module so it can use the same window for the
 *  user-facing "10 min 内有效" copy. */
export function getRecoveryWindowMs() {
    return resolveRecoveryWindowMs();
}
/** Test hook: artificially age a row's completed_at (or created_at) by a
 *  SQLite modifier like '-2 hours' or '-40 days'. Production code must NOT
 *  call this; it's the only practical way to exercise retention without
 *  sleeping in tests. */
export function _backdateForTests(id, modifier) {
    const d = getDb();
    if (!d)
        return;
    try {
        d.prepare(`UPDATE jobs
          SET completed_at = datetime(COALESCE(completed_at, datetime('now')), ?),
              created_at   = datetime(created_at, ?)
        WHERE id = ?`).run(modifier, modifier, id);
    }
    catch { /* tests fail loudly via the next assertion */ }
}
/** Stop the retention sweep (used by tests + graceful shutdown). */
export function stopJobRetentionSweep() {
    if (retentionSweepTimer) {
        clearInterval(retentionSweepTimer);
        retentionSweepTimer = null;
    }
}
/** Close the underlying SQLite handle. Called by cli.ts on graceful
 *  shutdown so the WAL is checkpointed before exit (L2). */
export function closeJobBoardDb() {
    helper.close();
}
function getDb() {
    return helper.get();
}
export function createJob(agent, prompt, opts = {}) {
    const d = getDb();
    if (!d)
        throw new Error('Job board unavailable (SQLite not initialized)');
    const row = d.prepare(`INSERT INTO jobs
       (agent, prompt, status, creator_id, workspace_id, thread_key, kind, parent_id, call_depth)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(agent, prompt, 'pending', opts.creatorId || '', opts.workspaceId || '', opts.threadKey || '', opts.kind || 'job', opts.parentId ?? null, opts.callDepth ?? 0);
    const id = Number(row.lastInsertRowid);
    eventBus.publish({ type: 'job', phase: 'created', jobId: id, agent });
    return id;
}
/** Inline-job convenience: same as createJob but never throws — returns -1
 *  when the board is disabled / locked. Caller is expected to log + carry
 *  on with the in-memory fast path. The whole tracking machinery short-
 *  circuits when env IMHUB_INLINE_JOB_TRACKING=0.
 *
 *  parentId/callDepth are A2A-L1 hand-offs: when an agent tool-calls
 *  another agent via mcp__imhub__call_agent, the bus routes through here
 *  to record the parent/depth relationship. User-originated rows leave
 *  these undefined → NULL parent, depth=0. */
export function createInlineJob(opts) {
    if (process.env.IMHUB_INLINE_JOB_TRACKING === '0')
        return -1;
    try {
        return createJob(opts.agent, opts.prompt, {
            kind: 'inline',
            threadKey: opts.threadKey,
            creatorId: opts.creatorId,
            workspaceId: opts.workspaceId,
            parentId: opts.parentId ?? null,
            callDepth: opts.callDepth ?? 0,
        });
    }
    catch (err) {
        rootLogger.warn({ component: 'job-board', event: 'job-board.inline_create_failed', err: err instanceof Error ? err.message : String(err) }, 'createInlineJob fell back to in-memory path');
        return -1;
    }
}
export function getJob(id, opts = {}) {
    const d = getDb();
    if (!d)
        return null;
    const row = (opts.creatorId !== undefined
        ? d.prepare("SELECT * FROM jobs WHERE id = ? AND (creator_id = ? OR creator_id = '')")
            .get(id, opts.creatorId)
        : d.prepare('SELECT * FROM jobs WHERE id = ?').get(id));
    return row || null;
}
export function listJobs(limit = 20, status, opts = {}) {
    const d = getDb();
    if (!d)
        return [];
    const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.min(Math.floor(limit), 1000) : 20;
    const conditions = [];
    const params = [];
    if (status) {
        conditions.push('status = ?');
        params.push(status);
    }
    if (opts.creatorId !== undefined) {
        conditions.push("(creator_id = ? OR creator_id = '')");
        params.push(opts.creatorId);
    }
    if (opts.agent) {
        conditions.push('agent = ?');
        params.push(opts.agent);
    }
    if (opts.kind) {
        conditions.push('kind = ?');
        params.push(opts.kind);
    }
    const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
    return d.prepare(`SELECT * FROM jobs ${where} ORDER BY created_at DESC LIMIT ?`)
        .all(...params, safeLimit);
}
function updateJob(id, fields) {
    const d = getDb();
    if (!d)
        return;
    const sets = [];
    const vals = [];
    for (const [k, v] of Object.entries(fields)) {
        if (v !== undefined) {
            sets.push(`${k} = ?`);
            vals.push(v);
        }
    }
    if (!sets.length)
        return;
    d.prepare(`UPDATE jobs SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
}
// ─── Inline-job state-machine helpers ────────────────────────────────
//
// All of these are no-op when id<=0 (board disabled / inline tracking off)
// or when the DB is unavailable. The caller never has to guard.
/** Replace the agent column. Used by inline jobs: cli.ts creates the row with
 *  the default agent name before routing resolves the actual agent, then this
 *  fires inside onAgentResolved to correct the record. No-op when status is
 *  past 'running' so we don't rewrite history. */
export function updateJobAgent(id, agent) {
    if (id <= 0 || !agent)
        return;
    const d = getDb();
    if (!d)
        return;
    try {
        d.prepare(`UPDATE jobs SET agent = ? WHERE id = ? AND status IN ('pending', 'running')`).run(agent, id);
    }
    catch (err) {
        rootLogger.debug({ component: 'job-board', event: 'updateJobAgent_failed', id, agent, err: String(err) });
    }
}
/** pending → running. Stamps started_at. Idempotent: an already-running row
 *  keeps its original started_at. */
export function markJobRunning(id) {
    if (id <= 0)
        return;
    const d = getDb();
    if (!d)
        return;
    try {
        d.prepare(`UPDATE jobs
          SET status = 'running',
              started_at = COALESCE(started_at, datetime('now'))
        WHERE id = ? AND status IN ('pending', 'running')`).run(id);
    }
    catch (err) {
        rootLogger.debug({ component: 'job-board', event: 'markJobRunning_failed', id, err: String(err) });
    }
}
/** running → completed (result captured). For inline kind 'delivered' is the
 *  next hop, triggered by the outbox worker once the IM platform confirms.
 *  For 'job' kind this is the terminal success state (no delivery channel). */
export function markJobCompleted(id, result) {
    if (id <= 0)
        return;
    const d = getDb();
    if (!d)
        return;
    try {
        d.prepare(`UPDATE jobs
          SET status = 'completed',
              completed_at = datetime('now'),
              result = ?
        WHERE id = ? AND status IN ('pending', 'running')`).run(result, id);
    }
    catch (err) {
        rootLogger.debug({ component: 'job-board', event: 'markJobCompleted_failed', id, err: String(err) });
    }
}
/** Link a job row to the outbox row that will (try to) deliver its result.
 *  Worker uses outbox.job_id to find this row; this column is the reverse
 *  link for /job inspection ("which outbox row carried this reply"). */
export function linkJobOutbox(id, outboxId) {
    if (id <= 0 || outboxId <= 0)
        return;
    const d = getDb();
    if (!d)
        return;
    try {
        d.prepare(`UPDATE jobs SET last_outbox_id = ? WHERE id = ?`).run(outboxId, id);
    }
    catch (err) {
        rootLogger.debug({ component: 'job-board', event: 'linkJobOutbox_failed', id, outboxId, err: String(err) });
    }
}
/** completed → delivered. Called by the outbox worker after the IM platform
 *  ack. No-op if the row isn't in 'completed' (e.g. user cancelled meanwhile). */
export function markJobDelivered(id) {
    if (id <= 0)
        return;
    const d = getDb();
    if (!d)
        return;
    try {
        d.prepare(`UPDATE jobs
          SET status = 'delivered',
              delivered_at = datetime('now')
        WHERE id = ? AND status = 'completed'`).run(id);
    }
    catch (err) {
        rootLogger.debug({ component: 'job-board', event: 'markJobDelivered_failed', id, err: String(err) });
    }
}
/** Terminal failure (agent threw, outbox gave up, etc.). Captures the error
 *  on the row. completed_at gets stamped so retention sweeps treat the row
 *  the same as a normal terminal state. */
export function markJobFailed(id, error) {
    if (id <= 0)
        return;
    const d = getDb();
    if (!d)
        return;
    try {
        d.prepare(`UPDATE jobs
          SET status = 'failed',
              completed_at = datetime('now'),
              error = ?
        WHERE id = ? AND status NOT IN ('cancelled', 'replaced', 'abandoned')`).run(error.slice(0, 4096), id);
    }
    catch (err) {
        rootLogger.debug({ component: 'job-board', event: 'markJobFailed_failed', id, err: String(err) });
    }
}
/**
 * Acquire a job slot. Returns immediately when capacity available, otherwise
 * queues the caller and returns a promise that resolves when a slot frees.
 */
async function acquireSlot() {
    const max = resolveMaxConcurrent();
    if (activeJobs < max) {
        activeJobs++;
        return;
    }
    await new Promise((resolve) => waitQueue.push(resolve));
    activeJobs++;
}
function releaseSlot() {
    activeJobs = Math.max(0, activeJobs - 1);
    const next = waitQueue.shift();
    if (next)
        next();
}
export async function runJob(id, runner, logger) {
    const job = getJob(id);
    if (!job)
        return;
    // Block here if the job board is at capacity. The job stays 'pending' in
    // SQLite while waiting — visible to /job list as queued.
    await acquireSlot();
    const controller = new AbortController();
    runningControllers.set(id, controller);
    updateJob(id, { status: 'running' });
    eventBus.publish({ type: 'job', phase: 'started', jobId: id, agent: job.agent });
    logger.info({ event: 'job.start', jobId: id, agent: job.agent, active: activeJobs });
    let fullText = '';
    let bytes = 0;
    let droppedBytes = 0;
    let truncated = false;
    const cap = resolveMaxResultBytes();
    try {
        for await (const chunk of runner(job, logger, controller.signal)) {
            if (controller.signal.aborted)
                break;
            const chunkBytes = Buffer.byteLength(chunk, 'utf-8');
            if (truncated) {
                droppedBytes += chunkBytes;
                continue;
            }
            if (bytes + chunkBytes > cap) {
                // Drop the entire over-cap chunk to keep the boundary on a UTF-8 /
                // line boundary the producer chose; partial slicing risks splitting
                // a multibyte char or a JSON object mid-event.
                truncated = true;
                droppedBytes += chunkBytes;
                continue;
            }
            fullText += chunk;
            bytes += chunkBytes;
        }
        if (truncated) {
            const note = `\n\n[agim: result truncated — ${droppedBytes} bytes dropped after ${cap}-byte cap; raise IMHUB_JOB_RESULT_MAX_BYTES if needed]`;
            fullText += note;
            logger.warn({ event: 'job.result.truncated', jobId: id, droppedBytes, cap });
        }
        // Only update if not cancelled
        if (!controller.signal.aborted) {
            updateJob(id, { status: 'completed', result: fullText, completed_at: new Date().toISOString() });
            eventBus.publish({ type: 'job', phase: 'completed', jobId: id, agent: job.agent });
            logger.info({ event: 'job.complete', jobId: id, resultLen: fullText.length, truncated });
        }
        else {
            updateJob(id, { status: 'cancelled', result: fullText || null, completed_at: new Date().toISOString() });
            eventBus.publish({ type: 'job', phase: 'cancelled', jobId: id, agent: job.agent });
            logger.info({ event: 'job.cancelled', jobId: id });
        }
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        const stack = err instanceof Error ? err.stack : undefined;
        updateJob(id, { status: 'failed', error: msg, completed_at: new Date().toISOString() });
        eventBus.publish({ type: 'job', phase: 'failed', jobId: id, agent: job.agent });
        logger.error({ event: 'job.failed', jobId: id, error: msg, stack });
    }
    finally {
        runningControllers.delete(id);
        releaseSlot();
    }
}
/** Test/diagnostic accessor: how many runJob invocations are currently in flight. */
export function _activeJobCount() { return activeJobs; }
/** Test/diagnostic accessor: the wait queue depth. */
export function _waitQueueDepth() { return waitQueue.length; }
/** Test-only: directly exercise the concurrency gate without SQLite. */
export const _testSlot = { acquire: acquireSlot, release: releaseSlot };
/** Batch 2 (#2 Stop) — return the most recent 'running' (or 'pending')
 *  job on the given thread, or null if none. Used by `/stop` to find
 *  the in-flight run to abort. */
export function findActiveJobByThread(threadKey) {
    const d = getDb();
    if (!d)
        return null;
    return d.prepare(`SELECT * FROM jobs
       WHERE thread_key = ?
         AND status IN ('running', 'pending')
       ORDER BY created_at DESC
       LIMIT 1`).get(threadKey);
}
export function cancelJob(id, opts = {}) {
    const job = getJob(id, opts);
    if (!job || (job.status !== 'pending' && job.status !== 'running'))
        return false;
    const ctrl = runningControllers.get(id);
    if (ctrl) {
        ctrl.abort();
        runningControllers.delete(id);
    }
    updateJob(id, { status: 'cancelled', completed_at: new Date().toISOString() });
    eventBus.publish({ type: 'job', phase: 'cancelled', jobId: id, agent: job.agent });
    return true;
}
/**
 * Delete jobs that finished (completed / failed / cancelled) more than
 * `daysRetention` days ago. 'pending' / 'running' rows are never pruned —
 * a stalled-but-resumable job is the operator's call. Returns the row
 * count so callers / tests can assert.
 */
export function pruneOldJobs(d = getDb(), daysRetention = resolveRetentionDays()) {
    if (!d)
        return 0;
    const inlineHours = resolveInlineRetentionHours();
    // Terminal statuses across both kinds. 'delivered' is inline-only.
    // 'interrupted' / 'replaced' / 'abandoned' are Phase 3 statuses — pre-
    // declared here so retention works consistently when Phase 3 lands.
    const TERMINAL = "'completed','delivered','failed','cancelled','replaced','abandoned'";
    try {
        // Two passes — different retention by kind. SQLite can't do this in one
        // statement without a CASE-on-window-or-something gnarly; two prepares
        // keeps it readable and the row counts attributable.
        //
        // L2 — collect ids BEFORE delete so we can hand them to artifacts.ts
        // for matching-dir cleanup. Two-step is unavoidable because SQLite
        // doesn't have DELETE … RETURNING. The window is tiny and the side
        // effect (orphaned dirs) is small, so we don't bother with a Tx.
        const jobIdsToDel = d.prepare(`
      SELECT id FROM jobs
      WHERE kind = 'job'
        AND status IN (${TERMINAL})
        AND completed_at IS NOT NULL
        AND completed_at < datetime('now', ?)
    `).all(`-${daysRetention} days`);
        const inlineIdsToDel = d.prepare(`
      SELECT id FROM jobs
      WHERE kind = 'inline'
        AND status IN (${TERMINAL})
        AND COALESCE(completed_at, created_at) < datetime('now', ?)
    `).all(`-${inlineHours} hours`);
        const jobInfo = d.prepare(`
      DELETE FROM jobs
      WHERE kind = 'job'
        AND status IN (${TERMINAL})
        AND completed_at IS NOT NULL
        AND completed_at < datetime('now', ?)
    `).run(`-${daysRetention} days`);
        const inlineInfo = d.prepare(`
      DELETE FROM jobs
      WHERE kind = 'inline'
        AND status IN (${TERMINAL})
        AND COALESCE(completed_at, created_at) < datetime('now', ?)
    `).run(`-${inlineHours} hours`);
        const total = jobInfo.changes + inlineInfo.changes;
        // L2 — drop artifacts dirs for everything we just deleted. inline jobs
        // are where the artifacts live, but kind='job' rows might also have
        // them in the future, so we feed both. Fire-and-forget; failures only
        // leave an orphaned dir, not a broken DB.
        const allIds = [...jobIdsToDel, ...inlineIdsToDel].map((r) => r.id);
        if (allIds.length > 0) {
            void import('./artifacts.js')
                .then(({ pruneArtifactsBatch }) => pruneArtifactsBatch(allIds))
                .catch((err) => rootLogger.warn({ component: 'job-board', event: 'job-board.prune.artifacts_failed', err: err instanceof Error ? err.message : String(err) }, 'pruneArtifactsBatch failed (orphaned dirs may remain)'));
        }
        if (total > 0) {
            rootLogger.info({
                component: 'job-board', event: 'job-board.pruned',
                deletedJob: jobInfo.changes, deletedInline: inlineInfo.changes,
                days: daysRetention, inlineHours,
            }, `Pruned ${jobInfo.changes} job + ${inlineInfo.changes} inline row(s)`);
        }
        return total;
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        rootLogger.warn({ component: 'job-board', event: 'job-board.prune.failed', error: msg }, 'job-board retention sweep failed');
        return 0;
    }
}
export function getJobStats() {
    const empty = { total: 0, pending: 0, running: 0, completed: 0, failed: 0 };
    const d = getDb();
    if (!d)
        return empty;
    try {
        const row = d.prepare(`
      SELECT
        COUNT(*) as total,
        SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END) as pending,
        SUM(CASE WHEN status='running' THEN 1 ELSE 0 END) as running,
        SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END) as completed,
        SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END) as failed
      FROM jobs
    `).get();
        return {
            total: row.total || 0, pending: row.pending || 0, running: row.running || 0,
            completed: row.completed || 0, failed: row.failed || 0,
        };
    }
    catch {
        return empty;
    }
}
//# sourceMappingURL=job-board.js.map