diff options
| author | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
| commit | 394f1ed37ce860da6fdc385769bf29f9737105cd (patch) | |
| tree | 4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/core/src/db | |
| parent | 81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff) | |
| download | dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip | |
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
Diffstat (limited to 'packages/core/src/db')
| -rw-r--r-- | packages/core/src/db/chunks.ts | 246 | ||||
| -rw-r--r-- | packages/core/src/db/index.ts | 177 | ||||
| -rw-r--r-- | packages/core/src/db/settings.ts | 22 | ||||
| -rw-r--r-- | packages/core/src/db/tabs.ts | 250 |
4 files changed, 0 insertions, 695 deletions
diff --git a/packages/core/src/db/chunks.ts b/packages/core/src/db/chunks.ts deleted file mode 100644 index b434a47..0000000 --- a/packages/core/src/db/chunks.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { - explodeTurn, - explodeUserText, - groupRowsToMessages, - type MessageRow, -} from "../chunks/transform.js"; -import type { - ChunkData, - ChunkRow, - ChunkRowDraft, - TextData, - UsageData, - UsageStats, -} from "../types/index.js"; -import { getDatabase } from "./index.js"; - -// Re-export the DB-free transforms so existing barrel consumers -// (`@dispatch/core`) keep importing them from here. The browser frontend deep- -// imports them directly from `chunks/transform.js` to avoid the DB dependency. -export { explodeTurn, explodeUserText, groupRowsToMessages, type MessageRow }; - -// ─── Persistence ───────────────────────────────────────────────── - -function mapRow(row: Record<string, unknown>): ChunkRow { - let data: ChunkData; - try { - data = JSON.parse(row.data_json as string) as ChunkData; - } catch { - data = { text: "" } as TextData; - } - return { - id: row.id as string, - tabId: row.tab_id as string, - seq: row.seq as number, - turnId: row.turn_id as string, - step: row.step as number, - role: row.role as ChunkRow["role"], - type: row.type as ChunkRow["type"], - data, - createdAt: row.created_at as number, - }; -} - -/** - * Append one or more chunk-row drafts to a tab, assigning a monotonic per-tab - * `seq` and a fresh id/timestamp to each. Returns the inserted rows in order. - */ -export function appendChunks(tabId: string, drafts: ChunkRowDraft[]): ChunkRow[] { - if (drafts.length === 0) return []; - const db = getDatabase(); - const maxSeq = db - .query("SELECT COALESCE(MAX(seq), -1) as max_seq FROM chunks WHERE tab_id = $tabId") - .get({ $tabId: tabId }) as { max_seq: number }; - let seq = (maxSeq?.max_seq ?? -1) + 1; - const now = Date.now(); - const insert = db.query( - `INSERT INTO chunks (id, tab_id, seq, turn_id, step, role, type, data_json, created_at) - VALUES ($id, $tabId, $seq, $turnId, $step, $role, $type, $dataJson, $now)`, - ); - const out: ChunkRow[] = []; - // Wrap the whole batch in one transaction: a turn's chunks are persisted in - // a single `appendChunks` call, so this is one fsync per turn instead of one - // per row — the chosen low-IO write strategy for constrained backends. - const insertAll = db.transaction(() => { - for (const draft of drafts) { - const id = randomUUID(); - insert.run({ - $id: id, - $tabId: tabId, - $seq: seq, - $turnId: draft.turnId, - $step: draft.step, - $role: draft.role, - $type: draft.type, - $dataJson: JSON.stringify(draft.data), - $now: now, - }); - out.push({ - id, - tabId, - seq, - turnId: draft.turnId, - step: draft.step, - role: draft.role, - type: draft.type, - data: draft.data, - createdAt: now, - }); - seq++; - } - }); - insertAll(); - return out; -} - -/** - * Read chunk rows for a tab in `seq` order (ASC). Pagination mirrors the old - * message pagination but at chunk granularity: - * - no options → all rows; - * - `before` → rows with `seq < before`, most-recent-first then reversed; - * - `limit` → most recent `limit` rows, reversed to ASC. - */ -export function getChunksForTab( - tabId: string, - options?: { limit?: number; before?: number }, -): ChunkRow[] { - const db = getDatabase(); - if (!options) { - const rows = db - .query("SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC") - .all({ $tabId: tabId }) as Array<Record<string, unknown>>; - return rows.map(mapRow); - } - const { limit, before } = options; - if (before !== undefined) { - if (limit !== undefined) { - const rows = db - .query( - "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC LIMIT $limit", - ) - .all({ $tabId: tabId, $before: before, $limit: limit }) as Array<Record<string, unknown>>; - return rows.map(mapRow).reverse(); - } - const rows = db - .query( - "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC", - ) - .all({ $tabId: tabId, $before: before }) as Array<Record<string, unknown>>; - return rows.map(mapRow).reverse(); - } - if (limit !== undefined) { - const rows = db - .query( - "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq DESC LIMIT $limit", - ) - .all({ $tabId: tabId, $limit: limit }) as Array<Record<string, unknown>>; - return rows.map(mapRow).reverse(); - } - const rows = db - .query("SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC") - .all({ $tabId: tabId }) as Array<Record<string, unknown>>; - return rows.map(mapRow); -} - -/** - * Derived, grouped view of a tab's full history as messages. Used to - * pre-populate the agent's in-memory `ChatMessage[]` history when an Agent is - * (re)constructed. Always reads the full log (grouping a partial window would - * be lossy for the rebuild path). - */ -export function getMessagesForTab(tabId: string): MessageRow[] { - return groupRowsToMessages(getChunksForTab(tabId)); -} - -export function getTotalChunkCount(tabId: string): number { - const db = getDatabase(); - const row = db - .query("SELECT COUNT(*) as count FROM chunks WHERE tab_id = $tabId AND type != 'usage'") - .get({ $tabId: tabId }) as { count: number } | null; - return row?.count ?? 0; -} - -/** - * Aggregate per-tab token/cache usage across ALL persisted `usage` chunk rows. - * - * Usage rows are written as an invisible side channel (one row per `usage` - * AgentEvent) and are query-excluded from `getChunksForTab`/`getTotalChunkCount`, - * so this aggregate is the read path. Because it sums server-side over every - * row, it stays complete even after the frontend evicts/pages out old turns - * (eviction is in-memory only). The return shape is structurally identical to - * the frontend `CacheStats`, so reload can seed it directly. - * - * - cumulative `inputTokens`/`outputTokens`/`cacheReadTokens`/`cacheWriteTokens` - * = SUM over all usage rows; - * - `requests` = COUNT of usage rows; - * - `last` = the highest-seq usage row's split (most recent request); - * - `null` when the tab has no usage rows. - * - * Sums in JS after selecting the rows (mirroring `mapRow`) to avoid relying on - * `json_extract` over the freeform `data_json`. - */ -export function getUsageStatsForTab(tabId: string): UsageStats | null { - const db = getDatabase(); - const rows = db - .query("SELECT data_json FROM chunks WHERE tab_id = $tabId AND type = 'usage' ORDER BY seq ASC") - .all({ $tabId: tabId }) as Array<{ data_json: string }>; - if (rows.length === 0) return null; - - let inputTokens = 0; - let outputTokens = 0; - let cacheReadTokens = 0; - let cacheWriteTokens = 0; - let last: UsageData | null = null; - for (const row of rows) { - let u: UsageData; - try { - u = JSON.parse(row.data_json) as UsageData; - } catch { - continue; - } - inputTokens += u.inputTokens ?? 0; - outputTokens += u.outputTokens ?? 0; - cacheReadTokens += u.cacheReadTokens ?? 0; - cacheWriteTokens += u.cacheWriteTokens ?? 0; - last = { - inputTokens: u.inputTokens ?? 0, - outputTokens: u.outputTokens ?? 0, - cacheReadTokens: u.cacheReadTokens ?? 0, - cacheWriteTokens: u.cacheWriteTokens ?? 0, - }; - } - - return { - inputTokens, - outputTokens, - cacheReadTokens, - cacheWriteTokens, - requests: rows.length, - last, - }; -} - -export function clearChunksForTab(tabId: string): void { - const db = getDatabase(); - db.query("DELETE FROM chunks WHERE tab_id = $tabId").run({ $tabId: tabId }); -} - -/** - * Relocate every chunk row from one tab to another (compaction backup path). - * - * Used by conversation compaction to move the FULL pre-compaction history off - * the canonical tab id (`fromTabId`) onto a freshly-created backup tab id - * (`toTabId`), leaving the canonical id free to be re-seeded with the summary + - * preserved tail. `seq` values are preserved (they remain per-tab monotonic for - * the destination since it starts empty), as are turn ids, so the relocated - * history groups identically under its new tab. Returns the number of rows - * moved. - */ -export function rekeyChunks(fromTabId: string, toTabId: string): number { - const db = getDatabase(); - const result = db - .query("UPDATE chunks SET tab_id = $to WHERE tab_id = $from") - .run({ $from: fromTabId, $to: toTabId }); - return Number(result.changes ?? 0); -} diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts deleted file mode 100644 index 93ec1f9..0000000 --- a/packages/core/src/db/index.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { Database } from "bun:sqlite"; -import { existsSync, mkdirSync } from "node:fs"; -import { homedir } from "node:os"; -import { isAbsolute, join } from "node:path"; - -/** - * Returns the directory for persistent Dispatch data, following XDG Base - * Directory spec on Linux: `$XDG_DATA_HOME/dispatch` (defaults to - * `~/.local/share/dispatch`). - */ -function getDataDir(): string { - const xdg = process.env.XDG_DATA_HOME; - const base = xdg && isAbsolute(xdg) ? xdg : join(homedir(), ".local", "share"); - return join(base, "dispatch"); -} - -let _db: Database | null = null; - -/** - * Get (or create) the singleton SQLite database. - * - * - Creates the data directory if it doesn't exist. - * - Creates `dispatch.db` if it doesn't exist. - * - Enables WAL journal mode for concurrent read performance. - */ -export function getDatabase(): Database { - if (_db) return _db; - - const dir = getDataDir(); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - - const dbPath = join(dir, "dispatch.db"); - _db = new Database(dbPath, { create: true }); - - // WAL mode: better concurrent read performance, safe for single-writer - _db.run("PRAGMA journal_mode = WAL;"); - // Recommended for WAL: normal synchronous is safe and faster - _db.run("PRAGMA synchronous = NORMAL;"); - // Enable foreign keys - _db.run("PRAGMA foreign_keys = ON;"); - - // Create tables - _db.run(`CREATE TABLE IF NOT EXISTS credentials ( - key_id TEXT PRIMARY KEY, - provider TEXT NOT NULL, - access_token TEXT NOT NULL, - refresh_token TEXT NOT NULL, - expires_at INTEGER NOT NULL, - subscription_type TEXT, - source_file TEXT, - imported_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - )`); - - // Wake schedule: 4 rows per marked hour (one per :00 / :15 / :30 / :45 probe - // slot). The PK is (hour, slot_minute). Destructive migration off the legacy - // single-row-per-hour schema: detect by absence of the `slot_minute` column - // and drop the old table. Other tables (credentials, api_keys, usage_cache, - // settings, tabs, chunks) are NOT touched. - const legacyWakeSchema = (() => { - try { - const cols = _db.query("PRAGMA table_info(wake_schedule)").all() as Array<{ name: string }>; - if (cols.length === 0) return false; // table doesn't exist yet - return !cols.some((c) => c.name === "slot_minute"); - } catch { - return false; - } - })(); - if (legacyWakeSchema) { - _db.run("DROP TABLE IF EXISTS wake_schedule"); - } - _db.run(`CREATE TABLE IF NOT EXISTS wake_schedule ( - hour INTEGER NOT NULL CHECK (hour BETWEEN 0 AND 23), - slot_minute INTEGER NOT NULL CHECK (slot_minute IN (0, 15, 30, 45)), - next_wake_at INTEGER NOT NULL, - PRIMARY KEY (hour, slot_minute) - )`); - - _db.run(`CREATE TABLE IF NOT EXISTS usage_cache ( - key_id TEXT PRIMARY KEY, - provider TEXT NOT NULL, - cached_at INTEGER NOT NULL, - report_json TEXT NOT NULL - )`); - - _db.run(`CREATE TABLE IF NOT EXISTS api_keys ( - key_id TEXT PRIMARY KEY, - provider TEXT NOT NULL, - api_key TEXT NOT NULL, - imported_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - )`); - - _db.run(`CREATE TABLE IF NOT EXISTS tabs ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - key_id TEXT, - model_id TEXT, - parent_tab_id TEXT, - status TEXT NOT NULL DEFAULT 'idle', - is_open INTEGER NOT NULL DEFAULT 1, - position INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - )`); - - try { - _db.run("ALTER TABLE tabs ADD COLUMN parent_tab_id TEXT"); - } catch { - // Column already exists — ignore - } - - // ─── Append-only chunk log (replaces the old `messages` blob table) ── - // - // A conversation is stored as a flat, append-only stream of chunk rows - // keyed by a per-tab monotonic `seq`. "Message" and "turn" are DERIVED - // groupings (see db/chunks.ts), never stored containers. This is what - // powers per-chunk frontend pagination AND the stable per-step wire - // format that fixes Anthropic prompt-cache churn (see notes/plan-chunk-log.md). - // - // role : 'user' | 'assistant' | 'tool' | 'system' - // type : 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'error' | 'system' - // step : LLM round-trip index within a turn (user/system rows = 0) - // data_json: the type-specific payload (see ChunkData in types) - _db.run(`CREATE TABLE IF NOT EXISTS chunks ( - id TEXT PRIMARY KEY, - tab_id TEXT NOT NULL, - seq INTEGER NOT NULL, - turn_id TEXT NOT NULL, - step INTEGER NOT NULL DEFAULT 0, - role TEXT NOT NULL, - type TEXT NOT NULL, - data_json TEXT NOT NULL, - created_at INTEGER NOT NULL - )`); - - _db.run(`CREATE INDEX IF NOT EXISTS idx_chunks_tab_seq ON chunks(tab_id, seq)`); - - // One-shot migration off the legacy `messages` blob model. Beta software, - // no backward compatibility: the old chat history is destroyed (tabs + - // messages), while settings / credentials / api_keys / usage_cache / - // wake_schedule are preserved. Detect the old schema by the presence of - // the `messages` table; once dropped, this branch never runs again. - const hasLegacyMessages = _db - .query("SELECT name FROM sqlite_master WHERE type='table' AND name='messages'") - .get() as { name: string } | null; - if (hasLegacyMessages) { - _db.run("DROP TABLE IF EXISTS messages"); - // Clear conversation containers too (fresh slate for the new model). - _db.run("DELETE FROM tabs"); - _db.run("DELETE FROM chunks"); - } - - _db.run(`CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - )`); - - return _db; -} - -/** Close the database connection (e.g. on shutdown). */ -export function closeDatabase(): void { - if (_db) { - _db.close(); - _db = null; - } -} - -/** Returns the path where the database file lives (or will live). */ -export function getDatabasePath(): string { - if (_db) return _db.filename; - const dir = getDataDir(); - return join(dir, "dispatch.db"); -} diff --git a/packages/core/src/db/settings.ts b/packages/core/src/db/settings.ts deleted file mode 100644 index f9d152e..0000000 --- a/packages/core/src/db/settings.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { getDatabase } from "./index.js"; - -export function getSetting(key: string): string | null { - const db = getDatabase(); - const row = db.query("SELECT value FROM settings WHERE key = $key").get({ $key: key }) as { - value: string; - } | null; - return row?.value ?? null; -} - -export function setSetting(key: string, value: string): void { - const db = getDatabase(); - db.query( - `INSERT INTO settings (key, value) VALUES ($key, $value) - ON CONFLICT(key) DO UPDATE SET value = $value`, - ).run({ $key: key, $value: value }); -} - -export function deleteSetting(key: string): void { - const db = getDatabase(); - db.query("DELETE FROM settings WHERE key = $key").run({ $key: key }); -} diff --git a/packages/core/src/db/tabs.ts b/packages/core/src/db/tabs.ts deleted file mode 100644 index f719a01..0000000 --- a/packages/core/src/db/tabs.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { getDatabase } from "./index.js"; - -export interface TabRow { - id: string; - title: string; - keyId: string | null; - modelId: string | null; - parentTabId: string | null; - status: string; - isOpen: boolean; - position: number; - createdAt: number; - updatedAt: number; -} - -function rowToTab(row: Record<string, unknown>): TabRow { - return { - id: row.id as string, - title: row.title as string, - keyId: row.key_id as string | null, - modelId: row.model_id as string | null, - parentTabId: (row.parent_tab_id as string) ?? null, - status: row.status as string, - isOpen: (row.is_open as number) === 1, - position: row.position as number, - createdAt: row.created_at as number, - updatedAt: row.updated_at as number, - }; -} - -export function createTab( - id: string, - title: string, - options?: { keyId?: string | null; modelId?: string | null; parentTabId?: string | null }, -): TabRow { - const db = getDatabase(); - const now = Date.now(); - const maxPos = db - .query("SELECT COALESCE(MAX(position), -1) as max_pos FROM tabs WHERE is_open = 1") - .get() as { max_pos: number }; - const position = (maxPos?.max_pos ?? -1) + 1; - const keyId = options?.keyId ?? null; - const modelId = options?.modelId ?? null; - const parentTabId = options?.parentTabId ?? null; - db.query( - `INSERT INTO tabs (id, title, key_id, model_id, parent_tab_id, status, is_open, position, created_at, updated_at) - VALUES ($id, $title, $keyId, $modelId, $parentTabId, 'idle', 1, $position, $now, $now)`, - ).run({ - $id: id, - $title: title, - $keyId: keyId, - $modelId: modelId, - $parentTabId: parentTabId, - $position: position, - $now: now, - }); - return { - id, - title, - keyId, - modelId, - parentTabId, - status: "idle", - isOpen: true, - position, - createdAt: now, - updatedAt: now, - }; -} - -export function getTab(id: string): TabRow | null { - const db = getDatabase(); - const row = db.query("SELECT * FROM tabs WHERE id = $id").get({ $id: id }) as Record< - string, - unknown - > | null; - return row ? rowToTab(row) : null; -} - -export function listOpenTabs(): TabRow[] { - const db = getDatabase(); - const rows = db - .query("SELECT * FROM tabs WHERE is_open = 1 ORDER BY position ASC") - .all() as Array<Record<string, unknown>>; - return rows.map(rowToTab); -} - -export function updateTabTitle(id: string, title: string): void { - const db = getDatabase(); - db.query("UPDATE tabs SET title = $title, updated_at = $now WHERE id = $id").run({ - $id: id, - $title: title, - $now: Date.now(), - }); -} - -export function updateTabModel(id: string, keyId: string | null, modelId: string | null): void { - const db = getDatabase(); - db.query( - "UPDATE tabs SET key_id = $keyId, model_id = $modelId, updated_at = $now WHERE id = $id", - ).run({ - $id: id, - $keyId: keyId, - $modelId: modelId, - $now: Date.now(), - }); -} - -export function updateTabStatus(id: string, status: string): void { - const db = getDatabase(); - db.query("UPDATE tabs SET status = $status, updated_at = $now WHERE id = $id").run({ - $id: id, - $status: status, - $now: Date.now(), - }); -} - -export function updateTabPositions(idsInOrder: string[]): void { - const db = getDatabase(); - const now = Date.now(); - const update = db.query("UPDATE tabs SET position = $position, updated_at = $now WHERE id = $id"); - // One transaction so a reorder is atomic: either every tab lands at its new - // slot or none does, never a half-applied ordering. - const applyAll = db.transaction(() => { - idsInOrder.forEach((id, index) => { - update.run({ $id: id, $position: index, $now: now }); - }); - }); - applyAll(); -} - -export function archiveTab(id: string): void { - const db = getDatabase(); - db.query("UPDATE tabs SET is_open = 0, updated_at = $now WHERE id = $id").run({ - $id: id, - $now: Date.now(), - }); -} - -/** - * Return the IDs of `rootId` plus every OPEN descendant tab, in leaf-first - * order (children before their parent). Archived descendants - * (`is_open = 0`) and their sub-trees are skipped — closing a parent - * shouldn't drag archived branches back into view. - * - * The starting `rootId` is always included in the result, even if no row - * with that id exists in the `tabs` table (graceful handling for stale - * references). - * - * Order matters for the cascade-close path: callers archive descendants - * leaf-first so foreign-key cleanup (messages, etc.) doesn't fail on - * partially-deleted parents. - * - * Cycle-safe: a `visited` set guards against accidental `parent_tab_id` - * loops that would otherwise spin forever. - */ -export function getDescendantIds(rootId: string): string[] { - const db = getDatabase(); - const visited = new Set<string>(); - const order: string[] = []; - const queue: string[] = [rootId]; - while (queue.length > 0) { - const id = queue.shift() as string; - if (visited.has(id)) continue; - visited.add(id); - order.push(id); - const children = db - .query("SELECT id FROM tabs WHERE parent_tab_id = $id AND is_open = 1") - .all({ $id: id }) as Array<{ id: string }>; - for (const child of children) { - if (!visited.has(child.id)) queue.push(child.id); - } - } - return order.reverse(); -} - -/** - * Minimum length of a tab-handle prefix accepted by `resolveTabPrefix`. - * Mirrors the frontend's minimum DISPLAY length (4 hex chars). Anything - * shorter is rejected as too broad — an agent must echo at least the 4-char - * handle shown in the UI. - */ -export const MIN_TAB_PREFIX_LENGTH = 4; - -/** - * Outcome of resolving a short tab handle (a git-style prefix of a tab's - * UUID) back to a concrete open tab. - * - * - `ok` — exactly one open tab matched; `tab` is it. - * - `none` — no open tab matched (bad/stale handle, or too-short prefix). - * - `ambiguous` — more than one open tab shares the prefix; `matches` lists - * them so the caller can ask for one more character (the same - * UX as `git checkout <ambiguous-sha>`). - */ -export type ResolveTabPrefixResult = - | { status: "ok"; tab: TabRow } - | { status: "none" } - | { status: "ambiguous"; matches: TabRow[] }; - -/** - * Resolve a short tab handle to a single OPEN tab by prefix match — the - * git-short-hash model. The handle is NEVER stored: it is always derived from - * (and matched against) the canonical lowercase UUID in `tabs.id`. - * - * Sanitization is mandatory because the SQLite `LIKE` operator treats `%` and - * `_` as wildcards: an unsanitized prefix like `a%` would match broadly. We - * lowercase the input (UUIDs are canonical lowercase; SQLite `LIKE` is also - * ASCII-case-insensitive by default) and strip everything outside the UUID - * alphabet `[0-9a-f-]` so no wildcard can survive into the query. - * - * A prefix shorter than `MIN_TAB_PREFIX_LENGTH` after sanitization returns - * `none` rather than matching a large swath of tabs. - * - * Only OPEN tabs (`is_open = 1`) are addressable — a closed tab's UUID prefix - * must not cause phantom ambiguity or resolve to a dead conversation. - */ -export function resolveTabPrefix(prefix: string): ResolveTabPrefixResult { - const sanitized = (prefix ?? "").toLowerCase().replace(/[^0-9a-f-]/g, ""); - if (sanitized.length < MIN_TAB_PREFIX_LENGTH) { - return { status: "none" }; - } - const db = getDatabase(); - const rows = db - .query("SELECT * FROM tabs WHERE is_open = 1 AND id LIKE $prefix ORDER BY position ASC") - .all({ $prefix: `${sanitized}%` }) as Array<Record<string, unknown>>; - if (rows.length === 0) return { status: "none" }; - if (rows.length === 1) return { status: "ok", tab: rowToTab(rows[0] as Record<string, unknown>) }; - return { status: "ambiguous", matches: rows.map(rowToTab) }; -} - -/** - * Compute the shortest unique prefix (minimum `MIN_TAB_PREFIX_LENGTH` chars) - * that identifies `tabId` among the currently OPEN tabs — the backend twin of - * the frontend's display helper. Used when a tool needs to echo a tab's own - * handle (e.g. provenance prefixes, "available tabs" hints) without trusting a - * value from the wire. - * - * Returns the full id if no shorter unique prefix exists (degenerate — only if - * two open tabs share an entire id, which UUID uniqueness precludes). - */ -export function shortestUniquePrefix(tabId: string): string { - const db = getDatabase(); - const rows = db.query("SELECT id FROM tabs WHERE is_open = 1").all() as Array<{ id: string }>; - const others = rows.map((r) => r.id).filter((id) => id !== tabId); - for (let len = MIN_TAB_PREFIX_LENGTH; len < tabId.length; len++) { - const candidate = tabId.slice(0, len); - if (!others.some((id) => id.startsWith(candidate))) return candidate; - } - return tabId; -} |
