/** * KHAELOR * File: src/daemon/approvals.ts * Description: Approval Queue โ€” asynchronous permission requests for autonomous runs; a suspended run costs zero (v2 design ยง7.5). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { ulid } from "../shared/index.js"; export type ApprovalStatus = "pending" | "approved" | "denied"; export interface ApprovalRequest { id: string; runId: string; goalId: string; capability: string; /** Human context: "Draft PR for goal 'deps up to date', diff +42 โˆ’13, verify โœ“". */ context: string; status: ApprovalStatus; requestedAt: number; resolvedAt: number | null; } /** * Persisted at .khaelor/daemon/approvals.json. The agent never blocks on an * approval: the run checkpoints (its JSONL simply pauses) and resumes when * the answer arrives via `khaelord approve ` or a channel reply. */ export class ApprovalQueue { readonly #path: string; #entries: ApprovalRequest[] = []; #loaded = false; constructor(daemonDir: string) { this.#path = join(daemonDir, "approvals.json"); } async #ensureLoaded(): Promise { if (this.#loaded) return; try { const raw = JSON.parse(await readFile(this.#path, "utf8")); if (Array.isArray(raw)) this.#entries = raw as ApprovalRequest[]; } catch { this.#entries = []; } this.#loaded = true; } async #save(): Promise { await mkdir(dirname(this.#path), { recursive: true }); await writeFile(this.#path, `${JSON.stringify(this.#entries, null, 2)}\n`, "utf8"); } async request(input: { runId: string; goalId: string; capability: string; context: string; }): Promise { await this.#ensureLoaded(); const entry: ApprovalRequest = { id: ulid().slice(0, 10).toLowerCase(), runId: input.runId, goalId: input.goalId, capability: input.capability, context: input.context, status: "pending", requestedAt: Date.now(), resolvedAt: null, }; this.#entries.push(entry); await this.#save(); return entry; } async list(status?: ApprovalStatus): Promise { await this.#ensureLoaded(); return status === undefined ? [...this.#entries] : this.#entries.filter((entry) => entry.status === status); } async resolve(id: string, decision: "approved" | "denied"): Promise { await this.#ensureLoaded(); const entry = this.#entries.find((candidate) => candidate.id === id); if (entry === undefined || entry.status !== "pending") return null; entry.status = decision; entry.resolvedAt = Date.now(); await this.#save(); return entry; } }