Public: anyone can read this; the crew can change it.

Data explorer: a model fallback for sentences the rules cannot read #9627

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/9569-data-explorer-a-model-fallback-for-sente → main 4 commits fork 5 threads · 5 unresolved

  • Merged. mason/9569-data-explorer-a-model-fallback-for-sente was merged into main as ad01e85.

Review

Sign in to review this change.
12 files changed +543 −23
docs/features/data-explorer-sentence.md added +18
@@ -0,0 +1,18 @@
1 # The data explorer's sentence box
2
3 The box on `/boards/data` (and `/` in the terminal's Data screen) takes a sentence such as "memory over the last 3 days" and fills the explorer's form from it.
4
5 - **Rules first.** `parseSentence` reads topics (cpu, memory, spend, deploys, slowest pages) and windows. A hit costs nothing and never reaches the model; the web redirects to the chart's own link.
6 - **The model on a miss.** A sentence the rules cannot read ("how pricey were the robots") goes to the model as one short question with no tools. It runs on the asker's own granted account, at most $0.05 a question. Each question is written to `one_shot_spend` with its cap before it starts and its real cost when it ends, and that table counts toward the account's daily ceiling like every run and conversation turn, so a spent ceiling stops the model too.
7 - **Checked before it is used.** The model may answer only with the explorer's own fields. Every source, column, aggregate, chart, operator and time is checked against `SOURCES` and `readParams`; anything else is refused and the query runs under the same gate as any link.
8 - **Six asks a minute** per person. Past that the box says the model is asked only a few times a minute.
9 - **The note.** When the model filled the form, the chart's foot (and the terminal's result) says "The model filled this form from your sentence. Check it before you trust it." A rules hit shows no note. The web draws a model fill in place rather than redirecting, so the note cannot be put on a link by hand, and a reload asks again.
10 - **Only the page's own form.** The box posts to `/boards/data`, and only that POST may ask the model; like every form it is refused when another site sent it (`readForm`, `crossOrigin`). A `?ask=` link is read by the rules only, so no link, clicked from a mail, a chat or another site, can spend an admin's budget.
11
12 ## What it needs
13
14 The crew on (the forge started with a model configured) and a signed-in site admin who is a person with an account granted to them. Without either, a sentence the rules cannot read says "I could not read that" and nothing is spent.
15
16 ## Try it
17
18 Sign in as a site admin, open `/boards/data`, type "how pricey were the robots" into the box and press Show; read the note under the chart. The same words as `/boards/data?ask=how+pricey+were+the+robots` say "I could not read that" and spend nothing.
src/boards/model_fill.ts added +118
@@ -0,0 +1,118 @@
1 /**
2 * The model as a fallback for the data explorer's sentence box (#9569). The
3 * rules in `parseSentence` answer first and cost nothing; only a sentence
4 * they cannot read comes here. The model may answer only with the explorer's
5 * own fields, and `validFill` checks every one against `SOURCES` before the
6 * answer is used; `runQuery` then judges the query as it judges any link.
7 * Plain data in, plain data out: the model itself is a function handed in.
8 */
9 import type { App } from "../app.ts";
10 import { oneShot, type OneShotRefusal } from "../crew/one_shot.ts";
11 import { RateLimiter } from "../core/ratelimit.ts";
12 import type { Result } from "../kernel/result.ts";
13 import { CHARTS } from "./chart.ts";
14 import { GUIDE, parseSentence } from "./guide.ts";
15 import { isBucket, readParams } from "./params.ts";
16 import { SOURCES } from "./sources.ts";
17
18 const AGGREGATES = ["count", "sum", "avg", "max"];
19 const OPS = ["eq", "ne", "lt", "lte", "gt", "gte", "starts"];
20 const FIELDS = ["source", "aggregate", "column", "by", "series", "from", "to", "chart", "fcolumn", "fop", "fvalue"] as const;
21
22 /** Asks the model for text; replaced in tests. */
23 export type Ask = (app: App, userId: string, system: string, prompt: string) => Promise<Result<string, OneShotRefusal>>;
24 let ask: Ask = oneShot;
25 /** Swap the model, or put the real one back with no argument. */
26 export const useModel = (fn?: Ask): void => void (ask = fn ?? oneShot);
27
28 /** The brief: the sources and their columns, the fields allowed, and nothing else to say. */
29 export function brief(now: number): string {
30 const sources = Object.entries(SOURCES).map(([name, s]) => `- ${name} (${GUIDE[name]?.name ?? name}): ${Object.entries(s.columns).map(([c, t]) => `${c}:${t}`).join(", ")}`);
31 return [
32 "You fill a form for a data explorer from one sentence. Answer with one JSON object and nothing else.",
33 `Fields (all strings): ${FIELDS.join(", ")}.`,
34 `aggregate is one of ${AGGREGATES.join(", ")}; count needs no column. by is minute, hour, day or a column of the source. chart is one of ${CHARTS.join(", ")}.`,
35 `fop is one of ${OPS.join(", ")}. from and to are ISO 8601 times. Now is ${new Date(now).toISOString()}.`,
36 "Use only these sources and their columns. If the sentence is not about them, answer {}.",
37 ...sources,
38 ].join("\n");
39 }
40
41 const isTime = (s: string) => /^\d{4}-\d\d-\d\d(T[\d:.]+Z?)?$/.test(s) && !Number.isNaN(Date.parse(s));
42
43 /** The model's answer as a fill, or null: every field known, every name a real source or column. */
44 export function validFill(raw: unknown): Record<string, string> | null {
45 if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
46 const out: Record<string, string> = {};
47 for (const [k, v] of Object.entries(raw)) {
48 if (!(FIELDS as readonly string[]).includes(k) || typeof v !== "string" || v.length > 120) return null;
49 out[k] = v;
50 }
51 const source = out.source ?? "";
52 if (!Object.hasOwn(SOURCES, source)) return null;
53 const cols = SOURCES[source]!.columns;
54 const col = (name: string | undefined) => name === undefined || name === "" || Object.hasOwn(cols, name);
55 if (out.aggregate === undefined || !AGGREGATES.includes(out.aggregate)) return null;
56 if (!col(out.column) || !col(out.series) || !col(out.fcolumn)) return null;
57 if (out.aggregate !== "count" && (out.column ?? "") === "") return null;
58 if (out.column && out.aggregate !== "count" && cols[out.column] !== "number") return null;
59 if (out.by !== undefined && !isBucket(out.by) && !col(out.by)) return null;
60 if (out.fop !== undefined && !OPS.includes(out.fop)) return null;
61 if (out.chart !== undefined && !(CHARTS as readonly string[]).includes(out.chart)) return null;
62 for (const t of [out.from, out.to]) if (t !== undefined && t !== "" && !isTime(t)) return null;
63 if (out.fcolumn && cols[out.fcolumn] === "number" && Number.isNaN(Number(out.fvalue))) return null;
64 // The same reading every link gets; a field it would bound or drop is not what was asked.
65 const read = readParams(new URLSearchParams(out)) as Record<string, string>;
66 for (const [k, v] of Object.entries(out)) if (read[k] !== v) return null;
67 return out;
68 }
69
70 /** The JSON object in a model's reply, which may wrap it in a fence or a word. */
71 export function jsonIn(text: string): unknown {
72 const start = text.indexOf("{");
73 const end = text.lastIndexOf("}");
74 if (start < 0 || end < start) return null;
75 try {
76 return JSON.parse(text.slice(start, end + 1));
77 } catch {
78 return null;
79 }
80 }
81
82 export type Filled =
83 | { ok: true; fill: Record<string, string>; by: "rules" | "model" }
84 | { ok: false; why: "unread" | "off" | "limit" | "refused" };
85
86 /** Questions to the model one person may ask in a minute. */
87 export const MODEL_FILL_PER_MINUTE = 6;
88 const limiters = new WeakMap<App, RateLimiter>();
89 const allowed = (app: App, userId: string): boolean => {
90 let l = limiters.get(app);
91 if (l === undefined) limiters.set(app, (l = new RateLimiter({ perMinute: MODEL_FILL_PER_MINUTE, adminPerMinute: MODEL_FILL_PER_MINUTE, enabled: true })));
92 return l.take(`explore-model:${userId}`, "rw").allowed;
93 };
94
95 /**
96 * A sentence as the form it fills. The rules answer first, free; only a miss
97 * asks the model, on the asker's own account and within its budget.
98 */
99 export async function fillFromSentence(app: App, userId: string, text: string, now: number, mayAsk = true): Promise<Filled> {
100 const rules = parseSentence(text, now);
101 if (rules) return { ok: true, fill: rules, by: "rules" };
102 if (text.trim() === "" || !mayAsk) return { ok: false, why: "unread" };
103 if (!allowed(app, userId)) return { ok: false, why: "limit" };
104 const said = await ask(app, userId, brief(now), `Sentence: ${JSON.stringify(text.slice(0, 200))}`);
105 if (!said.ok) return { ok: false, why: said.error === "off" || said.error === "no_account" ? "off" : "unread" };
106 const fill = validFill(jsonIn(said.value));
107 if (fill === null || Object.keys(fill).length === 0) return { ok: false, why: "refused" };
108 return { ok: true, fill, by: "model" };
109 }
110
111 /** What the explorer says when the model filled the form. */
112 export const MODEL_FILLED = "The model filled this form from your sentence. Check it before you trust it.";
113
114 const HINT = "Try a topic (cpu, memory, spend, deploys, slowest pages) and a window (last 3 days).";
115
116 /** What the explorer says when a sentence filled nothing; the web and the terminal both say it. */
117 export const unreadWords = (why: "unread" | "off" | "limit" | "refused"): string =>
118 why === "limit" ? "The model is asked only a few times a minute; try again shortly." : why === "refused" ? `The model's answer was not a query this explorer can run. ${HINT}` : `I could not read that. ${HINT}`;
src/core/account_ceiling.ts +4 −2
@@ -62,7 +62,7 @@ export function setAccountCeiling(
6262 return true;
6363 }
6464
65 /** What the account's repositories and conversation have spent today (UTC), running runs included. */
65 /** What the account's repositories, conversation and one-shot questions have spent today (UTC), running runs included. */
6666 export function daySpent(db: Database, userId: string, now: Date): number {
6767 return (
6868 db
@@ -70,7 +70,9 @@ export function daySpent(db: Database, userId: string, now: Date): number {
7070 `SELECT COALESCE((SELECT SUM(u.cost_usd) FROM usage u JOIN repo r ON r.id = u.repo_id
7171 WHERE r.owner_id = ?1 AND u.at >= ?2), 0)
7272 + COALESCE((SELECT SUM(c.cost_usd) FROM conversation_run c
73 WHERE c.user_id = ?1 AND c.started_at >= ?2), 0) AS n`,
73 WHERE c.user_id = ?1 AND c.started_at >= ?2), 0)
74 + COALESCE((SELECT SUM(o.cost_usd) FROM one_shot_spend o
75 WHERE o.user_id = ?1 AND o.at >= ?2), 0) AS n`,
7476 )
7577 .get(userId, dayStart(now))?.n ?? 0
7678 );
src/core/migration-pins/20261108170000-one-shot-spend.pin added +1
@@ -0,0 +1,1 @@
1 6b9a7968054f83a4a2d5485e233452903b65bd7595d54314843b25349903933b
src/core/migrations/20261108170000-one-shot-spend.ts added +14
@@ -0,0 +1,14 @@
1 // What each one-shot question to the model cost (#9569): the data explorer's
2 // fallback runs on the asker's account, so its spend counts toward their daily
3 // ceiling (`daySpent`). A row is written with the run's cap before it starts,
4 // so asks under way are counted, and set to the real cost when it ends. Additive.
5 export default `
6 CREATE TABLE one_shot_spend (
7 id TEXT PRIMARY KEY,
8 user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
9 subscription_id TEXT NOT NULL,
10 cost_usd REAL NOT NULL DEFAULT 0,
11 at TEXT NOT NULL
12 );
13 CREATE INDEX one_shot_spend_user_at ON one_shot_spend (user_id, at);
14 `;
src/crew/one_shot.ts added +71
@@ -0,0 +1,71 @@
1 /**

No test covers oneShot itself: budget clamp, off/no_account/resting refusals and cost booking are all untested.

2 * One short question to the model, answered in one turn with no tools: no
3 * shell, no files, no MCP server. It runs on the asker's own account as a
4 * conversation turn does (`pickSubscription`, their daily ceiling). Its cap is
5 * booked in `one_shot_spend` before it starts, so `daySpent` counts it while it
6 * runs, then set to what it cost, which is also added to the subscription's
7 * spend. Never throws; every ending is a `Result`.
8 */
9
10 import type { App } from "../app.ts";
11 import { dailyLeft } from "../core/account_ceiling.ts";
12 import { PLATFORM, pickSubscription } from "../core/subscriptions.ts";
13 import { id } from "../kernel/id.ts";
14 import { err, ok, type Result } from "../kernel/result.ts";
15 import { runClaude } from "./claude.ts";
16 import { conversationOptions, sessionsDir, turnDir } from "./conversation.ts";
17 import { isRateLimited } from "./limits.ts";
18 import { credentialEnv } from "./runner.ts";
19 import { sandboxPolicy } from "./sandbox.ts";
20
21 /** The most one such question may spend, in dollars. */
22 export const ONE_SHOT_USD = 0.05;
23 /** The longest one may take. */
24 export const ONE_SHOT_MS = 60_000;
25
26 export type OneShotRefusal = "off" | "no_account" | "resting" | "failed";
27
28 export async function oneShot(app: App, userId: string, system: string, prompt: string): Promise<Result<string, OneShotRefusal>> {
29 const options = conversationOptions(app);
30 if (options === undefined) return err("off");
31 const now = new Date();
32 const runId = id();
33 const picked = pickSubscription(app.db, app.config.dataDir, { repoId: "", ownerId: userId, agentId: "" }, now, { runId, usd: ONE_SHOT_USD });
34 if (picked.kind === "none") return err("no_account");
35 if (picked.kind === "resting") return err("resting");
36 const left = dailyLeft(app.db, userId, now);
37 const budget = Math.min(ONE_SHOT_USD, options.budgetUsd, picked.kind === "ready" ? (picked.capUsd ?? Infinity) : Infinity, left ?? Infinity);
38 if (budget <= 0) return err("resting");
39 const subscriptionId = picked.kind === "ready" ? picked.subscription.id : PLATFORM;
40 app.db
41 .query("INSERT INTO one_shot_spend (id, user_id, subscription_id, cost_usd, at) VALUES (?1, ?2, ?3, ?4, ?5)")
42 .run(runId, userId, subscriptionId, budget, now.toISOString());
43 let costUsd = budget;
44 try {
45 const outcome = await runClaude({
46 binary: options.binary,
47 cwd: turnDir(app.config.dataDir),
48 prompt,
49 appendSystemPrompt: system,
50 model: options.model,
51 budgetUsd: budget,
52 env: { ...credentialEnv(picked), CLAUDE_CONFIG_DIR: sessionsDir(app.config.dataDir, options.configDir) },
53 sandbox: sandboxPolicy({ dataDir: app.config.dataDir, agentName: "one-shot", urls: [], enforced: false, runId }),
outdated

High: this books the cost to the subscription only. daySpent (account_ceiling.ts) reads usage and conversation_run, so the daily ceiling never sees one-shot spend, and dailyLeft at line 33 stays stale. Record the cost where daySpent counts it, and test that a one-shot lowers dailyLeft.

54 label: "one-shot",
55 timeoutMs: ONE_SHOT_MS,
56 log: () => {},
57 signal: options.signal,
58 mcp: { config: JSON.stringify({ mcpServers: {} }), allow: [] },
59 });
60 costUsd = outcome.costUsd ?? 0;
61 app.db.query("UPDATE subscription SET spent_usd = spent_usd + ?2 WHERE id = ?1").run(subscriptionId, costUsd);
62 if (isRateLimited(outcome)) return err("resting");
63 return outcome.ok && outcome.result.trim() !== "" ? ok(outcome.result) : err("failed");
64 } catch (error) {
65 app.log.warn("one-shot failed", { error: String(error) });
66 return err("failed");
67 } finally {
68 // A run that threw keeps its cap as its cost: what it spent is not known, and the ceiling errs high.
69 app.db.query("UPDATE one_shot_spend SET cost_usd = ?2 WHERE id = ?1").run(runId, costUsd);
70 }
71 }
src/http/server.ts +2 −1
@@ -31,7 +31,7 @@ import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke }
3131 import { measureSubmit } from "./measure.ts";
3232 import { settingsRoutes } from "./settings_routes.ts";
3333 import { pinnedRoutes } from "./pinned_routes.ts";
34 import { boardsPage } from "../web/pages/boards.ts";
34 import { boardsAsk, boardsPage } from "../web/pages/boards.ts";
3535 import { boardNew, boardPin, boardPost, pinPost } from "../web/pages/boards-edit.ts";
3636 import { boardsHoldPost } from "../web/pages/boards-holds.ts";
3737 import { nowPage } from "../web/pages/now.ts";
@@ -952,6 +952,7 @@ export function buildRouter(): Router<Handler> {
952952 router.add("POST", "/signup/passkey", signupPasskey);
953953 router.add("GET", "/boards", boardsList);
954954 router.add("GET", "/boards/data", boardsPage);
955 router.add("POST", "/boards/data", boardsAsk);
955956 router.add("POST", "/boards/new", boardNew);
956957 router.add("POST", "/boards/holds", boardsHoldPost);
957958 router.add("POST", "/boards/pin", boardPin);
src/tui/screens/ask_data.ts +22 −11
@@ -3,12 +3,15 @@
33 * text. A list of the ready questions, then the sources by their plain names;
44 * `enter` opens one as its one-line description and the rows the query
55 * returns, and `/` takes a sentence ("memory over the last 3 days") that
6 * `parseSentence` reads. A sentence it cannot read says so and nothing more.
6 * `parseSentence` reads. A sentence it cannot read goes to the model
outdated

Wrap this comment line; it runs far past the file width.

7 * (`boards/model_fill.ts`), which may only fill the explorer's own fields;
8 * with no model it says so and nothing more.
79 * The gate is the page's: a site admin who is a person, never an agent; for
810 * anyone else the door is not offered, the list is empty and no query runs.
911 */
1012
1113 import { ask, GUIDE, parseSentence, QUESTIONS, sentence } from "../../boards/guide.ts";
14 import { fillFromSentence, MODEL_FILLED, unreadWords } from "../../boards/model_fill.ts";
1215 import { runFor } from "../../boards/panel_data.ts";
1316 import { runQuery } from "../../boards/query.ts";
1417 import { SOURCES } from "../../boards/sources.ts";
@@ -41,10 +44,10 @@ export function exploreRows(session: Session, now: number): ExploreRow[] {
4144 }
4245
4346 /** What a query shows: its sentence, then one line per row; empty for anyone the gate refuses. */
44 export function resultLines(session: Session, p: Params): string[] {
47 export function resultLines(session: Session, p: Params, note = ""): string[] {
4548 if (!mayExplore(session)) return [];
4649 const answer = p.rows === "" ? runFor(session.app.db, p.chart, toQuery(p)) : runQuery(session.app.db, toQuery(p), { repoIds: "all" });
47 const lines = [plain(sentence(p)), ""];
50 const lines = [plain(sentence(p)), ...(note === "" ? [] : [note]), ""];
4851 if (!answer.ok) return [...lines, `refused: ${plain(answer.error)}`];
4952 if (answer.rows.length === 0) return [...lines, "No rows: nothing matches this query."];
5053 for (const r of answer.rows) {
@@ -53,11 +56,13 @@ export function resultLines(session: Session, p: Params): string[] {
5356 return lines;
5457 }
5558
56 export function resultScreen(ctx: Ctx, session: Session, p: Params): Screen {
59 export const resultScreen = (ctx: Ctx, session: Session, p: Params, note = ""): Screen => linesScreen(ctx, GUIDE[p.source]?.name ?? "Data", resultLines(session, p, note));
60
61 /** Lines of text, scrolled, with `esc` to go back. */
62 function linesScreen(ctx: Ctx, title: string, lines: string[]): Screen {
5763 const scroll = new Scroll();
58 const lines = resultLines(session, p);
5964 return {
60 title: GUIDE[p.source]?.name ?? "Data",
65 title,
6166 keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")],
6267 render(area, s) {
6368 const c = ctx.theme.c;
@@ -75,7 +80,7 @@ export function resultScreen(ctx: Ctx, session: Session, p: Params): Screen {
7580 export function askDataScreen(session: Session, ctx: Ctx): Screen {
7681 const state = new ListState();
7782 let rows: ExploreRow[] = [];
78 const open = (p: Params): void => ctx.push(resultScreen(ctx, session, p));
83 const open = (p: Params, note = ""): void => ctx.push(resultScreen(ctx, session, p, note));
7984 const screen: Screen = {
8085 title: "Data",
8186 keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["/"], "ask in a sentence")],
@@ -104,10 +109,16 @@ export function askDataScreen(session: Session, ctx: Ctx): Screen {
104109 label: "Sentence",
105110 placeholder: "memory over the last 3 days",
106111 onSubmit: (text) => {
107 const fill = parseSentence(text, Date.now());
108 if (!fill) return "I could not read that. Try a topic (cpu, memory, spend, deploys, slowest pages) and a window (last 3 days).";
109 // The prompt closes itself after this returns; the result opens once it has.
110 return Promise.resolve().then(() => open(paramsOf(fill)));
112 if (text.trim() === "") return unreadWords("unread");
113 const rules = parseSentence(text, Date.now());
114 // The rules answer first, free, with the prompt closing after this returns.
115 if (rules) return Promise.resolve().then(() => open(paramsOf(rules)));
116 // A miss asks the model, on the asker's own budget; the result says it did.
117 return fillFromSentence(session.app, session.user!.id, text, Date.now()).then((r) => {
118 if (r.ok) return open(paramsOf(r.fill), r.by === "model" ? MODEL_FILLED : "");
119 // The prompt has closed by now; the reason shows as a toast, not a screen.
120 ctx.toast(unreadWords(r.why), "danger");
121 });
111122 },
112123 }),
113124 );
src/web/pages/boards.ts +28 −9
@@ -13,7 +13,8 @@ import { runQuery, type Row } from "../../boards/query.ts";
1313 import { gaps, SYSTEM } from "../../boards/sampler.ts";
1414 import { SOURCES } from "../../boards/sources.ts";
1515 import { BUCKET_MS, isBucket, MAX_PARAM, readParams, toQuery, type Bucket, type Params } from "../../boards/params.ts";
16 import { aggregateOption, ask, columnNote, columnOption, unitOf, GUIDE, parseSentence, QUESTIONS, sentence } from "../../boards/guide.ts";
16 import { fillFromSentence, MODEL_FILLED, unreadWords } from "../../boards/model_fill.ts";
17 import { aggregateOption, ask, columnNote, columnOption, unitOf, GUIDE, QUESTIONS, sentence } from "../../boards/guide.ts";
1718 import { listAllBoards } from "../../actions/boards.ts";
1819 import type { Ctx } from "../../http/context.ts";
1920 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
@@ -21,6 +22,7 @@ import { notFoundPage } from "../chrome.ts";
2122 import { html, when } from "../html.ts";
2223 import { page } from "../layout.ts";
2324 import { btn, card, chartBox, empty, field, selectField } from "../ui.ts";
25 import { readForm } from "../form.ts";
2426
2527 const AGGREGATES = ["count", "sum", "avg", "max"] as const;
2628 const OPS = ["eq", "ne", "lt", "lte", "gt", "gte", "starts"] as const;
@@ -139,15 +141,32 @@ export function paramsOf(q: Record<string, unknown>, chart: Chart = "line"): Par
139141 };
140142 }
141143
142 // GET /boards/data
144 // GET /boards/data: a `?ask=` sentence is read by the rules only, so no link can spend the model.
143145 export async function boardsPage(ctx: Ctx): Promise<Response> {
144146 const user = ctx.auth.user;
145147 if (user?.isAdmin !== true || user.kind === "agent") return notFoundPage(ctx);
148 return explorer(ctx, user, ctx.url.searchParams.get("ask"), false);
149 }
150
outdated

A GET spends the asker model budget. Allow only same-origin/none for Sec-Fetch-Site (a missing header should not spend), or make it a POST with the same-origin check.

151 // POST /boards/data: the page's own sentence box. Only here may a miss ask the model, which spends the
outdated

Medium: none is also what a link clicked in a mail client or chat app sends, so one click on a hand-made ?ask= link still spends the admin's budget on a GET. Take only same-origin, or move the model call to a POST with the same-origin check.

152 // asker's budget; `readForm` refuses a form another site sent (`crossOrigin`), as every mutation is.
153 export async function boardsAsk(ctx: Ctx): Promise<Response> {
154 const user = ctx.auth.user;
155 if (user?.isAdmin !== true || user.kind === "agent") return notFoundPage(ctx);
156 const form = await readForm(ctx, "admin");
157 if (form instanceof Response) return form;
158 const asked = form.get("ask");
159 return explorer(ctx, user, typeof asked === "string" ? asked.slice(0, 200) : "", true);
160 }
161
162 async function explorer(ctx: Ctx, user: NonNullable<Ctx["auth"]["user"]>, asked: string | null, mayAsk: boolean): Promise<Response> {
146163 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
147 const asked = ctx.url.searchParams.get("ask");
148 const fill = asked ? parseSentence(asked, Date.now()) : null;
149 if (fill) return new Response(null, { status: 303, headers: { location: link({ ...readParams(new URLSearchParams(fill)), ...fill } as Params) } });
150 const p = readParams(ctx.url.searchParams);
164 // The rules answer first, free, and a hit redirects to its link.
165 const filled = asked !== null && asked.trim() !== "" ? await fillFromSentence(ctx.app, user.id, asked, Date.now(), mayAsk) : null;
166 if (filled?.ok && filled.by === "rules") return new Response(null, { status: 303, headers: { location: link({ ...readParams(new URLSearchParams(filled.fill)), ...filled.fill } as Params) } });
167 // A model's fill is drawn here, not redirected to: only this response knows the model made it.
168 const modelFilled = filled?.ok === true;
169 const p = readParams(filled?.ok ? new URLSearchParams(filled.fill) : ctx.url.searchParams);
151170 const src = Object.hasOwn(SOURCES, p.source) ? SOURCES[p.source]! : undefined;
152171 const answer = timePhase(ctx.request, "query", () =>
153172 p.rows === "" ? runFor(ctx.app.db, p.chart, toQuery(p)) : runQuery(ctx.app.db, toQuery(p), { repoIds: "all" }),
@@ -176,8 +195,8 @@ export async function boardsPage(ctx: Ctx): Promise<Response> {
176195 // A blank box (Show pressed with nothing typed) is no sentence, not an unreadable one.
177196 const unread = asked !== null && asked.trim() !== "";
178197 const questions = card(
179 html`<form method="get" action="/boards/data" class="bar">
180 ${field({ label: "Ask in a sentence", name: "ask", id: "f-ask", placeholder: "memory over the last 3 days", maxlength: 200, ...(unread ? { error: "I could not read that. Try a topic (cpu, memory, spend, deploys, slowest pages) and a window (last 3 days)." } : {}) })}
198 html`<form method="post" action="/boards/data" class="bar">
199 ${field({ label: "Ask in a sentence", name: "ask", id: "f-ask", placeholder: "memory over the last 3 days", maxlength: 200, ...(unread ? { error: unreadWords(filled?.ok === false ? filled.why : "unread") } : {}) })}
181200 ${btn("Show", { variant: "ghost" })}
182201 </form>
183202 <ul class="list">
@@ -262,7 +281,7 @@ export async function boardsPage(ctx: Ctx): Promise<Response> {
262281 ctx,
263282 title: "Boards",
264283 body: html`<div class="page-head"><h1>Data</h1>${btn("All boards", { href: "/boards", variant: "ghost" })}</div>
265 ${questions} ${card(result, { head: html`<h2>${GUIDE[p.source]?.name ?? p.source}</h2>`, foot: html`${sentence(p)}`, id: "says" })} ${form} ${absent} ${pin} ${sources}`,
284 ${questions} ${card(result, { head: html`<h2>${GUIDE[p.source]?.name ?? p.source}</h2>`, foot: html`${sentence(p)}${modelFilled ? html` <span class="muted small">${MODEL_FILLED}</span>` : null}`, id: "says" })} ${form} ${absent} ${pin} ${sources}`,
266285 });
267286 notePhase(ctx.request, "render", Math.max(0, performance.now() - rendering - boardsMs));
268287 return response;
test/boards-model-fill.test.ts added +172
@@ -0,0 +1,172 @@
1 /** The data explorer's model fallback (#9569): rules first, the model only on a miss, its answer checked. */
2 import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp } from "../src/app.ts";
7 import { fillFromSentence, jsonIn, MODEL_FILLED, useModel, validFill, type Ask } from "../src/boards/model_fill.ts";
8 import { runQuery } from "../src/boards/query.ts";
9 import { readParams, toQuery } from "../src/boards/params.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { err, ok } from "../src/kernel/result.ts";
13 import { serve } from "../src/http/server.ts";
14
15 const NOW = Date.parse("2026-11-08T12:00:30Z");
16 const GOOD = { source: "spend", aggregate: "sum", column: "cost_usd", by: "day", chart: "bar", from: "2026-11-01T00:00:00.000Z" };
17
18 let calls: string[] = [];
19 const stub = (reply: string): Ask => async (_app, _user, _system, prompt) => (calls.push(prompt), ok(reply));
20 afterEach(() => {
21 useModel();
22 calls = [];
23 });
24
25 describe("validFill", () => {
26 test("a fill of real fields passes, and runQuery runs it as it runs any link", () => {
27 const fill = validFill(GOOD)!;
28 expect(fill).toEqual(GOOD);
29 expect(readParams(new URLSearchParams(fill)).column).toBe("cost_usd");
30 });
31 test("an unknown source, column, aggregate, field or time is refused", () => {
32 for (const bad of [
33 { ...GOOD, source: "secrets" },
34 { ...GOOD, source: "__proto__" },
35 { ...GOOD, column: "password_hash" },
36 { ...GOOD, series: "nope" },
37 { ...GOOD, by: "nope" },
38 { ...GOOD, aggregate: "drop" },
39 { ...GOOD, fcolumn: "nope", fop: "eq", fvalue: "x" },
40 { ...GOOD, fop: "like", fcolumn: "state", fvalue: "x" },
41 { ...GOOD, sql: "select 1" },
42 { ...GOOD, from: "yesterday-ish" },
43 { ...GOOD, column: "" },
44 { ...GOOD, rows: "1" },
45 { source: "spend" },
46 [GOOD],
47 "text",
48 null,
49 ])
50 expect(validFill(bad), JSON.stringify(bad)).toBeNull();
51 });
52 test("jsonIn finds the object in a fenced reply", () => {
53 expect(jsonIn('```json\n{"a":"b"}\n```')).toEqual({ a: "b" });
54 expect(jsonIn("no object")).toBeNull();
55 expect(jsonIn("{broken")).toBeNull();
56 });
57 });
58
59 describe("fillFromSentence", () => {
60 let dir = "", app: ReturnType<typeof createApp>;
61 beforeAll(async () => {
62 dir = await mkdtemp(join(tmpdir(), "forge-model-fill-"));
63 app = createApp({ dataDir: dir, port: 0 });
64 });
65 afterAll(async () => {
66 app.close();
67 await rm(dir, { recursive: true, force: true });
68 });
69
70 test("a hit by the rules never calls the model", async () => {
71 useModel(stub(JSON.stringify(GOOD)));
72 const r = await fillFromSentence(app, "u1", "memory over the last 3 days", NOW);
73 expect(r).toMatchObject({ ok: true, by: "rules" });
74 expect(calls).toEqual([]);
75 });
76 test("a miss reaches the model, and its checked answer is the fill", async () => {
77 useModel(stub(`Here: ${JSON.stringify(GOOD)}`));
78 const r = await fillFromSentence(app, "u2", "how pricey were the robots lately", NOW);
79 expect(r).toMatchObject({ ok: true, by: "model", fill: GOOD });
80 expect(calls.length).toBe(1);
81 if (r.ok) expect(runQuery(app.db, toQuery(readParams(new URLSearchParams(r.fill))), { repoIds: "all" }).ok).toBe(true);
82 });
83 test("an answer naming an unknown source or column is refused", async () => {
84 for (const bad of [{ ...GOOD, source: "users" }, { ...GOOD, column: "secret" }]) {
85 useModel(stub(JSON.stringify(bad)));
86 expect(await fillFromSentence(app, "u3", "something odd", NOW)).toEqual({ ok: false, why: "refused" });
87 }
88 });
89 test("no model, no account or a blank box is unread, and a blank box costs nothing", async () => {
90 useModel(async () => err("off"));
91 expect(await fillFromSentence(app, "u4", "zzz", NOW)).toEqual({ ok: false, why: "off" });
92 useModel(stub("{}"));
93 expect(await fillFromSentence(app, "u4", " ", NOW)).toEqual({ ok: false, why: "unread" });
94 expect(calls).toEqual([]);
95 });
96 test("the model is asked only a few times a minute per person", async () => {
97 useModel(stub("{}"));
98 const whys: string[] = [];
99 for (let i = 0; i < 8; i++) {
100 const r = await fillFromSentence(app, "u5", "zzz", NOW);
101 if (!r.ok) whys.push(r.why);
102 }
103 expect(whys.filter((w) => w === "limit").length).toBe(2);
104 expect(calls.length).toBe(6);
105 });
106 });
107
108 describe("/boards/data?ask=", () => {
109 let dir = "", app: ReturnType<typeof createApp>, servers: Awaited<ReturnType<typeof serve>>, bearer = "", member = "";
110 beforeAll(async () => {
111 dir = await mkdtemp(join(tmpdir(), "forge-model-fill-web-"));
112 app = createApp({ dataDir: dir, port: 0 });
113 servers = await serve(app);
114 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
115 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
116 if (!a.ok || !b.ok) throw new Error("users");
117 bearer = createToken(app.db, a.value.id, "t", "admin").token;
118 member = createToken(app.db, b.value.id, "t", "admin").token;
119 });
120 afterAll(async () => {
121 servers.stop();
122 app.close();
123 await rm(dir, { recursive: true, force: true });
124 });
125 const get = (path: string, headers: Record<string, string> = {}, token = bearer) =>
126 fetch(servers.url + path, { redirect: "manual", headers: { authorization: `Bearer ${token}`, ...headers } });
127
128 const post = (sentence: string, headers: Record<string, string> = {}) =>
129 fetch(servers.url + "/boards/data", { method: "POST", redirect: "manual", body: new URLSearchParams({ ask: sentence }), headers: { authorization: `Bearer ${bearer}`, origin: servers.url, ...headers } });
130 test("a miss the model fills is drawn in place, with the note, and no link can claim the note", async () => {
131 useModel(stub(JSON.stringify(GOOD)));
132 const res = await post("how pricey were the robots");
133 expect(res.status).toBe(200);
134 const page = await res.text();
135 expect(page).toContain(MODEL_FILLED);
136 expect(page).toContain("Total cost USD per day");
137 expect(await (await get("/boards/data?source=spend&aggregate=sum&column=cost_usd&model=1")).text()).not.toContain(MODEL_FILLED);
138 });
139 test("a hit by the rules redirects without the model and without the note", async () => {
140 useModel(stub(JSON.stringify(GOOD)));
141 const res = await get("/boards/data?ask=memory+over+the+last+3+days");
142 expect(res.status).toBe(303);
143 expect(calls).toEqual([]);
144 });
145 test("a refused answer says so and draws the form", async () => {
146 useModel(stub(JSON.stringify({ ...GOOD, source: "users" })));
147 const res = await post("zzz qqq");
148 expect(res.status).toBe(200);
149 expect(await res.text()).toContain("not a query this explorer can run");
150 });
151 test("a ?ask= link never spends the model, whatever Sec-Fetch-Site says", async () => {
152 useModel(stub(JSON.stringify(GOOD)));
153 for (const site of [undefined, "none", "same-origin", "same-site", "cross-site"]) {
154 const res = await get("/boards/data?ask=zzz+qqq", site === undefined ? {} : { "sec-fetch-site": site });
155 expect(res.status).toBe(200);
156 expect(await res.text()).toContain("I could not read that");
157 }
158 expect(calls).toEqual([]);
159 });
160 test("the page's sentence box posts, and a post from another site is refused before the model", async () => {
161 useModel(stub(JSON.stringify(GOOD)));
162 expect(await (await get("/boards/data")).text()).toContain('<form method="post" action="/boards/data"');
163 expect((await post("zzz qqq", { origin: "https://evil.example" })).status).toBe(403);
164 expect(calls).toEqual([]);
165 });
166 test("a member who is not an admin gets 404 and the model is not asked", async () => {
167 useModel(stub(JSON.stringify(GOOD)));
168 expect((await get("/boards/data?ask=zzz+qqq", {}, member)).status).toBe(404);
169 expect((await fetch(servers.url + "/boards/data", { method: "POST", body: new URLSearchParams({ ask: "zzz" }), headers: { authorization: `Bearer ${member}`, origin: servers.url } })).status).toBe(404);
170 expect(calls).toEqual([]);
171 });
172 });
test/crew-one-shot.test.ts added +73
@@ -0,0 +1,73 @@
1 /** oneShot (#9569): the refusals, the budget clamp and the cost booking, against a fake binary. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { chmodSync, readFileSync, writeFileSync } from "node:fs";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp } from "../src/app.ts";
8 import { dailyLeft, setAccountCeiling } from "../src/core/account_ceiling.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { configureConversation } from "../src/crew/conversation.ts";
11 import { ONE_SHOT_USD, oneShot } from "../src/crew/one_shot.ts";
12
13 let dir = "", app: ReturnType<typeof createApp>, admin = "", member = "";
14 const args = () => readFileSync(join(dir, "args.txt"), "utf8");
15
16 describe("oneShot", () => {
17 beforeAll(async () => {
18 dir = await mkdtemp(join(tmpdir(), "forge-one-shot-"));
19 app = createApp({ dataDir: dir, port: 0 });
20 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
21 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
22 if (!a.ok || !b.ok) throw new Error("users");
23 admin = a.value.id;
24 member = b.value.id;
25 const bin = join(dir, "claude");
26 writeFileSync(bin, `#!/bin/sh\necho "$@" > "${dir}/args.txt"\nIFS= read -r first\necho '{"type":"system","subtype":"init","session_id":"s1","model":"fake"}'\necho '{"type":"result","subtype":"success","is_error":false,"result":"{}","total_cost_usd":0.02,"num_turns":1,"session_id":"s1","terminal_reason":"completed","usage":{"input_tokens":1,"output_tokens":1}}'\n`);
27 chmodSync(bin, 0o755);
28 });
29 afterAll(async () => {
30 app.close();
31 await rm(dir, { recursive: true, force: true });
32 });
33 const on = (budgetUsd = 1) => configureConversation(app, { binary: join(dir, "claude"), localUrl: "http://127.0.0.1:1", budgetUsd, timeoutMs: 30_000, signal: new AbortController().signal });
34 const spent = () => app.db.query<{ n: number }, []>("SELECT spent_usd AS n FROM subscription WHERE id = 'platform'").get()?.n ?? 0;
35
36 test("with no crew configured it is off", async () => {
37 expect(await oneShot(app, admin, "s", "p")).toEqual({ ok: false, error: "off" });
38 });
39 test("a member with no granted account has none", async () => {
40 on();
41 expect(await oneShot(app, member, "s", "p")).toEqual({ ok: false, error: "no_account" });
42 });
43 test("the answer comes back, the budget is the smallest cap, and the cost is booked", async () => {
44 on(0.03);
45 const before = spent();
46 const r = await oneShot(app, admin, "s", "p");
47 expect(r).toEqual({ ok: true, value: "{}" });
48 expect(args()).toContain("--max-budget-usd 0.03");
49 expect(args()).not.toContain(String(ONE_SHOT_USD));
50 expect(spent() - before).toBeCloseTo(0.02, 5);
51 });
52 test("the configured budget above the one-shot cap is clamped to it", async () => {
53 on(5);
54 await oneShot(app, admin, "s", "p");
55 expect(args()).toContain(`--max-budget-usd ${ONE_SHOT_USD}`);
56 });
57 test("a one-shot counts toward the asker's daily ceiling", async () => {
58 on();
59 setAccountCeiling(app.db, admin, 1, 1, admin, new Date());
60 const before = dailyLeft(app.db, admin, new Date())!;
61 expect(await oneShot(app, admin, "s", "p")).toEqual({ ok: true, value: "{}" });
62 expect(before - dailyLeft(app.db, admin, new Date())!).toBeCloseTo(0.02, 5);
63 });
64 test("a spent daily ceiling rests it without starting the model", async () => {
65 on();
66 setAccountCeiling(app.db, admin, 0.01, 0.01, admin, new Date());
67 app.db.exec("PRAGMA foreign_keys = OFF");
68 app.db.query("INSERT INTO conversation_run (id, conversation_id, user_id, prompt, cost_usd, started_at) VALUES ('r1', 'c1', ?1, 'x', 0.5, ?2)").run(admin, new Date().toISOString());
69 writeFileSync(join(dir, "args.txt"), "untouched");
70 expect(await oneShot(app, admin, "s", "p")).toEqual({ ok: false, error: "resting" });
71 expect(args()).toBe("untouched");
72 });
73 });
test/tui-ask-data.test.ts +20
@@ -9,6 +9,8 @@ import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
99 import { exploreRows, resultLines } from "../src/tui/screens/ask_data.ts";
1010 import { entry } from "../src/tui/screens/ask_data.ts";
1111 import { readParams } from "../src/boards/params.ts";
12 import { useModel } from "../src/boards/model_fill.ts";
13 import { ok } from "../src/kernel/result.ts";
1214 import { headlessTerminal } from "../src/tui/term/terminal.ts";
1315 import { quietLogs } from "./helpers/globals.ts";
1416 import { settleApp } from "./helpers/tui.ts";
@@ -80,3 +82,21 @@ test("nobody else has the door, the rows or a query", async () => {
8082 }
8183 expect(resultLines(new Session(app, identity(carol)), readParams(new URLSearchParams("source=runs"))).length).toBeGreaterThan(0);
8284 });
85
86 test("a sentence the rules cannot read goes to the model, and the result says so; a hit never does", async () => {
87 const asked: string[] = [];
88 useModel(async (_a, _u, _s, prompt) => (asked.push(prompt), ok(JSON.stringify({ source: "spend", aggregate: "sum", column: "cost_usd", by: "day", chart: "bar" }))));
89 try {
90 const hit = await typed(carol, ["/", ..."memory over the last 3 days", "\r"]);
91 expect(hit).toContain("Average memory bytes per hour");
92 expect(asked).toEqual([]);
93 const filled = await typed(carol, ["/", ..."how pricey were the robots", "\r"]);
94 expect(asked.length).toBe(1);
95 expect(filled).toContain("Total cost USD per day");
96 expect(filled).toContain("The model filled this form");
97 useModel(async () => ok(JSON.stringify({ source: "users", aggregate: "count" })));
98 expect(await typed(carol, ["/", ..."how pricey were the robots", "\r"])).toContain("not a query this explorer can run");
99 } finally {
100 useModel();
101 }
102 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs