Public: anyone can read this; the crew can change it.
firstAsk keeps each refusal as one row: who asked, the first 200 characters, and the refusal's words #11530
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11525-firstask-keeps-each-refusal-as-one-row-w → main 4 commits fork
5 files
changed +207
−7
src/actions/first_ask.ts +13 −7
| @@ -18,6 +18,7 @@ import { parseFrom } from "./tell_from.ts"; | ||
| 18 | 18 | import { deriveAppName } from "../web/pages/apps-name.ts"; |
| 19 | 19 | import { requireVerified } from "./verify.ts"; |
| 20 | 20 | import { MAX_BODY } from "../core/issues.ts"; |
| 21 | import { recordRefusal } from "../core/first_ask_refusals.ts"; | |
| 21 | 22 | import { DIRECTION_NOTE } from "../crew/direct.ts"; |
| 22 | 23 | |
| 23 | 24 | export interface FirstAsk { |
| @@ -49,19 +50,24 @@ export async function firstAsk( | ||
| 49 | 50 | from: string | null = null, |
| 50 | 51 | ): Promise<Result<FirstAsk, FirstAskError>> { |
| 51 | 52 | const text = said.trim(); |
| 52 | if (text === "") return err({ words: SAY_SOMETHING_WORDS }); | |
| 53 | /** A refusal: kept as one row, and its words returned unchanged. */ | |
| 54 | const refuse = (words: string): Result<FirstAsk, FirstAskError> => { | |
| 55 | recordRefusal(app.db, { actorId: actor?.id ?? null, ask: text, words }, Date.now()); | |
| 56 | return err({ words }); | |
| 57 | }; | |
| 58 | if (text === "") return refuse(SAY_SOMETHING_WORDS); | |
| 53 | 59 | // Everything the tell could refuse is asked before the app exists, so a |
| 54 | 60 | // refused ask leaves nothing behind and a retry makes no second app. The |
| 55 | 61 | // new app's owner is the actor, who may direct the crew on it. |
| 56 | if (text.length > MAX_BODY) return err({ words: TOO_LONG_WORDS }); | |
| 57 | if (actor === undefined || actor.kind === "org") return err({ words: DIRECTION_NOTE }); | |
| 58 | if (!requireVerified(app, actor).ok) return err({ words: "Verify your email first." }); | |
| 59 | if (from !== null && typeof parseFrom(from) !== "string") return err({ words: FROM_WORDS }); | |
| 62 | if (text.length > MAX_BODY) return refuse(TOO_LONG_WORDS); | |
| 63 | if (actor === undefined || actor.kind === "org") return refuse(DIRECTION_NOTE); | |
| 64 | if (!requireVerified(app, actor).ok) return refuse("Verify your email first."); | |
| 65 | if (from !== null && typeof parseFrom(from) !== "string") return refuse(FROM_WORDS); | |
| 60 | 66 | // The forge's own repository is never an ask's target: refused before any app exists. |
| 61 | if (resolvesToForgeRepo(app, actor, text)) return err({ words: FORGE_REPO_WORDS }); | |
| 67 | if (resolvesToForgeRepo(app, actor, text)) return refuse(FORGE_REPO_WORDS); | |
| 62 | 68 | // An empty description: the issue the tell files is the one issue of the ask. |
| 63 | 69 | const made = await newAppFromSentence(app, actor, { sentence: text, description: "" }); |
| 64 | if (!made.ok) return err({ words: NEW_APP_ERRORS[made.code] ?? NOT_TOLD_WORDS }); | |
| 70 | if (!made.ok) return refuse(NEW_APP_ERRORS[made.code] ?? NOT_TOLD_WORDS); | |
| 65 | 71 | let repo = made.repo; |
| 66 | 72 | let autonomyFailed = false; |
| 67 | 73 | if (crewCanRun) { |
src/core/first_ask_refusals.ts added +52
| @@ -0,0 +1,52 @@ | ||
| 1 | /** | |
| 2 | * Refused first asks (#11525): one row each with who asked, the first | |
| 3 | * `ASK_CHARS` characters and the refusal's words. At most `PER_ACTOR_HOUR` | |
| 4 | * rows per actor per hour (the next is dropped silently); rows older than | |
| 5 | * `KEEP_MS` are pruned on write. Never holds a claim code, token or email. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | ||
| 10 | export const ASK_CHARS = 200; | |
| 11 | export const PER_ACTOR_HOUR = 20; | |
| 12 | export const KEEP_MS = 30 * 24 * 3_600_000; | |
| 13 | const HOUR_MS = 3_600_000; | |
| 14 | ||
| 15 | export interface Refusal { | |
| 16 | readonly id: number; | |
| 17 | readonly at: number; | |
| 18 | readonly actorId: string | null; | |
| 19 | readonly ask: string; | |
| 20 | readonly words: string; | |
| 21 | } | |
| 22 | ||
| 23 | export interface NewRefusal { | |
| 24 | readonly actorId: string | null; | |
| 25 | readonly ask: string; | |
| 26 | readonly words: string; | |
| 27 | } | |
| 28 | ||
| 29 | /** Keep one refusal; returns whether a row was written. */ | |
| 30 | export function recordRefusal(db: Database, r: NewRefusal, now: number): boolean { | |
| 31 | db.query("DELETE FROM first_ask_refusals WHERE at < ?1").run(now - KEEP_MS); | |
| 32 | const recent = db | |
| 33 | .query("SELECT COUNT(*) AS n FROM first_ask_refusals WHERE actor_id IS ?1 AND at > ?2") | |
| 34 | .get(r.actorId, now - HOUR_MS) as { n: number }; | |
| 35 | if (recent.n >= PER_ACTOR_HOUR) return false; | |
| 36 | db.query("INSERT INTO first_ask_refusals (at, actor_id, ask, words) VALUES (?1, ?2, ?3, ?4)").run( | |
| 37 | now, | |
| 38 | r.actorId, | |
| 39 | r.ask.slice(0, ASK_CHARS), | |
| 40 | r.words, | |
| 41 | ); | |
| 42 | return true; | |
| 43 | } | |
| 44 | ||
| 45 | /** The newest refusals first, at most `limit` (1 to 500), none older than `KEEP_MS` before `now`. */ | |
| 46 | export function listRefusals(db: Database, limit: number, now: number): Refusal[] { | |
| 47 | const n = Math.min(500, Math.max(1, Math.trunc(limit) || 1)); | |
| 48 | const rows = db | |
| 49 | .query("SELECT id, at, actor_id, ask, words FROM first_ask_refusals WHERE at >= ?2 ORDER BY at DESC, id DESC LIMIT ?1") | |
| 50 | .all(n, now - KEEP_MS) as { id: number; at: number; actor_id: string | null; ask: string; words: string }[]; | |
| 51 | return rows.map((r) => ({ id: r.id, at: r.at, actorId: r.actor_id, ask: r.ask, words: r.words })); | |
| 52 | } |
src/core/migration-pins/20261111170000-first-ask-refusals.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 4e6d6e628ab275c02b8019f85d074703ae15e5881eb2ba3a1e13f368babb7445 |
src/core/migrations/20261111170000-first-ask-refusals.ts added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | // `first_ask_refusals` (#11525): one row for each refused first ask: who asked (null when nobody | |
| 2 | // was signed in), the first 200 characters, and the refusal's words. Kept 30 days. New table only. Additive. | |
| 3 | export default ` | |
| 4 | CREATE TABLE first_ask_refusals ( | |
| 5 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 6 | at INTEGER NOT NULL, | |
| 7 | actor_id TEXT, | |
| 8 | ask TEXT NOT NULL, | |
| 9 | words TEXT NOT NULL | |
| 10 | ); | |
| 11 | CREATE INDEX first_ask_refusals_at ON first_ask_refusals (at); | |
| 12 | `; |
test/first-ask-refusals.test.ts added +129
| @@ -0,0 +1,129 @@ | ||
| 1 | /** | |
| 2 | * Refused first asks are kept (#11525): one row each, the ask cut to 200 | |
| 3 | * characters, 20 per actor per hour, 30 days; a success leaves none. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterEach, beforeEach, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { firstAsk } from "../src/actions/first_ask.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { KEEP_MS, listRefusals, recordRefusal } from "../src/core/first_ask_refusals.ts"; | |
| 13 | import { MAX_BODY } from "../src/core/issues.ts"; | |
| 14 | import { FORGE_REPO_WORDS, SAY_SOMETHING_WORDS } from "../src/actions/tell_words.ts"; | |
| 15 | import { NEW_APP_ERRORS } from "../src/actions/apps.ts"; | |
| 16 | import { deriveAppName } from "../src/web/pages/apps-name.ts"; | |
| 17 | import { listReposOwnedBy } from "../src/core/repos.ts"; | |
| 18 | import { createUser, type User } from "../src/core/users.ts"; | |
| 19 | import { TOO_LONG_WORDS } from "../src/actions/tell.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | let dir = ""; | |
| 25 | let app: App; | |
| 26 | let maria: User; | |
| 27 | ||
| 28 | beforeEach(async () => { | |
| 29 | dir = await mkdtemp(join(tmpdir(), "forge-first-ask-refusal-")); | |
| 30 | app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 31 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "maria", email: "[email protected]", password: "a-long-refusal-passphrase", isAdmin: true }); | |
| 32 | if (!u.ok) throw new Error("user"); | |
| 33 | maria = u.value; | |
| 34 | }); | |
| 35 | afterEach(async () => { | |
| 36 | app.db.close(); | |
| 37 | await rm(dir, { recursive: true, force: true }); | |
| 38 | }); | |
| 39 | ||
| 40 | const apps = () => listReposOwnedBy(app.db, maria.id).length; | |
| 41 | ||
| 42 | test("an empty ask leaves one row and no app, words unchanged", async () => { | |
| 43 | const r = await firstAsk(app, maria, " ", false); | |
| 44 | expect(r.ok).toBe(false); | |
| 45 | if (!r.ok) expect(r.error.words).toBe(SAY_SOMETHING_WORDS); | |
| 46 | const rows = listRefusals(app.db, 10, Date.now()); | |
| 47 | expect(rows.length).toBe(1); | |
| 48 | expect(rows[0]!.actorId).toBe(maria.id); | |
| 49 | expect(rows[0]!.words).toBe(SAY_SOMETHING_WORDS); | |
| 50 | expect(apps()).toBe(0); | |
| 51 | }); | |
| 52 | ||
| 53 | test("too long, nobody signed in and a bad from each leave one row", async () => { | |
| 54 | const long = "x".repeat(MAX_BODY + 1); | |
| 55 | const a = await firstAsk(app, maria, long, false); | |
| 56 | if (a.ok) throw new Error("kept"); | |
| 57 | expect(a.error.words).toBe(TOO_LONG_WORDS); | |
| 58 | await firstAsk(app, undefined, "a menu page", false); | |
| 59 | await firstAsk(app, maria, "a menu page", false, "not-a-from-value-\u0000"); | |
| 60 | const rows = listRefusals(app.db, 10, Date.now()); | |
| 61 | expect(rows.length).toBe(3); | |
| 62 | expect(rows.filter((r) => r.actorId === null).length).toBe(1); | |
| 63 | expect(rows.every((r) => r.ask.length <= 200)).toBe(true); | |
| 64 | expect(apps()).toBe(0); | |
| 65 | }); | |
| 66 | ||
| 67 | test("an unverified actor leaves one row and no app", async () => { | |
| 68 | const u = await createUser(app.db, { origin: "seed", username: "newbie", email: "[email protected]", password: "a-long-unverified-passphrase" }); | |
| 69 | if (!u.ok) throw new Error("user"); | |
| 70 | const r = await firstAsk(app, u.value, "A weekly menu page", false); | |
| 71 | if (r.ok) throw new Error("kept"); | |
| 72 | expect(r.error.words).toBe("Verify your email first."); | |
| 73 | const rows = listRefusals(app.db, 10, Date.now()); | |
| 74 | expect(rows.length).toBe(1); | |
| 75 | expect(rows[0]!.actorId).toBe(u.value.id); | |
| 76 | expect(rows[0]!.words).toBe("Verify your email first."); | |
| 77 | expect(listReposOwnedBy(app.db, u.value.id).length).toBe(0); | |
| 78 | }); | |
| 79 | ||
| 80 | test("an ask that resolves to the forge repository leaves one row and no app", async () => { | |
| 81 | const ask = "A weekly menu page"; | |
| 82 | app.config.forgeRepo = `maria/${deriveAppName(ask)}`; | |
| 83 | const r = await firstAsk(app, maria, ask, false); | |
| 84 | if (r.ok) throw new Error("kept"); | |
| 85 | expect(r.error.words).toBe(FORGE_REPO_WORDS); | |
| 86 | const rows = listRefusals(app.db, 10, Date.now()); | |
| 87 | expect(rows.length).toBe(1); | |
| 88 | expect(rows[0]!.words).toBe(FORGE_REPO_WORDS); | |
| 89 | expect(apps()).toBe(0); | |
| 90 | }); | |
| 91 | ||
| 92 | test("an app that cannot be made leaves one row and no app", async () => { | |
| 93 | const r = await firstAsk(app, maria, "!!!", false); | |
| 94 | if (r.ok) throw new Error("kept"); | |
| 95 | expect(r.error.words).toBe(NEW_APP_ERRORS.no_name!); | |
| 96 | const rows = listRefusals(app.db, 10, Date.now()); | |
| 97 | expect(rows.length).toBe(1); | |
| 98 | expect(rows[0]!.words).toBe(NEW_APP_ERRORS.no_name!); | |
| 99 | expect(apps()).toBe(0); | |
| 100 | }); | |
| 101 | ||
| 102 | test("a success leaves no row", async () => { | |
| 103 | const r = await firstAsk(app, maria, "A weekly menu page for the bakery", false); | |
| 104 | expect(r.ok).toBe(true); | |
| 105 | expect(listRefusals(app.db, 10, Date.now()).length).toBe(0); | |
| 106 | }); | |
| 107 | ||
| 108 | test("a 5,000-character ask is stored as 200", () => { | |
| 109 | recordRefusal(app.db, { actorId: maria.id, ask: "y".repeat(5000), words: "w" }, Date.now()); | |
| 110 | expect(listRefusals(app.db, 1, Date.now())[0]!.ask.length).toBe(200); | |
| 111 | }); | |
| 112 | ||
| 113 | test("the 21st refusal in an hour is dropped; another actor and a later hour are not", () => { | |
| 114 | const now = 1_800_000_000_000; | |
| 115 | for (let i = 0; i < 25; i++) recordRefusal(app.db, { actorId: maria.id, ask: `a${i}`, words: "w" }, now + i); | |
| 116 | expect(listRefusals(app.db, 100, now + 100).length).toBe(20); | |
| 117 | recordRefusal(app.db, { actorId: "other", ask: "b", words: "w" }, now + 30); | |
| 118 | expect(listRefusals(app.db, 100, now + 100).length).toBe(21); | |
| 119 | recordRefusal(app.db, { actorId: maria.id, ask: "later", words: "w" }, now + 3_700_000); | |
| 120 | expect(listRefusals(app.db, 1, now + 3_700_000)[0]!.ask).toBe("later"); | |
| 121 | }); | |
| 122 | ||
| 123 | test("rows older than 30 days are pruned on write, and the list is newest first", () => { | |
| 124 | const now = 1_800_000_000_000; | |
| 125 | recordRefusal(app.db, { actorId: "a", ask: "old", words: "w" }, now); | |
| 126 | recordRefusal(app.db, { actorId: "a", ask: "mid", words: "w" }, now + KEEP_MS - 1000); | |
| 127 | recordRefusal(app.db, { actorId: "a", ask: "new", words: "w" }, now + KEEP_MS + 1000); | |
| 128 | expect(listRefusals(app.db, 10, now + KEEP_MS + 1000).map((r) => r.ask)).toEqual(["new", "mid"]); | |
| 129 | }); |