/**
 * Dispatcher script: check-due-events
 *
 * Standalone dispatcher. Not fired automatically on a running install — the
 * installer-registered cron entry was removed and the replacement
 * surface (Claude Code's desktop scheduled tasks, per maxy-code-prd.md
 * §Scheduled tasks) is not yet wired. Until that lands, this binary runs only
 * when invoked manually or by a future scheduled-task dispatcher. Finds events
 * whose time has arrived and marks them appropriately:
 *
 * - One-time events: set eventStatus = 'due'
 * - Recurring events: set lastTriggered, advance nextRun, append trigger note
 *   (status stays 'scheduled' to avoid orphaning)
 *
 * If an event carries an action (actionPlugin + actionTool), the corresponding
 * MCP tool is dispatched after the event is updated. Dispatch happens AFTER
 * the nextRun/status update so a failed dispatch never blocks the event.
 *
 * PID lock at $HOME/{configDir}/check-due-events.lock prevents overlapping runs.
 *
 *   PLATFORM_ROOT=... node dist/scripts/check-due-events.js
 */
import neo4j from "neo4j-driver";
import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync, readdirSync } from "node:fs";
import { resolve, join } from "node:path";
import { homedir } from "node:os";
import cronParser from "cron-parser";
const { parseExpression } = cronParser;
// ---------------------------------------------------------------------------
// Brand-aware config directory — reads configDir from brand.json stamped at install time.
// No fallback: if brand.json is missing or incomplete, the platform wasn't properly installed.
// ---------------------------------------------------------------------------
function resolveConfigDir() {
    const platformRoot = process.env.PLATFORM_ROOT;
    if (!platformRoot) {
        throw new Error("PLATFORM_ROOT environment variable is required");
    }
    const brandPath = resolve(platformRoot, "config", "brand.json");
    if (!existsSync(brandPath)) {
        throw new Error(`brand.json not found at ${brandPath} — platform not properly installed`);
    }
    try {
        const brand = JSON.parse(readFileSync(brandPath, "utf-8"));
        if (!brand.configDir) {
            throw new Error(`brand.json at ${brandPath} is missing the configDir field`);
        }
        return brand.configDir;
    }
    catch (err) {
        if (err instanceof SyntaxError) {
            throw new Error(`brand.json at ${brandPath} is not valid JSON: ${err.message}`);
        }
        throw err;
    }
}
const CONFIG_DIR = resolve(homedir(), resolveConfigDir());
// ---------------------------------------------------------------------------
// Lock file — prevents overlapping cron runs
// ---------------------------------------------------------------------------
const LOCK_DIR = CONFIG_DIR;
const LOCK_FILE = resolve(LOCK_DIR, "check-due-events.lock");
function acquireLock() {
    mkdirSync(LOCK_DIR, { recursive: true });
    if (existsSync(LOCK_FILE)) {
        const storedPid = readFileSync(LOCK_FILE, "utf-8").trim();
        // Check if the process is still running
        try {
            process.kill(Number(storedPid), 0);
            // Process exists — lock is held
            return false;
        }
        catch {
            // Process is gone — stale lock, remove and acquire
            unlinkSync(LOCK_FILE);
        }
    }
    writeFileSync(LOCK_FILE, String(process.pid));
    return true;
}
function releaseLock() {
    try {
        if (existsSync(LOCK_FILE)) {
            const storedPid = readFileSync(LOCK_FILE, "utf-8").trim();
            if (storedPid === String(process.pid)) {
                unlinkSync(LOCK_FILE);
            }
        }
    }
    catch {
        // Best-effort cleanup
    }
}
// ---------------------------------------------------------------------------
// Neo4j connection (standalone — not sharing the MCP server's driver)
// ---------------------------------------------------------------------------
function connectNeo4j() {
    const platformRoot = process.env.PLATFORM_ROOT;
    if (!platformRoot) {
        throw new Error("PLATFORM_ROOT environment variable is required");
    }
    let password;
    if (process.env.NEO4J_PASSWORD) {
        password = process.env.NEO4J_PASSWORD;
    }
    else {
        const passwordFile = resolve(platformRoot, "config/.neo4j-password");
        try {
            password = readFileSync(passwordFile, "utf-8").trim();
        }
        catch {
            throw new Error(`Neo4j password not found at ${passwordFile}`);
        }
    }
    // NEO4J_URI must be explicit. Crontab installer prefixes
    // NEO4J_URI=bolt://localhost:${NEO4J_PORT} so the cron path is safe, but the
    // throw-on-unset catches manual runs that forget to source .env.
    const uri = process.env.NEO4J_URI;
    if (!uri) {
        throw new Error("[scheduling/check-due-events] NEO4J_URI unset — refusing to default to bolt://localhost:7687");
    }
    const user = process.env.NEO4J_USER ?? "neo4j";
    console.error(`[scheduling/check-due-events] resolved neo4j_uri=${uri}`);
    return neo4j.driver(uri, neo4j.auth.basic(user, password));
}
// ---------------------------------------------------------------------------
// MCP tool dispatch
// ---------------------------------------------------------------------------
// 10-minute ceiling — accommodates normal workflow durations (30-90 s for
// agentic LLM steps on Sonnet 4.6; occasionally longer under degraded
// conditions). Tail of the protection stack: workflow-execute's SIGTERM
// handler (platform/plugins/workflows/mcp/src/index.ts) still terminal-
// transitions the run if this ceiling fires.
const DISPATCH_TIMEOUT_MS = 600_000;
async function dispatchAction(eventId, actionPlugin, actionTool, actionArgs, eventAccountId, driver) {
    const platformRoot = process.env.PLATFORM_ROOT;
    const serverPath = resolve(platformRoot, "plugins", actionPlugin, "mcp", "dist", "index.js");
    if (!existsSync(serverPath)) {
        throw new Error(`MCP server not found: ${serverPath}`);
    }
    // Dynamic import — the MCP SDK Client is only needed when dispatching
    const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
    const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
    // Build env: inherit parent env (for NEO4J_*, OLLAMA_*, etc.) + set ACCOUNT_ID
    const env = {};
    for (const [k, v] of Object.entries(process.env)) {
        if (v !== undefined)
            env[k] = v;
    }
    env.PLATFORM_ROOT = platformRoot;
    env.ACCOUNT_ID = eventAccountId;
    const transport = new StdioClientTransport({
        command: process.execPath,
        args: [serverPath],
        env,
        stderr: "pipe",
    });
    // Collect child stderr for diagnostics on failure
    let childStderr = "";
    const stderrStream = transport.stderr;
    if (stderrStream) {
        stderrStream.on("data", (chunk) => {
            // Cap at 4KB to avoid unbounded memory
            if (childStderr.length < 4096) {
                childStderr += chunk.toString("utf-8").slice(0, 4096 - childStderr.length);
            }
        });
    }
    const client = new Client({ name: "event-dispatcher", version: "1.0.0" });
    let timedOut = false;
    // The timeout kills the child process via client.close(), which causes
    // the pending callTool() promise to reject — unlike AbortController,
    // this actually stops the work.
    const timeout = setTimeout(() => {
        timedOut = true;
        client.close().catch(() => { });
    }, DISPATCH_TIMEOUT_MS);
    try {
        await client.connect(transport);
        const args = actionArgs ? JSON.parse(actionArgs) : {};
        const result = await client.callTool({ name: actionTool, arguments: args });
        // Check if the tool itself reported an error
        const isError = result.isError === true;
        if (isError) {
            const contentArr = Array.isArray(result.content) ? result.content : [];
            const resultText = contentArr
                .filter((c) => c.type === "text")
                .map((c) => c.text)
                .join("\n");
            throw new Error(`Tool returned error: ${resultText}`);
        }
        // Log success
        const now = new Date().toISOString();
        const logSession = driver.session();
        try {
            await logSession.run(`MATCH (e:Event {eventId: $eventId})
         SET e.lastDispatchResult = 'success',
             e.lastDispatchAt = $now,
             e.notes = COALESCE(e.notes, '') + $noteEntry`, {
                eventId,
                now,
                noteEntry: `\n[${now}] Action dispatched: ${actionTool} — success`,
            });
        }
        finally {
            await logSession.close();
        }
        console.error(`[check-due] dispatched: ${actionTool} on ${actionPlugin} — success`);
    }
    catch (err) {
        const now = new Date().toISOString();
        const msg = err instanceof Error ? err.message : String(err);
        const outcome = timedOut ? "timeout" : "error";
        const stderrSuffix = childStderr ? `\nChild stderr: ${childStderr.trim()}` : "";
        // Log failure to Event node (best-effort — Neo4j may also be down)
        try {
            const logSession = driver.session();
            try {
                await logSession.run(`MATCH (e:Event {eventId: $eventId})
           SET e.lastDispatchResult = $outcome,
               e.lastDispatchAt = $now,
               e.notes = COALESCE(e.notes, '') + $noteEntry`, {
                    eventId,
                    outcome,
                    now,
                    noteEntry: `\n[${now}] Action dispatch FAILED: ${actionTool} — ${msg}${stderrSuffix}`,
                });
            }
            finally {
                await logSession.close();
            }
        }
        catch {
            // Neo4j logging failed — stderr is the only record
        }
        console.error(`[check-due] dispatch FAILED: ${actionTool} on ${actionPlugin} — ${outcome}: ${msg}${stderrSuffix}`);
    }
    finally {
        clearTimeout(timeout);
        if (!timedOut) {
            try {
                await client.close();
            }
            catch {
                // Best-effort cleanup
            }
        }
    }
}
/**
 * Extract the lifecycle field from a PLUGIN.md frontmatter.
 * Returns null if the file is missing, unreadable, or has no lifecycle field.
 */
function readPluginLifecycle(pluginDir) {
    const platformRoot = process.env.PLATFORM_ROOT;
    const pluginPath = resolve(platformRoot, "plugins", pluginDir, "PLUGIN.md");
    if (!existsSync(pluginPath))
        return null;
    let raw;
    try {
        raw = readFileSync(pluginPath, "utf-8");
    }
    catch {
        return null;
    }
    const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
    if (!fmMatch)
        return null;
    const lifecycleMatch = fmMatch[1].match(/^lifecycle:\s*(.+)$/m);
    if (!lifecycleMatch)
        return null;
    try {
        return JSON.parse(lifecycleMatch[1]);
    }
    catch {
        console.error(`[reconcile] ${pluginDir}/PLUGIN.md: invalid lifecycle JSON`);
        return null;
    }
}
/**
 * Read account.json for an account directory.
 * Returns enabledPlugins array, or null if unreadable.
 */
function readEnabledPlugins(accountDir) {
    const configPath = join(accountDir, "account.json");
    if (!existsSync(configPath))
        return null;
    try {
        const config = JSON.parse(readFileSync(configPath, "utf-8"));
        return Array.isArray(config.enabledPlugins) ? config.enabledPlugins : [];
    }
    catch {
        console.error(`[reconcile] cannot read ${configPath}`);
        return null;
    }
}
/**
 * Reconcile plugin lifecycle: activate enabled plugins that have no
 * scheduled Event, deactivate Events for disabled plugins.
 */
async function reconcilePluginLifecycles(driver) {
    const platformRoot = process.env.PLATFORM_ROOT;
    const accountsDir = resolve(platformRoot, "..", "data/accounts");
    if (!existsSync(accountsDir))
        return;
    let accountDirs;
    try {
        accountDirs = readdirSync(accountsDir).filter((d) => {
            if (d.startsWith("."))
                return false;
            try {
                return existsSync(join(accountsDir, d, "account.json"));
            }
            catch {
                return false;
            }
        });
    }
    catch {
        console.error("[reconcile] cannot read accounts directory");
        return;
    }
    for (const accountName of accountDirs) {
        const accountDir = join(accountsDir, accountName);
        const enabledPlugins = readEnabledPlugins(accountDir);
        if (!enabledPlugins)
            continue;
        console.error(`[reconcile] checking ${enabledPlugins.length} enabled plugins for account ${accountName}`);
        // --- Lifecycle: activate plugins missing Events, heal plugins with Events ---
        for (const pluginName of enabledPlugins) {
            const lifecycle = readPluginLifecycle(pluginName);
            if (!lifecycle?.activate && !lifecycle?.heal)
                continue;
            // Check if an active Event already exists for this plugin
            const checkSession = driver.session();
            try {
                const result = await checkSession.run(`MATCH (e:Event {sourcePlugin: $pluginName, accountId: $accountId})
           WHERE e.eventStatus = 'scheduled'
           RETURN e.eventId AS eventId LIMIT 1`, { pluginName, accountId: accountName });
                if (result.records.length > 0) {
                    // Event exists — plugin is active. Run heal if declared.
                    if (lifecycle.heal) {
                        try {
                            await dispatchAction(`lifecycle-heal-${pluginName}`, pluginName, lifecycle.heal, JSON.stringify({ accountId: accountName }), accountName, driver);
                            console.error(`[reconcile] heal dispatched: ${pluginName} — success`);
                        }
                        catch (err) {
                            console.error(`[reconcile] heal FAILED: ${pluginName} — ${err instanceof Error ? err.message : String(err)}`);
                        }
                    }
                    continue;
                }
                // No event — activate if the plugin declares an activate tool
                if (lifecycle.activate) {
                    console.error(`[reconcile] activating plugin ${pluginName} — no scheduled Event found`);
                    try {
                        await dispatchAction(`lifecycle-${pluginName}`, pluginName, lifecycle.activate, JSON.stringify({ accountId: accountName }), accountName, driver);
                        console.error(`[reconcile] activation dispatched: ${pluginName} — success`);
                    }
                    catch (err) {
                        console.error(`[reconcile] activation FAILED: ${pluginName} — ${err instanceof Error ? err.message : String(err)}`);
                    }
                }
            }
            finally {
                await checkSession.close();
            }
        }
        // --- Deactivation: Events with sourcePlugin not in enabledPlugins ---
        const deactivateSession = driver.session();
        try {
            const now = new Date().toISOString();
            const result = await deactivateSession.run(`MATCH (e:Event {accountId: $accountId})
         WHERE e.sourcePlugin IS NOT NULL
           AND e.eventStatus = 'scheduled'
           AND NOT e.sourcePlugin IN $enabledPlugins
         SET e.eventStatus = 'cancelled',
             e.updatedAt = $now,
             e.notes = COALESCE(e.notes, '') + $noteEntry
         RETURN e.eventId AS eventId, e.sourcePlugin AS sourcePlugin`, {
                accountId: accountName,
                enabledPlugins,
                now,
                noteEntry: `\n[${now}] Cancelled by lifecycle reconciliation — plugin disabled`,
            });
            for (const record of result.records) {
                console.error(`[reconcile] cancelled Event ${record.get("eventId")} (sourcePlugin: ${record.get("sourcePlugin")}) — plugin disabled`);
            }
        }
        finally {
            await deactivateSession.close();
        }
    }
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
/**
 * Account IDs present on this install's filesystem. Dispatch queries
 * are scoped to this set so a scheduler connected (by env misconfig or
 * intent) to a Neo4j instance shared with another install never
 * dispatches foreign events against the local platformRoot. Without
 * this guard, install A's workflow-execute runs against install B's
 * accountId, finds no matching data/accounts/<id>/ directory under A's
 * platformRoot, and surfaces as silent "No model configured" failures
 * on B's Events — the root cause pattern is stale cross-install cron
 * dispatches.
 */
function listLocalAccountIds(platformRoot) {
    const accountsDir = resolve(platformRoot, "..", "data/accounts");
    if (!existsSync(accountsDir))
        return [];
    try {
        return readdirSync(accountsDir).filter((d) => {
            if (d.startsWith("."))
                return false;
            try {
                return existsSync(join(accountsDir, d, "account.json"));
            }
            catch {
                return false;
            }
        });
    }
    catch {
        return [];
    }
}
async function main() {
    if (!acquireLock()) {
        console.error("[check-due] another instance running, exiting");
        process.exit(0);
    }
    console.error("[check-due] lock acquired");
    const driver = connectNeo4j();
    const now = new Date().toISOString();
    const localAccountIds = listLocalAccountIds(process.env.PLATFORM_ROOT);
    let processed = 0;
    let dispatched = 0;
    let errors = 0;
    if (localAccountIds.length === 0) {
        console.error("[check-due] no local accounts under platformRoot — skipping dispatch sweep (reconcile still runs)");
    }
    try {
        // --- One-time events: startDate <= now AND status = 'scheduled' AND no recurrence ---
        // Scoped to local accounts so a scheduler sharing a Neo4j instance with
        // another install never dispatches foreign events.
        const oneTimeSession = driver.session();
        try {
            const oneTimeResult = await oneTimeSession.run(`MATCH (e:Event)
         WHERE e.eventStatus = 'scheduled'
           AND e.recurrence IS NULL
           AND e.startDate <= $now
           AND e.accountId IN $localAccountIds
         SET e.eventStatus = 'due',
             e.updatedAt = $now,
             e.notes = COALESCE(e.notes, '') + $noteEntry
         RETURN e.eventId AS eventId, e.name AS name,
                e.accountId AS accountId,
                e.actionPlugin AS actionPlugin,
                e.actionTool AS actionTool,
                e.actionArgs AS actionArgs`, {
                now,
                localAccountIds,
                noteEntry: `\n[${now}] Marked due by cron`,
            });
            for (const record of oneTimeResult.records) {
                const eventId = record.get("eventId");
                const name = record.get("name");
                const actionPlugin = record.get("actionPlugin");
                const actionTool = record.get("actionTool");
                const actionArgs = record.get("actionArgs");
                const accountId = record.get("accountId");
                console.error(`[check-due] one-time due: ${name} (${eventId})`);
                processed++;
                if (actionPlugin && actionTool && accountId) {
                    await dispatchAction(eventId, actionPlugin, actionTool, actionArgs, accountId, driver);
                    dispatched++;
                }
            }
        }
        finally {
            await oneTimeSession.close();
        }
        // --- Recurring events: nextRun <= now AND status = 'scheduled' ---
        // Scoped to local accounts (see one-time query above).
        const recurringSession = driver.session();
        try {
            const recurringResult = await recurringSession.run(`MATCH (e:Event)
         WHERE e.eventStatus = 'scheduled'
           AND e.recurrence IS NOT NULL
           AND e.nextRun IS NOT NULL
           AND e.nextRun <= $now
           AND e.accountId IN $localAccountIds
         RETURN e.eventId AS eventId, e.name AS name,
                e.recurrence AS recurrence, e.nextRun AS nextRun,
                e.timezone AS timezone, e.accountId AS accountId,
                e.actionPlugin AS actionPlugin,
                e.actionTool AS actionTool,
                e.actionArgs AS actionArgs`, { now, localAccountIds });
            for (const record of recurringResult.records) {
                const eventId = record.get("eventId");
                const name = record.get("name");
                const recurrence = record.get("recurrence");
                const currentNextRun = record.get("nextRun");
                const timezone = record.get("timezone");
                const actionPlugin = record.get("actionPlugin");
                const actionTool = record.get("actionTool");
                const actionArgs = record.get("actionArgs");
                const accountId = record.get("accountId");
                try {
                    const interval = parseExpression(recurrence, {
                        currentDate: new Date(currentNextRun),
                        tz: timezone ?? undefined,
                    });
                    const newNextRun = interval.next().toISOString();
                    // Update FIRST — advance nextRun before dispatch so a second
                    // instance won't re-dispatch if it starts during our dispatch
                    const updateSession = driver.session();
                    try {
                        await updateSession.run(`MATCH (e:Event {eventId: $eventId})
               SET e.lastTriggered = $now,
                   e.nextRun = $newNextRun,
                   e.updatedAt = $now,
                   e.notes = COALESCE(e.notes, '') + $noteEntry`, {
                            eventId,
                            now,
                            newNextRun,
                            noteEntry: `\n[${now}] Triggered (next: ${newNextRun})`,
                        });
                    }
                    finally {
                        await updateSession.close();
                    }
                    console.error(`[check-due] recurring triggered: ${name} (${eventId}), next: ${newNextRun}`);
                    processed++;
                    // Dispatch AFTER the nextRun update
                    if (actionPlugin && actionTool && accountId) {
                        await dispatchAction(eventId, actionPlugin, actionTool, actionArgs, accountId, driver);
                        dispatched++;
                    }
                }
                catch (err) {
                    console.error(`[check-due] ERROR processing ${name} (${eventId}): ${err instanceof Error ? err.message : String(err)}`);
                    errors++;
                }
            }
        }
        finally {
            await recurringSession.close();
        }
        // --- Plugin lifecycle reconciliation ---
        try {
            await reconcilePluginLifecycles(driver);
        }
        catch (err) {
            console.error(`[reconcile] ERROR: ${err instanceof Error ? err.message : String(err)}`);
            errors++;
        }
        console.error(`[check-due] done: ${processed} processed, ${dispatched} dispatched, ${errors} errors`);
    }
    finally {
        await driver.close();
        releaseLock();
    }
    process.exit(errors > 0 ? 1 : 0);
}
main().catch((err) => {
    console.error(`[check-due] FATAL: ${err instanceof Error ? err.message : String(err)}`);
    releaseLock();
    process.exit(1);
});
//# sourceMappingURL=check-due-events.js.map