pike/glyph Public Public: anyone can read this; nobody can change it.
forked from apps/glyph
workbenchthis machine
Files 65c469b86b750b044d3235f2387522d1b8b0ce0b
| 1 | /** |
| 2 | * The work after the response: a drawing row is made at once (status |
| 3 | * `drawing`) and this fills it in when Claude answers — `done` with its SVGs, |
| 4 | * or `failed` with a sentence a person can act on. Each change is emitted, |
| 5 | * so the page updates live. |
| 6 | * |
| 7 | * The drawer is set once at boot (`src/server.ts`) from the environment; a |
| 8 | * test sets its own. With no token there is still a drawer: one that says |
| 9 | * what to set, so the page never just spins. |
| 10 | */ |
| 11 | |
| 12 | import type { Database } from "bun:sqlite"; |
| 13 | import { claudeRunner, drawArgs, resultText, type RunClaude } from "./claude.ts"; |
| 14 | import { ask, COUNT, parseReply, type Kind } from "./glyph.ts"; |
| 15 | import { emit } from "./live.ts"; |
| 16 | |
| 17 | /** A prompt and a kind in, the kept SVGs out — or a thrown Error whose message a person reads. */ |
| 18 | export type Drawer = (prompt: string, kind: Kind) => Promise<string[]>; |
| 19 | |
| 20 | export const NO_TOKEN = |
| 21 | "Drawing needs a Claude token: set CLAUDE_CODE_OAUTH_TOKEN in Settings → App → Environment (from `claude setup-token`), then try again."; |
| 22 | |
| 23 | let drawer: Drawer = async () => { |
| 24 | throw new Error(NO_TOKEN); |
| 25 | }; |
| 26 | |
| 27 | export function setDrawer(next: Drawer): void { |
| 28 | drawer = next; |
| 29 | } |
| 30 | |
| 31 | /** A drawer over Claude Code. */ |
| 32 | export function claudeDrawer(run: RunClaude): Drawer { |
| 33 | return async (prompt, kind) => { |
| 34 | const answer = resultText(await run(drawArgs(), ask(prompt, kind))); |
| 35 | if (!answer.ok) throw new Error(answer.why); |
| 36 | const svgs = parseReply(answer.text); |
| 37 | if (svgs.length === 0) throw new Error("Claude answered, but no drawing in it passed the checks. Try again."); |
| 38 | return svgs.slice(0, COUNT); |
| 39 | }; |
| 40 | } |
| 41 | |
| 42 | /** The drawer this process uses, from what the forge told it. */ |
| 43 | export function drawerFromEnv( |
| 44 | dataDir: string, |
| 45 | from: Readonly<Record<string, string | undefined>> = process.env, |
| 46 | ): Drawer | undefined { |
| 47 | const token = from.CLAUDE_CODE_OAUTH_TOKEN ?? ""; |
| 48 | return token === "" ? undefined : claudeDrawer(claudeRunner(token, dataDir)); |
| 49 | } |
| 50 | |
| 51 | /** The work in flight, so a test (or a shutdown) can wait for it. */ |
| 52 | const running = new Set<Promise<void>>(); |
| 53 | export const settled = (): Promise<unknown> => Promise.all([...running]); |
| 54 | |
| 55 | /** Fill in drawing `id` in the background. */ |
| 56 | export function kick(db: Database, id: number, prompt: string, kind: Kind): void { |
| 57 | const job = (async () => { |
| 58 | let row: { id: number; status: string } | null; |
| 59 | try { |
| 60 | const svgs = await drawer(prompt, kind); |
| 61 | row = db |
| 62 | .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 *") |
| 63 | .get(JSON.stringify(svgs), id); |
| 64 | } catch (error) { |
| 65 | const message = error instanceof Error ? error.message : String(error); |
| 66 | row = db |
| 67 | .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 *") |
| 68 | .get(message.slice(0, 500), id); |
| 69 | } |
| 70 | if (row !== null) emit("drawing.changed", { id: row.id, status: row.status }); |
| 71 | })().catch((error) => console.error("drawing", id, error)); |
| 72 | running.add(job); |
| 73 | void job.finally(() => running.delete(job)); |
| 74 | } |
| 75 | |
| 76 | /** Drawings a stopped process left half done are failed, so nobody waits on them forever. */ |
| 77 | export function recover(db: Database): number { |
| 78 | return db |
| 79 | .query("UPDATE drawing SET status = 'failed', error = 'Interrupted: the app restarted while drawing. Try again.' WHERE status = 'drawing'") |
| 80 | .run().changes; |
| 81 | } |