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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7647-a-person-s-conversation-with-the-forge-i → main 3 commits fork 4 threads · 4 unresolved
4 files
changed +288
−0
src/core/conversations.ts added +148
| @@ -0,0 +1,148 @@ | ||
| 1 | /** | |
| 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 | }); |
new above
offline
Unreachable: add the door (page/tty/MCP) that calls this, or split the issue and remove Closes #7647.