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

Merged opened this

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

Merged

scout/forge:scout/11525-firstask-keeps-each-refusal-as-one-row-w → main 4 commits fork

  • Merged. scout/11525-firstask-keeps-each-refusal-as-one-row-w was merged into main as aa46b8b.

Review

Sign in to review this change.
5 files changed +207 −7
src/actions/first_ask.ts +13 −7
@@ -18,6 +18,7 @@ import { parseFrom } from "./tell_from.ts";
1818 import { deriveAppName } from "../web/pages/apps-name.ts";
1919 import { requireVerified } from "./verify.ts";
2020 import { MAX_BODY } from "../core/issues.ts";
21 import { recordRefusal } from "../core/first_ask_refusals.ts";
2122 import { DIRECTION_NOTE } from "../crew/direct.ts";
2223
2324 export interface FirstAsk {
@@ -49,19 +50,24 @@ export async function firstAsk(
4950 from: string | null = null,
5051 ): Promise<Result<FirstAsk, FirstAskError>> {
5152 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);
5359 // Everything the tell could refuse is asked before the app exists, so a
5460 // refused ask leaves nothing behind and a retry makes no second app. The
5561 // 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);
6066 // 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);
6268 // An empty description: the issue the tell files is the one issue of the ask.
6369 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);
6571 let repo = made.repo;
6672 let autonomyFailed = false;
6773 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs