/**
 * @fileoverview Workflow (ultracode) Run Watcher
 *
 * Watches `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` —
 * the run-state JSON the Workflow tool writes for each ultracode run — and emits
 * events powering the master-detail "working agents" view (tasks/phases on the
 * LEFT, per-agent tokens/tool-calls on the RIGHT).
 *
 * Deliberately STANDALONE: it never imports from or touches subagent-watcher.ts.
 * It globs the run-state tree (`.../workflows/wf_*.json`); subagent-watcher globs
 * the disjoint, deeper transcript tree (`.../subagents/workflows/wf_<id>/agent-*`).
 * Separate singletons, disjoint directories, no shared mutable state.
 *
 * Discovery is dual: a periodic poll (catches new run dirs + removals) plus a
 * per-run-dir chokidar watcher (live within-run updates). A per-file mtime skip
 * keeps the hot path cheap — the run JSON is rewritten on every agent tick.
 *
 * @module workflow-run-watcher
 */
import { EventEmitter } from 'node:events';
import { readdir, readFile, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { watch as chokidarWatch } from 'chokidar';
import { LRUMap } from './utils/lru-map.js';
import { WORKFLOW_RUN_POLL_INTERVAL_MS, MAX_CACHED_WORKFLOW_RUNS, WORKFLOW_RUN_RECENT_WINDOW_MIN, } from './config/workflow-config.js';
const WORKFLOWS_SUBDIR = 'workflows';
const RUN_FILE_PREFIX = 'wf_';
const RUN_FILE_SUFFIX = '.json';
/** Hard caps on the largest per-agent strings so a 28-agent run stays compact. */
const PROMPT_PREVIEW_MAX = 200;
const RESULT_PREVIEW_MAX = 240;
function truncate(value, max) {
    if (typeof value !== 'string')
        return undefined;
    return value.length > max ? `${value.slice(0, max)}…` : value;
}
/** Drop the heavy `agents[]` for list/snapshot use. */
export function summarizeRun(info) {
    const { agents: _agents, ...summary } = info;
    void _agents;
    return summary;
}
export class WorkflowRunWatcher extends EventEmitter {
    projectsDir;
    pollTimer = null;
    _isRunning = false;
    /** runId -> latest parsed run info (LRU-bounded). */
    runs = new LRUMap({ maxSize: MAX_CACHED_WORKFLOW_RUNS });
    /** absolute run-file path -> last seen mtimeMs (skip unchanged files). */
    fileMtimes = new Map();
    /** runId -> absolute run-file path (for mtime cleanup on removal). */
    runIdToPath = new Map();
    /** workflows-dir absolute path -> chokidar watcher (one per live run dir). */
    dirWatchers = new Map();
    constructor(projectsDir) {
        super();
        this.projectsDir = projectsDir || join(homedir(), '.claude', 'projects');
        this.setMaxListeners(50);
    }
    // ========== Public API ==========
    isRunning() {
        return this._isRunning;
    }
    start() {
        if (this._isRunning)
            return;
        this._isRunning = true;
        this.poll();
        this.pollTimer = setInterval(() => this.poll(), WORKFLOW_RUN_POLL_INTERVAL_MS);
    }
    stop() {
        this._isRunning = false;
        if (this.pollTimer) {
            clearInterval(this.pollTimer);
            this.pollTimer = null;
        }
        for (const watcher of this.dirWatchers.values()) {
            watcher.close().catch(() => { }); // best-effort teardown
        }
        this.dirWatchers.clear();
        this.runs.clear();
        this.fileMtimes.clear();
        this.runIdToPath.clear();
    }
    /** All cached runs (no recency filter), most-recently-active first. */
    getAllRuns() {
        return Array.from(this.runs.values()).sort((a, b) => b.lastActivityAt - a.lastActivityAt);
    }
    /** Runs active within the last `minutes`, most-recently-active first. */
    getRecentRuns(minutes = WORKFLOW_RUN_RECENT_WINDOW_MIN) {
        const cutoff = Date.now() - minutes * 60_000;
        return this.getAllRuns().filter((r) => r.lastActivityAt >= cutoff);
    }
    /**
     * Lightweight summaries (no agents[]) of ALL cached runs, most-recently-active
     * first. This is the LEFT-pane list + getLightState snapshot source: the cache
     * is LRU-bounded (MAX_CACHED_WORKFLOW_RUNS), so it's already size-capped, and a
     * run-browser should show past runs — NOT hide everything older than a window.
     */
    getAllRunSummaries() {
        return this.getAllRuns().map(summarizeRun);
    }
    /** Summaries filtered to the last `minutes` of activity (opt-in via ?minutes). */
    getRecentRunSummaries(minutes = WORKFLOW_RUN_RECENT_WINDOW_MIN) {
        return this.getRecentRuns(minutes).map(summarizeRun);
    }
    getRun(runId) {
        return this.runs.get(runId);
    }
    getStats() {
        let running = 0;
        let agentCount = 0;
        for (const run of this.runs.values()) {
            if (run.status === 'running')
                running++;
            agentCount += run.agents.length;
        }
        return { runCount: this.runs.size, running, agentCount };
    }
    // ========== Private ==========
    poll() {
        this.pollAsync().catch(() => {
            // Filesystem may be transiently unavailable; the next poll retries.
        });
    }
    async pollAsync() {
        const { files, dirs } = await this.discover();
        // Install a live watcher for each run dir; tear down watchers for dirs that vanished.
        for (const dir of dirs)
            this.ensureDirWatcher(dir);
        for (const dir of Array.from(this.dirWatchers.keys())) {
            if (!dirs.has(dir))
                this.removeDirWatcher(dir);
        }
        const seenRunIds = new Set();
        for (const file of files) {
            seenRunIds.add(file.runId);
            await this.maybeParse(file);
        }
        // Removal by set-diff: a cached run whose file disappeared.
        for (const runId of Array.from(this.runs.keys())) {
            if (!seenRunIds.has(runId)) {
                this.runs.delete(runId);
                const path = this.runIdToPath.get(runId);
                if (path)
                    this.fileMtimes.delete(path);
                this.runIdToPath.delete(runId);
                this.emit('run_removed', { runId });
            }
        }
    }
    /** Walk projects/<projHash>/<sessionUuid>/workflows/ for wf_*.json files. */
    async discover() {
        const files = [];
        const dirs = new Set();
        let projectHashes;
        try {
            projectHashes = await readdir(this.projectsDir);
        }
        catch {
            return { files, dirs };
        }
        for (const projectHash of projectHashes) {
            let sessions;
            try {
                sessions = await readdir(join(this.projectsDir, projectHash));
            }
            catch {
                continue;
            }
            for (const sessionUuid of sessions) {
                const workflowsDir = join(this.projectsDir, projectHash, sessionUuid, WORKFLOWS_SUBDIR);
                let names;
                try {
                    names = await readdir(workflowsDir);
                }
                catch {
                    continue; // no workflows dir for this session — normal
                }
                let hasRun = false;
                for (const name of names) {
                    if (!name.startsWith(RUN_FILE_PREFIX) || !name.endsWith(RUN_FILE_SUFFIX))
                        continue;
                    hasRun = true;
                    files.push({
                        filePath: join(workflowsDir, name),
                        projectHash,
                        sessionUuid,
                        runId: name.slice(0, -RUN_FILE_SUFFIX.length),
                    });
                }
                if (hasRun)
                    dirs.add(workflowsDir);
            }
        }
        return { files, dirs };
    }
    async maybeParse(file) {
        let mtime;
        try {
            mtime = (await stat(file.filePath)).mtimeMs;
        }
        catch {
            return; // vanished between discover and stat
        }
        if (this.fileMtimes.get(file.filePath) === mtime)
            return;
        this.fileMtimes.set(file.filePath, mtime);
        const info = await this.parseFile(file);
        if (!info)
            return;
        const existed = this.runs.has(info.runId);
        this.runs.set(info.runId, info);
        this.runIdToPath.set(info.runId, file.filePath);
        this.emit(existed ? 'run_updated' : 'run_discovered', info);
    }
    /**
     * Parse a wf_<runId>.json into WorkflowRunInfo, STRIPPING the heavyweight
     * `script`/`scriptPath`/`result`/`logs` fields (the embedded script alone is
     * 15–660KB) so they never reach the cache, SSE, or routes.
     */
    async parseFile(file) {
        let raw;
        try {
            raw = JSON.parse(await readFile(file.filePath, 'utf-8'));
        }
        catch {
            return null; // mid-write or malformed — next mtime change re-parses
        }
        if (!raw || typeof raw !== 'object')
            return null;
        const progress = Array.isArray(raw.workflowProgress) ? raw.workflowProgress : [];
        const agents = progress
            .filter((e) => e && e.type === 'workflow_agent')
            .map((e) => this.toAgent(e));
        const phases = Array.isArray(raw.phases)
            ? raw.phases.map((p) => ({
                title: typeof p.title === 'string' ? p.title : '',
                detail: typeof p.detail === 'string' ? p.detail : '',
            }))
            : [];
        let lastProgress = 0;
        for (const a of agents) {
            if (typeof a.lastProgressAt === 'number' && a.lastProgressAt > lastProgress)
                lastProgress = a.lastProgressAt;
        }
        const startTime = typeof raw.startTime === 'number' ? raw.startTime : undefined;
        const lastActivityAt = lastProgress || startTime || 0;
        return {
            runId: typeof raw.runId === 'string' ? raw.runId : file.runId,
            workflowName: typeof raw.workflowName === 'string' ? raw.workflowName : undefined,
            status: typeof raw.status === 'string' ? raw.status : undefined,
            summary: typeof raw.summary === 'string' ? raw.summary : undefined,
            agentCount: typeof raw.agentCount === 'number' ? raw.agentCount : undefined,
            totalTokens: typeof raw.totalTokens === 'number' ? raw.totalTokens : undefined,
            totalToolCalls: typeof raw.totalToolCalls === 'number' ? raw.totalToolCalls : undefined,
            durationMs: typeof raw.durationMs === 'number' ? raw.durationMs : undefined,
            startTime,
            timestamp: typeof raw.timestamp === 'string' ? raw.timestamp : undefined,
            defaultModel: typeof raw.defaultModel === 'string' ? raw.defaultModel : undefined,
            taskId: typeof raw.taskId === 'string' ? raw.taskId : undefined,
            error: typeof raw.error === 'string' ? raw.error : undefined,
            phases,
            agents,
            sessionUuid: file.sessionUuid,
            projectHash: file.projectHash,
            lastActivityAt,
        };
    }
    toAgent(e) {
        return {
            index: typeof e.index === 'number' ? e.index : 0,
            label: typeof e.label === 'string' ? e.label : '',
            phaseIndex: typeof e.phaseIndex === 'number' ? e.phaseIndex : 0,
            phaseTitle: typeof e.phaseTitle === 'string' ? e.phaseTitle : '',
            model: typeof e.model === 'string' ? e.model : '',
            state: typeof e.state === 'string' ? e.state : 'start',
            queuedAt: e.queuedAt,
            lastProgressAt: e.lastProgressAt,
            promptPreview: truncate(e.promptPreview, PROMPT_PREVIEW_MAX),
            agentId: e.agentId,
            startedAt: e.startedAt,
            attempt: e.attempt,
            tokens: e.tokens,
            toolCalls: e.toolCalls,
            lastToolName: e.lastToolName,
            lastToolSummary: truncate(e.lastToolSummary, RESULT_PREVIEW_MAX),
            durationMs: e.durationMs,
            resultPreview: truncate(e.resultPreview, RESULT_PREVIEW_MAX),
        };
    }
    ensureDirWatcher(workflowsDir) {
        if (this.dirWatchers.has(workflowsDir))
            return;
        try {
            const watcher = chokidarWatch(workflowsDir, {
                depth: 0,
                awaitWriteFinish: { stabilityThreshold: 200 },
                ignoreInitial: true,
                persistent: false,
            });
            const handler = () => this.poll();
            watcher.on('add', handler);
            watcher.on('change', handler);
            watcher.on('unlink', handler);
            watcher.on('error', () => {
                // chokidar surfaced an error for this dir — drop the watcher; poll still covers it.
                this.removeDirWatcher(workflowsDir);
            });
            this.dirWatchers.set(workflowsDir, watcher);
        }
        catch {
            // Watch setup failed — periodic poll still discovers changes.
        }
    }
    removeDirWatcher(workflowsDir) {
        const watcher = this.dirWatchers.get(workflowsDir);
        if (watcher) {
            watcher.close().catch(() => { });
            this.dirWatchers.delete(workflowsDir);
        }
    }
}
/** Process-wide singleton (mirrors subagentWatcher / imageWatcher). */
export const workflowRunWatcher = new WorkflowRunWatcher();
//# sourceMappingURL=workflow-run-watcher.js.map