import { resolve } from 'node:path';
import dayjs from 'dayjs';
import omit from 'lodash/omit.js';
import orderBy from 'lodash/orderBy.js';
import { FileHeartbeatTaskRepository } from './repository.js';
/**
 * Heartbeat task service.
 *
 * This is the persistence boundary for durable heartbeat tasks, checkpoints,
 * run records, and operator-facing task/run projections. Hosts should call this
 * service, not the file repository.
 */
export class FileHeartbeatTaskService {
    repository;
    constructor(options) {
        this.repository = new FileHeartbeatTaskRepository({
            dir: FileHeartbeatTaskService.resolveHeartbeatRoot(options),
        });
    }
    async listTasks() {
        return await this.repository.listTasks();
    }
    async saveTask(task) {
        await this.repository.saveTask(task);
    }
    async loadCheckpoint(task) {
        return await this.repository.loadCheckpoint(task);
    }
    async saveCheckpoint(task, checkpoint) {
        await this.repository.saveCheckpoint(task, checkpoint);
    }
    async saveRunRecord(record) {
        await this.repository.saveRunRecord?.(record);
    }
    async listRunRecords(options) {
        return await this.repository.listRunRecords?.(options) ?? [];
    }
    async loadRunRecord(id) {
        return await this.repository.loadRunRecord?.(id);
    }
    async listTaskViews() {
        return orderBy((await this.listTasks()).map((task) => FileHeartbeatTaskService.projectTaskView(task)), [(task) => FileHeartbeatTaskService.taskLastRunTime(task)], ['desc']);
    }
    async listRunViews(options = {}) {
        const runs = await this.listRunRecords(options);
        return runs.map((run) => FileHeartbeatTaskService.projectRunView(run));
    }
    async createTask(input) {
        const tasks = await this.listTasks();
        const now = dayjs();
        const id = input.id ?? FileHeartbeatTaskService.createTaskId(input.name ?? input.task, tasks.map((task) => task.id));
        if (tasks.some((task) => task.id === id)) {
            throw new Error(`Heartbeat task already exists: ${id}`);
        }
        const intervalMs = input.intervalMs ?? 60 * 60_000;
        const task = {
            id,
            workspaceId: input.workspaceId,
            name: input.name,
            task: input.task.trim(),
            enabled: input.enabled ?? true,
            continuationMode: input.continuationMode ?? 'operator',
            schedule: {
                intervalMs,
                nextRunAt: (input.defer === false ? now.subtract(1, 'second') : now.add(intervalMs, 'millisecond')).toISOString(),
            },
            runtime: {
                model: input.model,
                maxSteps: input.maxSteps,
                workspaceRoot: input.workspaceRoot,
                stateDir: input.stateDir,
                searchIgnoreDirs: input.searchIgnoreDirs,
                systemContext: input.systemContext,
            },
            state: {
                status: input.enabled === false ? 'idle' : 'waiting',
                updatedAt: now.toISOString(),
            },
        };
        await this.saveTask(task);
        return FileHeartbeatTaskService.projectTaskView(task);
    }
    async updateTask(taskId, input) {
        const task = await this.requireTask(taskId);
        const now = dayjs();
        const intervalMs = input.intervalMs ?? task.schedule.intervalMs;
        const running = task.state?.status === 'running';
        const nextTask = {
            ...task,
            name: input.name ?? task.name,
            task: input.task?.trim() ?? task.task,
            enabled: input.enabled ?? task.enabled,
            continuationMode: input.continuationMode ?? task.continuationMode ?? 'operator',
            schedule: {
                ...task.schedule,
                intervalMs,
                nextRunAt: running ? task.schedule.nextRunAt : now.add(intervalMs, 'millisecond').toISOString(),
            },
            runtime: {
                ...task.runtime,
                model: input.model === undefined ? task.runtime?.model : input.model ?? undefined,
                maxSteps: input.maxSteps === undefined ? task.runtime?.maxSteps : input.maxSteps ?? undefined,
                searchIgnoreDirs: input.searchIgnoreDirs ?? task.runtime?.searchIgnoreDirs,
                systemContext: input.systemContext ?? task.runtime?.systemContext,
            },
            state: {
                ...task.state,
                updatedAt: now.toISOString(),
            },
        };
        await this.saveTask(nextTask);
        return FileHeartbeatTaskService.projectTaskView(nextTask);
    }
    async deleteTask(taskId) {
        const task = await this.requireTask(taskId);
        if (task.state?.status === 'running') {
            throw new Error(`Heartbeat task ${taskId} is running. Wait for the run to finish before deleting it.`);
        }
        await this.repository.deleteTask(task);
        return FileHeartbeatTaskService.projectTaskView(task);
    }
    async resumeTask(taskId) {
        const task = await this.requireTask(taskId);
        if (task.state?.status === 'running') {
            throw new Error(`Heartbeat task ${taskId} is already running.`);
        }
        if (task.state?.resumable === false) {
            throw new Error(`Heartbeat task ${taskId} cannot be resumed.`);
        }
        const now = dayjs();
        const nextTask = {
            ...task,
            enabled: true,
            schedule: {
                ...task.schedule,
                nextRunAt: now.subtract(1, 'second').toISOString(),
            },
            state: {
                ...omit(task.state, ['error']),
                status: 'waiting',
                progress: 'Heartbeat task resumed. Waiting for the next scheduler poll.',
                updatedAt: now.toISOString(),
            },
        };
        await this.saveTask(nextTask);
        return FileHeartbeatTaskService.projectTaskView(nextTask);
    }
    async readTask(taskId, options = {}) {
        const task = await this.requireTask(taskId);
        const runs = await this.listRunViews({
            taskId,
            limit: options.runLimit ?? 50,
        });
        return {
            task: FileHeartbeatTaskService.projectTaskView(task),
            runs,
        };
    }
    async readRun(taskId, runId) {
        await this.requireTask(taskId);
        const run = runId === 'latest' ?
            (await this.listRunRecords({ taskId, limit: 1 }))[0]
            : await this.loadRunRecord(runId);
        if (!run || run.taskId !== taskId) {
            return undefined;
        }
        return FileHeartbeatTaskService.projectRunView(run);
    }
    async setTaskEnabled(taskId, enabled) {
        const task = await this.requireTask(taskId);
        const now = dayjs();
        if (enabled && task.state?.status === 'blocked') {
            throw new Error(`Heartbeat task ${taskId} is blocked. Use resume to unblock it.`);
        }
        const status = FileHeartbeatTaskService.resolveTaskEnabledStatus(task, enabled);
        const progress = FileHeartbeatTaskService.resolveTaskEnabledProgress(task, enabled);
        const nextTask = {
            ...task,
            enabled,
            schedule: {
                ...task.schedule,
                nextRunAt: enabled ?
                    task.schedule.nextRunAt ?? now.subtract(1, 'second').toISOString()
                    : undefined,
            },
            state: {
                ...task.state,
                status,
                progress,
                resumable: enabled ? true : task.state?.resumable,
                updatedAt: now.toISOString(),
            },
        };
        await this.saveTask(nextTask);
        return FileHeartbeatTaskService.projectTaskView(nextTask);
    }
    async triggerTaskRun(taskId) {
        const task = await this.requireTask(taskId);
        if (!task.enabled) {
            throw new Error(`Heartbeat task ${taskId} is disabled. Enable it before triggering a run.`);
        }
        const now = dayjs();
        const status = task.state?.status === 'running' ? 'running' : 'waiting';
        const nextTask = {
            ...task,
            schedule: {
                ...task.schedule,
                nextRunAt: now.subtract(1, 'second').toISOString(),
            },
            state: {
                ...task.state,
                status,
                progress: task.state?.status === 'running' ?
                    task.state.progress
                    : 'Task manually triggered from control plane. Waiting for the next heartbeat worker poll.',
                updatedAt: now.toISOString(),
            },
        };
        await this.saveTask(nextTask);
        return FileHeartbeatTaskService.projectTaskView(nextTask);
    }
    async requireTask(taskId) {
        const tasks = await this.listTasks();
        const task = tasks.find((candidate) => candidate.id === taskId);
        if (!task) {
            throw new Error(`Heartbeat task not found: ${taskId}`);
        }
        return task;
    }
    projectTaskView(task) {
        return FileHeartbeatTaskService.projectTaskView(task);
    }
    projectRunView(run) {
        return FileHeartbeatTaskService.projectRunView(run);
    }
    static projectTaskView(task) {
        const state = FileHeartbeatTaskService.projectTaskStateView(task.state);
        return {
            ...task,
            taskId: task.id,
            state,
        };
    }
    static projectRunView(run) {
        return {
            ...omit(run, ['record', 'path']),
            ...FileHeartbeatTaskService.projectRunRecordView(run.record),
        };
    }
    static projectRunRecordView(record) {
        const runId = record.result.state.runId;
        return {
            id: runId,
            taskId: record.task.id,
            runId,
            workspaceId: record.task.workspaceId,
            createdAt: record.result.state.finishedAt,
            task: FileHeartbeatTaskService.projectTaskView(record.task),
            result: FileHeartbeatTaskService.projectResultView(record.result),
            loadedCheckpoint: record.loadedCheckpoint,
        };
    }
    static projectTaskStateView(state) {
        const result = state?.result;
        return {
            ...omit(state ?? {}, ['result']),
            status: state?.status ?? 'idle',
            result: result ? FileHeartbeatTaskService.projectResultView(result) : undefined,
        };
    }
    static projectResultView(result) {
        return {
            decision: result.decision,
            summary: result.summary,
            outcome: result.state.outcome,
            usage: result.state.usage,
        };
    }
    static taskLastRunTime(task) {
        const runAt = task.state.runAt ? dayjs(task.state.runAt) : undefined;
        return runAt?.isValid() ? runAt.valueOf() : 0;
    }
    static resolveTaskEnabledStatus(task, enabled) {
        if (task.state?.status === 'running') {
            return 'running';
        }
        if (!enabled && task.state?.status === 'blocked') {
            return 'blocked';
        }
        return enabled ? 'waiting' : 'idle';
    }
    static resolveTaskEnabledProgress(task, enabled) {
        if (task.state?.status === 'running' || task.state?.status === 'blocked') {
            return task.state.progress;
        }
        return enabled ?
            'Heartbeat task enabled. Waiting for the next scheduled run.'
            : 'Heartbeat task paused by operator.';
    }
    static resolveHeartbeatRoot(options) {
        if ('dir' in options) {
            return options.dir;
        }
        if ('stateRoot' in options) {
            return resolve(options.stateRoot, 'heartbeat');
        }
        return resolve(options.workspaceRoot, options.stateDir ?? '.heddle', 'heartbeat');
    }
    static createTaskId(value, existingIds) {
        const base = value
            .toLowerCase()
            .replace(/[`'"]/g, '')
            .replace(/[^a-z0-9._-]+/g, '-')
            .replace(/^-+|-+$/g, '')
            .slice(0, 64)
            .replace(/-+$/g, '') || 'heartbeat-task';
        if (!existingIds.includes(base)) {
            return base;
        }
        for (let index = 2; index < 1_000; index++) {
            const candidate = `${base}-${index}`;
            if (!existingIds.includes(candidate)) {
                return candidate;
            }
        }
        throw new Error(`Unable to create a unique heartbeat task id for ${base}`);
    }
}
//# sourceMappingURL=service.js.map