Public: anyone can read this; the crew can change it.
Data explorer: a model fallback for sentences the rules cannot read #9627
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9569-data-explorer-a-model-fallback-for-sente → main 4 commits fork 5 threads · 5 unresolved
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( | ||
| 62 | 62 | return true; |
| 63 | 63 | } |
| 64 | 64 | |
| 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. */ | |
| 66 | 66 | export function daySpent(db: Database, userId: string, now: Date): number { |
| 67 | 67 | return ( |
| 68 | 68 | db |
| @@ -70,7 +70,9 @@ export function daySpent(db: Database, userId: string, now: Date): number { | ||
| 70 | 70 | `SELECT COALESCE((SELECT SUM(u.cost_usd) FROM usage u JOIN repo r ON r.id = u.repo_id |
| 71 | 71 | WHERE r.owner_id = ?1 AND u.at >= ?2), 0) |
| 72 | 72 | + 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`, | |
| 74 | 76 | ) |
| 75 | 77 | .get(userId, dayStart(now))?.n ?? 0 |
| 76 | 78 | ); |
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 | /** | |
| 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. |
||
| 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 } | ||
| 31 | 31 | import { measureSubmit } from "./measure.ts"; |
| 32 | 32 | import { settingsRoutes } from "./settings_routes.ts"; |
| 33 | 33 | import { pinnedRoutes } from "./pinned_routes.ts"; |
| 34 | import { boardsPage } from "../web/pages/boards.ts"; | |
| 34 | import { boardsAsk, boardsPage } from "../web/pages/boards.ts"; | |
| 35 | 35 | import { boardNew, boardPin, boardPost, pinPost } from "../web/pages/boards-edit.ts"; |
| 36 | 36 | import { boardsHoldPost } from "../web/pages/boards-holds.ts"; |
| 37 | 37 | import { nowPage } from "../web/pages/now.ts"; |
| @@ -952,6 +952,7 @@ export function buildRouter(): Router<Handler> { | ||
| 952 | 952 | router.add("POST", "/signup/passkey", signupPasskey); |
| 953 | 953 | router.add("GET", "/boards", boardsList); |
| 954 | 954 | router.add("GET", "/boards/data", boardsPage); |
| 955 | router.add("POST", "/boards/data", boardsAsk); | |
| 955 | 956 | router.add("POST", "/boards/new", boardNew); |
| 956 | 957 | router.add("POST", "/boards/holds", boardsHoldPost); |
| 957 | 958 | router.add("POST", "/boards/pin", boardPin); |
src/tui/screens/ask_data.ts +22 −11
| @@ -3,12 +3,15 @@ | ||
| 3 | 3 | * text. A list of the ready questions, then the sources by their plain names; |
| 4 | 4 | * `enter` opens one as its one-line description and the rows the query |
| 5 | 5 | * 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. | |
| 7 | 9 | * The gate is the page's: a site admin who is a person, never an agent; for |
| 8 | 10 | * anyone else the door is not offered, the list is empty and no query runs. |
| 9 | 11 | */ |
| 10 | 12 | |
| 11 | 13 | import { ask, GUIDE, parseSentence, QUESTIONS, sentence } from "../../boards/guide.ts"; |
| 14 | import { fillFromSentence, MODEL_FILLED, unreadWords } from "../../boards/model_fill.ts"; | |
| 12 | 15 | import { runFor } from "../../boards/panel_data.ts"; |
| 13 | 16 | import { runQuery } from "../../boards/query.ts"; |
| 14 | 17 | import { SOURCES } from "../../boards/sources.ts"; |
| @@ -41,10 +44,10 @@ export function exploreRows(session: Session, now: number): ExploreRow[] { | ||
| 41 | 44 | } |
| 42 | 45 | |
| 43 | 46 | /** 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[] { | |
| 45 | 48 | if (!mayExplore(session)) return []; |
| 46 | 49 | 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]), ""]; | |
| 48 | 51 | if (!answer.ok) return [...lines, `refused: ${plain(answer.error)}`]; |
| 49 | 52 | if (answer.rows.length === 0) return [...lines, "No rows: nothing matches this query."]; |
| 50 | 53 | for (const r of answer.rows) { |
| @@ -53,11 +56,13 @@ export function resultLines(session: Session, p: Params): string[] { | ||
| 53 | 56 | return lines; |
| 54 | 57 | } |
| 55 | 58 | |
| 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 { | |
| 57 | 63 | const scroll = new Scroll(); |
| 58 | const lines = resultLines(session, p); | |
| 59 | 64 | return { |
| 60 | title: GUIDE[p.source]?.name ?? "Data", | |
| 65 | title, | |
| 61 | 66 | keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")], |
| 62 | 67 | render(area, s) { |
| 63 | 68 | const c = ctx.theme.c; |
| @@ -75,7 +80,7 @@ export function resultScreen(ctx: Ctx, session: Session, p: Params): Screen { | ||
| 75 | 80 | export function askDataScreen(session: Session, ctx: Ctx): Screen { |
| 76 | 81 | const state = new ListState(); |
| 77 | 82 | 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)); | |
| 79 | 84 | const screen: Screen = { |
| 80 | 85 | title: "Data", |
| 81 | 86 | 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 { | ||
| 104 | 109 | label: "Sentence", |
| 105 | 110 | placeholder: "memory over the last 3 days", |
| 106 | 111 | 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 | }); | |
| 111 | 122 | }, |
| 112 | 123 | }), |
| 113 | 124 | ); |
src/web/pages/boards.ts +28 −9
| @@ -13,7 +13,8 @@ import { runQuery, type Row } from "../../boards/query.ts"; | ||
| 13 | 13 | import { gaps, SYSTEM } from "../../boards/sampler.ts"; |
| 14 | 14 | import { SOURCES } from "../../boards/sources.ts"; |
| 15 | 15 | 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"; | |
| 17 | 18 | import { listAllBoards } from "../../actions/boards.ts"; |
| 18 | 19 | import type { Ctx } from "../../http/context.ts"; |
| 19 | 20 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| @@ -21,6 +22,7 @@ import { notFoundPage } from "../chrome.ts"; | ||
| 21 | 22 | import { html, when } from "../html.ts"; |
| 22 | 23 | import { page } from "../layout.ts"; |
| 23 | 24 | import { btn, card, chartBox, empty, field, selectField } from "../ui.ts"; |
| 25 | import { readForm } from "../form.ts"; | |
| 24 | 26 | |
| 25 | 27 | const AGGREGATES = ["count", "sum", "avg", "max"] as const; |
| 26 | 28 | 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 | ||
| 139 | 141 | }; |
| 140 | 142 | } |
| 141 | 143 | |
| 142 | // GET /boards/data | |
| 144 | // GET /boards/data: a `?ask=` sentence is read by the rules only, so no link can spend the model. | |
| 143 | 145 | export async function boardsPage(ctx: Ctx): Promise<Response> { |
| 144 | 146 | const user = ctx.auth.user; |
| 145 | 147 | 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: |
||
| 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> { | |
| 146 | 163 | 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); | |
| 151 | 170 | const src = Object.hasOwn(SOURCES, p.source) ? SOURCES[p.source]! : undefined; |
| 152 | 171 | const answer = timePhase(ctx.request, "query", () => |
| 153 | 172 | 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> { | ||
| 176 | 195 | // A blank box (Show pressed with nothing typed) is no sentence, not an unreadable one. |
| 177 | 196 | const unread = asked !== null && asked.trim() !== ""; |
| 178 | 197 | 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") } : {}) })} | |
| 181 | 200 | ${btn("Show", { variant: "ghost" })} |
| 182 | 201 | </form> |
| 183 | 202 | <ul class="list"> |
| @@ -262,7 +281,7 @@ export async function boardsPage(ctx: Ctx): Promise<Response> { | ||
| 262 | 281 | ctx, |
| 263 | 282 | title: "Boards", |
| 264 | 283 | 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}`, | |
| 266 | 285 | }); |
| 267 | 286 | notePhase(ctx.request, "render", Math.max(0, performance.now() - rendering - boardsMs)); |
| 268 | 287 | 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"; | ||
| 9 | 9 | import { exploreRows, resultLines } from "../src/tui/screens/ask_data.ts"; |
| 10 | 10 | import { entry } from "../src/tui/screens/ask_data.ts"; |
| 11 | 11 | import { readParams } from "../src/boards/params.ts"; |
| 12 | import { useModel } from "../src/boards/model_fill.ts"; | |
| 13 | import { ok } from "../src/kernel/result.ts"; | |
| 12 | 14 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 13 | 15 | import { quietLogs } from "./helpers/globals.ts"; |
| 14 | 16 | import { settleApp } from "./helpers/tui.ts"; |
| @@ -80,3 +82,21 @@ test("nobody else has the door, the rows or a query", async () => { | ||
| 80 | 82 | } |
| 81 | 83 | expect(resultLines(new Session(app, identity(carol)), readParams(new URLSearchParams("source=runs"))).length).toBeGreaterThan(0); |
| 82 | 84 | }); |
| 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 | }); |
No test covers oneShot itself: budget clamp, off/no_account/resting refusals and cost booking are all untested.