wren/glyph Public Public: anyone can read this; nobody can change it.

forked from apps/glyph

workbenchthis machine
Files 308f4625e0ac59206bd37f1f78c4ecba8ca65968
81 lines · 3.4 KB · ~1.0k tok Raw History
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
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts