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

A person's conversation with the forge is stored turn by turn and read back after a restart #7666

Merged opened this

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

Merged

mason/forge:mason/7647-a-person-s-conversation-with-the-forge-i → main 3 commits fork 4 threads · 4 unresolved

  • Merged. mason/7647-a-person-s-conversation-with-the-forge-i was merged into main as 6035f0a.

Review

Sign in to review this change.
4 files changed +288 −0
src/core/conversations.ts added +148
@@ -0,0 +1,148 @@
1 /**

Unreachable: add the door (page/tty/MCP) that calls this, or split the issue and remove Closes #7647.

2 * A person's conversation with the forge: one per person, stored turn by turn
3 * in the database so it reads back after a restart. Every call takes the
4 * person's id and answers only for their own conversation; another person's
5 * conversation is `not_found`. Plain data in, plain data out, no authz beyond
6 * that ownership.
7 */
8 import type { Database } from "bun:sqlite";
9 import { id } from "../kernel/id.ts";
10 import { err, ok, type Result } from "../kernel/result.ts";
11
12 export const TURN_ROLES = ["person", "agent", "tool", "confirm"] as const;
13 export type TurnRole = (typeof TURN_ROLES)[number];
14
15 /** Longest turn text kept, in characters; longer is refused, not cut. */
16 export const MAX_TURN_CHARS = 20_000;
17 export const DEFAULT_PAGE = 50;
18 export const MAX_PAGE = 200;
19
20 export interface Conversation {
21 readonly id: string;
22 readonly userId: string;
23 readonly claudeSessionId: string;
24 readonly createdAt: string;
25 readonly updatedAt: string;
26 }
27
28 export interface Turn {
29 readonly id: string;
30 readonly conversationId: string;
31 readonly role: TurnRole;
32 readonly text: string;
33 readonly createdAt: string;
34 }
35
36 interface ConversationRow {
37 id: string;
38 user_id: string;
39 claude_session_id: string;
40 created_at: string;
41 updated_at: string;
42 }
43
44 interface TurnRow {
45 id: string;
46 conversation_id: string;
47 role: TurnRole;
48 text: string;
49 created_at: string;
50 }
51
52 const conversationOf = (r: ConversationRow): Conversation => ({
53 id: r.id,
54 userId: r.user_id,
55 claudeSessionId: r.claude_session_id,
56 createdAt: r.created_at,
57 updatedAt: r.updated_at,
58 });
59
60 const turnOf = (r: TurnRow): Turn => ({
61 id: r.id,
62 conversationId: r.conversation_id,
63 role: r.role,
64 text: r.text,
65 createdAt: r.created_at,
66 });
67
68 /** The person's conversation, made on first use. */
69 export function openConversation(db: Database, userId: string): Conversation {
70 const now = new Date().toISOString();
71 db.query(
72 "INSERT OR IGNORE INTO conversation (id, user_id, claude_session_id, created_at, updated_at) VALUES (?1,?2,'',?3,?3)",
73 ).run(id(), userId, now);
74 const row = db
75 .query("SELECT * FROM conversation WHERE user_id = ?1")
76 .get(userId) as ConversationRow;
77 return conversationOf(row);
78 }
79
80 const owned = (db: Database, userId: string, conversationId: string): boolean =>
81 db
82 .query("SELECT 1 FROM conversation WHERE id = ?1 AND user_id = ?2")
83 .get(conversationId, userId) !== null;
84
85 /** Add a turn at the end. Text is 1 to `MAX_TURN_CHARS` characters. */
86 export function appendTurn(
87 db: Database,
88 userId: string,
89 conversationId: string,
90 input: { readonly role: string; readonly text: string },
91 ): Result<Turn, "not_found" | "bad_role" | "empty_text" | "text_too_long"> {
92 if (!owned(db, userId, conversationId)) return err("not_found");
93 if (!(TURN_ROLES as readonly string[]).includes(input.role)) return err("bad_role");
94 if (input.text.trim() === "") return err("empty_text");
95 if (input.text.length > MAX_TURN_CHARS) return err("text_too_long");
96 const now = new Date().toISOString();
97 const row: TurnRow = {
98 id: id(),
99 conversation_id: conversationId,
100 role: input.role as TurnRole,
101 text: input.text,
102 created_at: now,
103 };
104 db.query(
105 "INSERT INTO conversation_turn (id, conversation_id, role, text, created_at) VALUES (?1,?2,?3,?4,?5)",
106 ).run(row.id, row.conversation_id, row.role, row.text, row.created_at);
107 db.query("UPDATE conversation SET updated_at = ?2 WHERE id = ?1").run(conversationId, now);
108 return ok(turnOf(row));
109 }
110
111 /**
112 * A page of turns, newest last: the latest `limit` turns, or the `limit`
113 * before the turn id `before`, oldest first. Order is insertion order
114 * (rowid), so turns written in the same millisecond keep their order. Page
115 * back by passing the first turn's id as `before`.
116 */
117 export function listTurns(
118 db: Database,
outdated

Ordering and paging depend on id lexical order; confirm id() is monotonic within a millisecond or order by rowid, and test same-ms turns.

119 userId: string,
120 conversationId: string,
121 opts: { readonly before?: string; readonly limit?: number } = {},
122 ): Result<Turn[], "not_found"> {
123 if (!owned(db, userId, conversationId)) return err("not_found");
124 const limit = Math.min(MAX_PAGE, Math.max(1, Math.floor(opts.limit ?? DEFAULT_PAGE)));
125 const rows = db
126 .query(
127 "SELECT * FROM conversation_turn WHERE conversation_id = ?1 AND (?2 IS NULL OR rowid < (SELECT rowid FROM conversation_turn WHERE id = ?2 AND conversation_id = ?1)) ORDER BY rowid DESC LIMIT ?3",
128 )
129 .all(conversationId, opts.before ?? null, limit) as TurnRow[];
130 return ok(rows.reverse().map(turnOf));
131 }
132
133 /** What a Claude session id may look like: short, and only id characters. */
134 export const SESSION_ID = /^[A-Za-z0-9._-]{1,128}$/;
135
136 /** Remember the Claude session this conversation resumes. */
137 export function setClaudeSession(
138 db: Database,
139 userId: string,
140 conversationId: string,
outdated

sessionId is stored with no length or format check; validate at the boundary and add a test.

141 sessionId: string,
142 ): Result<void, "not_found" | "bad_session"> {
143 if (!SESSION_ID.test(sessionId)) return err("bad_session");
144 const changed = db
145 .query("UPDATE conversation SET claude_session_id = ?3, updated_at = ?4 WHERE id = ?1 AND user_id = ?2")
146 .run(conversationId, userId, sessionId, new Date().toISOString()).changes;
147 return changed === 0 ? err("not_found") : ok();
148 }
src/core/migration-pins/20261101090000-conversation.pin added +1
@@ -0,0 +1,1 @@
1 ccd2f59c07a53b0795d8f2129a1cafd26fb9aa01bf2106dbf3ae304ba07a2ecb
src/core/migrations/20261101090000-conversation.ts added +19
@@ -0,0 +1,19 @@
1 // A person's conversation with the forge, kept turn by turn (#7647): one per
2 // person, so talking to the forge never depends on a session that can stall.
3 export default `
4 CREATE TABLE conversation (
5 id TEXT PRIMARY KEY,
6 user_id TEXT NOT NULL UNIQUE REFERENCES user(id) ON DELETE CASCADE,
7 claude_session_id TEXT NOT NULL DEFAULT '',
8 created_at TEXT NOT NULL,
9 updated_at TEXT NOT NULL
10 );
11 CREATE TABLE conversation_turn (
12 id TEXT PRIMARY KEY,
13 conversation_id TEXT NOT NULL REFERENCES conversation(id) ON DELETE CASCADE,
14 role TEXT NOT NULL CHECK (role IN ('person','agent','tool','confirm')),
15 text TEXT NOT NULL,
16 created_at TEXT NOT NULL
17 );
18 CREATE INDEX conversation_turn_by_conversation ON conversation_turn(conversation_id, id);

Non-blocking: listTurns orders by rowid, not id, so this index does not serve that order. Index (conversation_id) is enough.

19 `;
test/conversations.test.ts added +120
@@ -0,0 +1,120 @@
1 import { afterAll, describe, expect, test } from "bun:test";
2 import { mkdtempSync, rmSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { migrations } from "../src/core/schema.ts";
6 import {
7 appendTurn,
8 listTurns,
9 MAX_TURN_CHARS,
10 openConversation,
11 setClaudeSession,
12 } from "../src/core/conversations.ts";
13 import { openDb } from "../src/kernel/db.ts";
14
15 const dir = mkdtempSync(join(tmpdir(), "forge-conversations-"));
16 afterAll(() => rmSync(dir, { recursive: true, force: true }));
17
18 const open = (name: string) => {
19 const db = openDb(join(dir, name), migrations);
20 for (const u of ["u1", "u2"])
21 db.query("INSERT OR IGNORE INTO user (id, username, email, password_hash) VALUES (?1,?1,?2,'x')").run(u, `${u}@x`);
22 return db;
23 };
24
25 describe("conversations", () => {
26 test("one conversation per person, returned on the second open", () => {
27 const db = open("one.db");
28 const a = openConversation(db, "u1");
29 expect(openConversation(db, "u1").id).toBe(a.id);
30 expect(openConversation(db, "u2").id).not.toBe(a.id);
31 db.close();
32 });
33
34 test("turns written, database reopened, read back in order", () => {
35 const db = open("reopen.db");
36 const c = openConversation(db, "u1");
37 for (const [role, text] of [["person", "hi"], ["agent", "hello"], ["tool", "ran"], ["confirm", "ok?"]] as [string, string][])
38 expect(appendTurn(db, "u1", c.id, { role, text }).ok).toBe(true);
39 setClaudeSession(db, "u1", c.id, "sess-1");
40 db.close();
41 const again = open("reopen.db");
42 const c2 = openConversation(again, "u1");
43 expect(c2.id).toBe(c.id);
44 expect(c2.claudeSessionId).toBe("sess-1");
45 const r = listTurns(again, "u1", c.id);
46 expect(r.ok && r.value.map((t) => `${t.role}:${t.text}`)).toEqual([
47 "person:hi", "agent:hello", "tool:ran", "confirm:ok?",
48 ]);
49 again.close();
50 });
51
52 test("pages back from the newest, newest last", () => {
53 const db = open("page.db");
54 const c = openConversation(db, "u1");
55 for (let i = 1; i <= 5; i++) appendTurn(db, "u1", c.id, { role: "person", text: `t${i}` });
56 const last = listTurns(db, "u1", c.id, { limit: 2 });
57 expect(last.ok && last.value.map((t) => t.text)).toEqual(["t4", "t5"]);
58 const before = last.ok ? last.value[0]!.id : "";
59 const prev = listTurns(db, "u1", c.id, { limit: 2, before });
60 expect(prev.ok && prev.value.map((t) => t.text)).toEqual(["t2", "t3"]);
61 db.close();
62 });
63
64 test("many turns in one burst page back without gaps or repeats", () => {
65 const db = open("burst.db");
66 const c = openConversation(db, "u1");
67 for (let i = 0; i < 120; i++) appendTurn(db, "u1", c.id, { role: "person", text: `n${i}` });
68 const seen: string[] = [];
69 let before: string | undefined;
70 for (;;) {
71 const r = listTurns(db, "u1", c.id, before === undefined ? { limit: 50 } : { limit: 50, before });
72 if (!r.ok || r.value.length === 0) break;
73 seen.unshift(...r.value.map((t) => t.text));
74 before = r.value[0]!.id;
75 }
76 expect(seen).toEqual(Array.from({ length: 120 }, (_, i) => `n${i}`));
77 db.close();
78 });
79
80 test("a before id from another conversation pages nothing", () => {
81 const db = open("cross.db");
82 const a = openConversation(db, "u1");
83 const b = openConversation(db, "u2");
84 const t = appendTurn(db, "u2", b.id, { role: "person", text: "b" });
85 appendTurn(db, "u1", a.id, { role: "person", text: "a" });
86 const r = listTurns(db, "u1", a.id, { before: t.ok ? t.value.id : "" });
87 expect(r.ok && r.value).toEqual([]);
88 db.close();
89 });
90
91 test("the Claude session id is checked", () => {
92 const db = open("sess.db");
93 const c = openConversation(db, "u1");
94 expect(setClaudeSession(db, "u1", c.id, "x".repeat(129))).toEqual({ ok: false, error: "bad_session" });
95 expect(setClaudeSession(db, "u1", c.id, "a b;c")).toEqual({ ok: false, error: "bad_session" });
96 expect(setClaudeSession(db, "u1", c.id, "")).toEqual({ ok: false, error: "bad_session" });
97 expect(setClaudeSession(db, "u1", c.id, "3f2a-9b").ok).toBe(true);
98 db.close();
99 });
100
101 test("another person's conversation is not_found", () => {
102 const db = open("own.db");
103 const c = openConversation(db, "u1");
104 expect(appendTurn(db, "u2", c.id, { role: "person", text: "x" })).toEqual({ ok: false, error: "not_found" });
105 expect(listTurns(db, "u2", c.id)).toEqual({ ok: false, error: "not_found" });
106 expect(setClaudeSession(db, "u2", c.id, "s")).toEqual({ ok: false, error: "not_found" });
107 db.close();
108 });
109
110 test("turn text is checked at the boundary", () => {
111 const db = open("bounds.db");
112 const c = openConversation(db, "u1");
113 const add = (role: string, text: string) => appendTurn(db, "u1", c.id, { role, text });
114 expect(add("person", "x".repeat(MAX_TURN_CHARS)).ok).toBe(true);
115 expect(add("person", "x".repeat(MAX_TURN_CHARS + 1))).toEqual({ ok: false, error: "text_too_long" });
116 expect(add("person", " ")).toEqual({ ok: false, error: "empty_text" });
117 expect(add("robot", "hi")).toEqual({ ok: false, error: "bad_role" });
118 db.close();
119 });
120 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs