/** * The work after the response: a drawing row is made at once (status * `drawing`) and this fills it in when Claude answers — `done` with its SVGs, * or `failed` with a sentence a person can act on. Each change is emitted, * so the page updates live. * * The drawer is set once at boot (`src/server.ts`) from the environment; a * test sets its own. With no token there is still a drawer: one that says * what to set, so the page never just spins. */ import type { Database } from "bun:sqlite"; import { claudeRunner, drawArgs, resultText, type RunClaude } from "./claude.ts"; import { ask, COUNT, parseReply, type Kind } from "./glyph.ts"; import { emit } from "./live.ts"; /** A prompt and a kind in, the kept SVGs out — or a thrown Error whose message a person reads. */ export type Drawer = (prompt: string, kind: Kind) => Promise; export const NO_TOKEN = "Drawing needs a Claude token: set CLAUDE_CODE_OAUTH_TOKEN in Settings → App → Environment (from `claude setup-token`), then try again."; let drawer: Drawer = async () => { throw new Error(NO_TOKEN); }; export function setDrawer(next: Drawer): void { drawer = next; } /** A drawer over Claude Code. */ export function claudeDrawer(run: RunClaude): Drawer { return async (prompt, kind) => { const answer = resultText(await run(drawArgs(), ask(prompt, kind))); if (!answer.ok) throw new Error(answer.why); const svgs = parseReply(answer.text); if (svgs.length === 0) throw new Error("Claude answered, but no drawing in it passed the checks. Try again."); return svgs.slice(0, COUNT); }; } /** The drawer this process uses, from what the forge told it. */ export function drawerFromEnv( dataDir: string, from: Readonly> = process.env, ): Drawer | undefined { const token = from.CLAUDE_CODE_OAUTH_TOKEN ?? ""; return token === "" ? undefined : claudeDrawer(claudeRunner(token, dataDir)); } /** The work in flight, so a test (or a shutdown) can wait for it. */ const running = new Set>(); export const settled = (): Promise => Promise.all([...running]); /** Fill in drawing `id` in the background. */ export function kick(db: Database, id: number, prompt: string, kind: Kind): void { const job = (async () => { let row: { id: number; status: string } | null; try { const svgs = await drawer(prompt, kind); row = db .query("UPDATE drawing SET status = 'done', svgs = ?1, error = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?2 AND status = 'drawing' RETURNING *") .get(JSON.stringify(svgs), id); } catch (error) { const message = error instanceof Error ? error.message : String(error); row = db .query("UPDATE drawing SET status = 'failed', error = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?2 AND status = 'drawing' RETURNING *") .get(message.slice(0, 500), id); } if (row !== null) emit("drawing.changed", { id: row.id, status: row.status }); })().catch((error) => console.error("drawing", id, error)); running.add(job); void job.finally(() => running.delete(job)); } /** Drawings a stopped process left half done are failed, so nobody waits on them forever. */ export function recover(db: Database): number { return db .query("UPDATE drawing SET status = 'failed', error = 'Interrupted: the app restarted while drawing. Try again.' WHERE status = 'drawing'") .run().changes; }