@vellumai/assistant 0.10.0-dev.202606200539.c052d10 → 0.10.0-dev.202606201539.0cebd50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/agent-loop-callsite-precedence.test.ts +1 -40
  3. package/src/__tests__/agent-wake-override-profile.test.ts +2 -0
  4. package/src/__tests__/app-source-watcher.test.ts +30 -10
  5. package/src/__tests__/config-schema.test.ts +34 -0
  6. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +3 -0
  7. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +3 -0
  8. package/src/__tests__/conversation-agent-loop-overflow.test.ts +3 -0
  9. package/src/__tests__/conversation-agent-loop.test.ts +3 -0
  10. package/src/__tests__/conversation-process-callsite.test.ts +0 -14
  11. package/src/__tests__/db-llm-request-log-provider-migration.test.ts +6 -1
  12. package/src/__tests__/db-migration-rollback.test.ts +69 -1
  13. package/src/__tests__/heartbeat-disk-pressure.test.ts +3 -0
  14. package/src/__tests__/heartbeat-service.test.ts +6 -0
  15. package/src/__tests__/list-messages-attachments.test.ts +41 -0
  16. package/src/__tests__/plugin-source-watcher.test.ts +33 -1
  17. package/src/__tests__/usage-cache-backfill-migration.test.ts +17 -2
  18. package/src/acp/__tests__/session-manager.test.ts +72 -1
  19. package/src/acp/index.ts +10 -0
  20. package/src/acp/session-manager.ts +35 -0
  21. package/src/agent/loop.ts +28 -22
  22. package/src/cli/commands/db/status.ts +35 -1
  23. package/src/config/schemas/memory-lifecycle.ts +5 -3
  24. package/src/config/schemas/timeouts.ts +24 -0
  25. package/src/daemon/app-source-watcher.ts +31 -18
  26. package/src/daemon/conversation-agent-loop.ts +8 -5
  27. package/src/daemon/conversation.ts +30 -41
  28. package/src/daemon/handlers/conversations.ts +7 -0
  29. package/src/daemon/plugin-source-watcher.ts +5 -0
  30. package/src/daemon/workspace-tools-watcher.ts +4 -0
  31. package/src/heartbeat/__tests__/heartbeat-service.test.ts +6 -0
  32. package/src/heartbeat/heartbeat-service.ts +3 -4
  33. package/src/memory/__tests__/db-maintenance.test.ts +27 -35
  34. package/src/memory/__tests__/db-memory-attach.test.ts +126 -0
  35. package/src/memory/conversation-crud.ts +9 -3
  36. package/src/memory/db-connection.ts +49 -0
  37. package/src/memory/db-init.ts +50 -14
  38. package/src/memory/db-maintenance.ts +43 -38
  39. package/src/memory/job-handlers/cleanup.ts +6 -0
  40. package/src/memory/migrations/{100-core-tables.ts → 000-core-tables.ts} +6 -10
  41. package/src/memory/migrations/103-complex-migrations.ts +13 -12
  42. package/src/memory/migrations/104-core-indexes.ts +1 -1
  43. package/src/memory/migrations/113-late-migrations.ts +16 -14
  44. package/src/memory/migrations/297-move-llm-request-logs-to-logs-db.ts +130 -0
  45. package/src/memory/migrations/__tests__/297-move-llm-request-logs.test.ts +159 -0
  46. package/src/memory/migrations/__tests__/run-migrations.test.ts +147 -5
  47. package/src/memory/migrations/index.ts +5 -4
  48. package/src/memory/migrations/run-migrations.ts +162 -12
  49. package/src/memory/migrations/validate-migration-state.ts +14 -56
  50. package/src/plugin-api/index.ts +7 -0
  51. package/src/plugin-api/vision-support.ts +75 -0
  52. package/src/prompts/system-prompt.ts +1 -1
  53. package/src/runtime/__tests__/agent-wake.test.ts +6 -4
  54. package/src/runtime/agent-wake.ts +15 -7
  55. package/src/runtime/routes/conversation-routes.ts +24 -3
  56. package/src/runtime/routes/migration-routes.ts +35 -39
  57. package/src/schedule/scheduler.ts +5 -9
  58. package/src/tools/ask-question/ask-question-tool.test.ts +60 -52
  59. package/src/tools/ask-question/ask-question-tool.ts +14 -73
  60. package/src/util/fs-watcher-error.ts +36 -0
  61. package/src/util/memory-db-path.ts +23 -0
@@ -2,14 +2,12 @@
2
2
  * Tests for `db-maintenance.ts` (orchestration) and the underlying
3
3
  * `db-async-query.ts` abstraction.
4
4
  *
5
- * The contract this PR locks in:
6
- * 1. `runDbMaintenance` runs `VACUUM` through the async abstraction
7
- * — when the `sqlite3` CLI is available, that means a subprocess
8
- * and the daemon's main event loop keeps ticking. (The structural
9
- * anti-block assertion lives in
10
- * `db-async-query.test.ts`; here we focus on orchestration.)
11
- * 2. The subprocess actually shrinks the on-disk page count when
12
- * there's reclaimable space.
5
+ * The contract this locks in:
6
+ * 1. `runDbMaintenance` runs `PRAGMA optimize` through the async
7
+ * abstraction and then truncates the WAL on the daemon connection.
8
+ * It intentionally does NOT run a full VACUUM (which in WAL mode
9
+ * inflates the WAL to ~the DB size and needs ~2x the DB size free).
10
+ * 2. The truncating checkpoint shrinks the WAL file once it has grown.
13
11
  * 3. `maybeRunDbMaintenance` is genuinely async — callers can `await`
14
12
  * it and observe completion.
15
13
  * 4. The 24 h interval guard short-circuits a recent re-run.
@@ -17,7 +15,7 @@
17
15
  * The per-file temp workspace is set up by `test-preload.ts`; tests just
18
16
  * dynamic-import the DB modules so they resolve paths under that temp dir.
19
17
  */
20
- import { Database } from "bun:sqlite";
18
+ import { existsSync, statSync } from "node:fs";
21
19
  import { beforeEach, describe, expect, test } from "bun:test";
22
20
 
23
21
  const { getSqlite } = await import("../db-connection.js");
@@ -56,8 +54,9 @@ function insertMessage(role: "user" | "assistant", createdAt: number): void {
56
54
  .run(`msg-${createdAt}-${role}`, convId, role, "[]", createdAt);
57
55
  }
58
56
 
59
- /** Inflate the test DB with bloat that VACUUM can reclaim. */
60
- function inflateAndDelete(byteTarget: number): void {
57
+ /** Pile writes into the WAL (without checkpointing) so a truncating
58
+ * checkpoint has measurable work to do. */
59
+ function inflateWal(byteTarget: number): void {
61
60
  const sqlite = getSqlite();
62
61
  sqlite.exec(
63
62
  "CREATE TABLE IF NOT EXISTS bloat (id INTEGER PRIMARY KEY, payload BLOB)",
@@ -72,11 +71,10 @@ function inflateAndDelete(byteTarget: number): void {
72
71
  for (let i = 0; i < rowsTarget; i++) {
73
72
  insert.run(payload);
74
73
  }
74
+ // Commit, but do not checkpoint — the frames stay in the WAL, leaving it at
75
+ // its high-water mark (a PASSIVE auto-checkpoint resets the WAL for reuse but
76
+ // never shrinks the file). Maintenance is what should truncate it.
75
77
  sqlite.exec("COMMIT");
76
- sqlite.exec("DELETE FROM bloat");
77
- sqlite.exec("DROP TABLE bloat");
78
- // Force the WAL onto the main DB file so the bloat is visible on disk.
79
- sqlite.exec("PRAGMA wal_checkpoint(TRUNCATE)");
80
78
  }
81
79
 
82
80
  describe("maybeRunDbMaintenance", () => {
@@ -106,36 +104,30 @@ describe("maybeRunDbMaintenance", () => {
106
104
  expect(getMemoryCheckpoint(MAINTENANCE_CHECKPOINT_KEY)).toBe(String(now));
107
105
  });
108
106
 
109
- test("VACUUM reclaims pages on a bloated DB", async () => {
107
+ test("truncates a bloated WAL", async () => {
110
108
  const sqlite = getSqlite();
111
109
  sqlite.exec("DROP TABLE IF EXISTS bloat");
112
110
  sqlite.exec("PRAGMA wal_checkpoint(TRUNCATE)");
113
111
 
114
- inflateAndDelete(8 * 1024 * 1024);
115
-
116
- const dbPath = getDbPath();
117
- // Read page_count from a fresh connection so we observe post-write
118
- // ground truth without snapshot caching on the main test connection.
119
- const readPageCount = (): number => {
120
- const probe = new Database(dbPath, { readonly: true });
121
- try {
122
- return (
123
- probe.query("PRAGMA page_count").get() as { page_count: number }
124
- ).page_count;
125
- } finally {
126
- probe.close();
127
- }
128
- };
129
- const pagesBefore = readPageCount();
112
+ const walPath = `${getDbPath()}-wal`;
113
+ const walSize = (): number =>
114
+ existsSync(walPath) ? statSync(walPath).size : 0;
115
+
116
+ inflateWal(4 * 1024 * 1024);
117
+ // Sanity: the writes really did grow the WAL on disk.
118
+ expect(walSize()).toBeGreaterThan(1024 * 1024);
130
119
 
131
120
  await maybeRunDbMaintenance();
132
121
 
133
- const pagesAfter = readPageCount();
134
- expect(pagesAfter).toBeLessThan(pagesBefore);
122
+ // Maintenance truncates the WAL back down (no VACUUM, so the data is folded
123
+ // into the main file rather than rebuilt).
124
+ expect(walSize()).toBeLessThan(64 * 1024);
125
+
126
+ sqlite.exec("DROP TABLE IF EXISTS bloat");
135
127
  }, 60_000);
136
128
 
137
129
  test("defers maintenance while the last user message is within the quiet period", async () => {
138
- /** VACUUM must not fire while the user is active, so a recent user
130
+ /** Maintenance must not fire while the user is active, so a recent user
139
131
  * message keeps maintenance deferred. */
140
132
  // GIVEN the user sent a message one minute ago (well within the quiet period)
141
133
  const now = Date.now();
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Tests for the secondary memory database file wiring (infra PR).
3
+ *
4
+ * What this locks in:
5
+ * 1. Opening the connection ATTACHes `assistant-memory.db` as the `memory`
6
+ * schema and creates the file on disk.
7
+ * 2. The attached schema runs in WAL mode (its own `-wal`, independent of
8
+ * the main DB) — the regression that lets a high-churn table (the
9
+ * `memory_jobs` queue) bloat the *main* WAL is exactly what this split
10
+ * exists to prevent.
11
+ * 3. A table created in the `memory` schema lives in the separate file, not
12
+ * in `main` — proving the physical split rather than just an alias.
13
+ * 4. `runAsyncSqlite({ dbPath })` targets the given file via the sqlite3
14
+ * CLI backend, leaving the main DB untouched.
15
+ */
16
+ import { existsSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { describe, expect, test } from "bun:test";
20
+
21
+ import { removeTestDbFiles } from "../../__tests__/assert-not-live-db.js";
22
+
23
+ const { getSqlite, MEMORY_DB_SCHEMA } = await import("../db-connection.js");
24
+ const { initializeDb } = await import("../db-init.js");
25
+ const { runAsyncSqlite } = await import("../db-async-query.js");
26
+ const { getMemoryDbPath } = await import("../../util/memory-db-path.js");
27
+ const { findSqlite3 } = await import("../../util/sqlite3-runtime.js");
28
+
29
+ initializeDb();
30
+
31
+ const sqlite3Available = findSqlite3() !== undefined;
32
+
33
+ describe("memory database attachment", () => {
34
+ test("ATTACHes the memory file as the memory schema and creates it on disk", () => {
35
+ // Touch the connection so the attach runs.
36
+ getSqlite();
37
+ expect(existsSync(getMemoryDbPath())).toBe(true);
38
+
39
+ const attached = getSqlite()
40
+ .query<{ name: string; file: string }, []>("PRAGMA database_list")
41
+ .all();
42
+ const memoryEntry = attached.find((e) => e.name === MEMORY_DB_SCHEMA);
43
+ expect(memoryEntry).toBeDefined();
44
+ expect(memoryEntry?.file).toBe(getMemoryDbPath());
45
+ });
46
+
47
+ test("the attached schema runs in WAL mode", () => {
48
+ const mode = getSqlite()
49
+ .query<
50
+ { journal_mode: string },
51
+ []
52
+ >(`PRAGMA ${MEMORY_DB_SCHEMA}.journal_mode`)
53
+ .get();
54
+ expect(mode?.journal_mode).toBe("wal");
55
+ });
56
+
57
+ test("a table created in the memory schema lives in the memory file, not main", () => {
58
+ const sqlite = getSqlite();
59
+ sqlite.exec(
60
+ `CREATE TABLE IF NOT EXISTS ${MEMORY_DB_SCHEMA}.attach_probe (id INTEGER PRIMARY KEY)`,
61
+ );
62
+ try {
63
+ const inMemory = sqlite
64
+ .query<
65
+ { name: string },
66
+ []
67
+ >(`SELECT name FROM ${MEMORY_DB_SCHEMA}.sqlite_master WHERE name = 'attach_probe'`)
68
+ .get();
69
+ expect(inMemory?.name).toBe("attach_probe");
70
+
71
+ const inMain = sqlite
72
+ .query<
73
+ { name: string },
74
+ []
75
+ >(`SELECT name FROM main.sqlite_master WHERE name = 'attach_probe'`)
76
+ .get();
77
+ expect(inMain).toBeNull();
78
+ } finally {
79
+ sqlite.exec(`DROP TABLE ${MEMORY_DB_SCHEMA}.attach_probe`);
80
+ }
81
+ });
82
+
83
+ test.if(sqlite3Available)(
84
+ "runAsyncSqlite({ dbPath }) targets the given file, not the main DB",
85
+ async () => {
86
+ const targetPath = join(
87
+ tmpdir(),
88
+ `vellum-async-memory-dbpath-${Date.now()}.db`,
89
+ );
90
+ try {
91
+ const result = await runAsyncSqlite(
92
+ "CREATE TABLE dbpath_probe (x INTEGER); INSERT INTO dbpath_probe VALUES (42); SELECT changes();",
93
+ { dbPath: targetPath, forceBackend: "sqlite3-cli" },
94
+ );
95
+ expect(result.ok).toBe(true);
96
+ expect(result.backend).toBe("sqlite3-cli");
97
+
98
+ // The target file got the table…
99
+ const { Database } = await import("bun:sqlite");
100
+ const probe = new Database(targetPath, { readonly: true });
101
+ try {
102
+ const row = probe
103
+ .query<
104
+ { name: string },
105
+ []
106
+ >("SELECT name FROM sqlite_master WHERE name = 'dbpath_probe'")
107
+ .get();
108
+ expect(row?.name).toBe("dbpath_probe");
109
+ } finally {
110
+ probe.close();
111
+ }
112
+
113
+ // …and the main DB did not.
114
+ const inMain = getSqlite()
115
+ .query<
116
+ { name: string },
117
+ []
118
+ >("SELECT name FROM main.sqlite_master WHERE name = 'dbpath_probe'")
119
+ .get();
120
+ expect(inMain).toBeNull();
121
+ } finally {
122
+ removeTestDbFiles(targetPath);
123
+ }
124
+ },
125
+ );
126
+ });
@@ -37,6 +37,7 @@ import { publishSyncInvalidation } from "../runtime/sync/sync-publisher.js";
37
37
  import { UserError } from "../util/errors.js";
38
38
  import { safeParseRecord } from "../util/json.js";
39
39
  import { getLogger } from "../util/logger.js";
40
+ import { getLogsDbPath } from "../util/logs-db-path.js";
40
41
  import { getConversationsDir } from "../util/platform.js";
41
42
  import { createRowMapper } from "../util/row-mapper.js";
42
43
  import {
@@ -2140,8 +2141,11 @@ export async function clearAll(): Promise<{
2140
2141
  // Each DELETE goes through `runAsyncSqlite`. The original code threw
2141
2142
  // on rawExec failure; mirror that here by throwing when the async
2142
2143
  // result reports `ok: false`, so the route handler still returns 500.
2143
- const runOrThrow = async (sql: string): Promise<void> => {
2144
- const result = await runAsyncSqlite(sql);
2144
+ const runOrThrow = async (
2145
+ sql: string,
2146
+ options?: { dbPath?: string },
2147
+ ): Promise<void> => {
2148
+ const result = await runAsyncSqlite(sql, options);
2145
2149
  if (!result.ok) {
2146
2150
  throw new Error(
2147
2151
  `clearAll: \`${sql}\` failed (${result.backend}): ${result.error ?? "unknown"}`,
@@ -2163,7 +2167,9 @@ export async function clearAll(): Promise<{
2163
2167
  await runOrThrow("DELETE FROM memory_embeddings");
2164
2168
  await runOrThrow("DELETE FROM memory_jobs");
2165
2169
  await runOrThrow("DELETE FROM memory_checkpoints");
2166
- await runOrThrow("DELETE FROM llm_request_logs");
2170
+ // llm_request_logs lives in the attached logs database; point the sqlite3
2171
+ // subprocess at that file (the in-process fallback resolves it via ATTACH).
2172
+ await runOrThrow("DELETE FROM llm_request_logs", { dbPath: getLogsDbPath() });
2167
2173
  await runOrThrow("DELETE FROM llm_usage_events");
2168
2174
  await runOrThrow("DELETE FROM message_attachments");
2169
2175
  await runOrThrow("DELETE FROM attachments");
@@ -6,6 +6,7 @@ import { Database } from "bun:sqlite";
6
6
  import { drizzle } from "drizzle-orm/bun-sqlite";
7
7
 
8
8
  import { getLogsDbPath } from "../util/logs-db-path.js";
9
+ import { getMemoryDbPath } from "../util/memory-db-path.js";
9
10
  import { ensureDataDir, getDbPath } from "../util/platform.js";
10
11
  import { clearStoredDb, getStoredDb, setStoredDb } from "./db-singleton.js";
11
12
  import * as schema from "./schema.js";
@@ -58,6 +59,53 @@ function attachLogsDb(sqlite: Database): void {
58
59
  }
59
60
  }
60
61
 
62
+ /**
63
+ * Schema name under which the secondary memory database file
64
+ * (`assistant-memory.db`) is ATTACHed to the main connection. High-churn memory
65
+ * tables (starting with `memory_jobs`) live in this separate file but remain
66
+ * queryable — and joinable against main-schema tables — over the single daemon
67
+ * connection. Because no such table exists in the `main` schema once a table is
68
+ * relocated, an unqualified reference (e.g. `memory_jobs`) resolves to the
69
+ * attached copy.
70
+ */
71
+ export const MEMORY_DB_SCHEMA = "memory";
72
+
73
+ /**
74
+ * ATTACH the secondary memory database to an open connection and apply its
75
+ * per-database PRAGMAs.
76
+ *
77
+ * `journal_mode` and `synchronous` are per-database settings, so they must be
78
+ * set against the attached schema explicitly — the PRAGMAs run on `main` before
79
+ * the attach do not carry over. `busy_timeout`, `foreign_keys`, `cache_size`,
80
+ * and `temp_store` are connection-wide and already configured on the connection.
81
+ *
82
+ * Best-effort: a failure here (e.g. a filesystem permission problem on the new
83
+ * file) must not take down the main database. We log and continue in line with
84
+ * the daemon's "never block startup on a subsystem failure" policy; relocated
85
+ * memory tables are simply unavailable until the next successful open.
86
+ *
87
+ * The logger is pulled in lazily (dynamic import) only on the error path. This
88
+ * file is intentionally kept import-light — see `db-singleton.ts` — so it does
89
+ * not take a static dependency on the logger (and transitively on pino).
90
+ */
91
+ function attachMemoryDb(sqlite: Database): void {
92
+ const memoryPath = getMemoryDbPath();
93
+ try {
94
+ // Escape single quotes for the SQL string literal (paths can contain them).
95
+ const escaped = memoryPath.replace(/'/g, "''");
96
+ sqlite.exec(`ATTACH DATABASE '${escaped}' AS ${MEMORY_DB_SCHEMA}`);
97
+ sqlite.exec(`PRAGMA ${MEMORY_DB_SCHEMA}.journal_mode=WAL`);
98
+ sqlite.exec(`PRAGMA ${MEMORY_DB_SCHEMA}.synchronous=FULL`);
99
+ } catch (err) {
100
+ void import("../util/logger.js").then(({ getLogger }) =>
101
+ getLogger("db-connection").error(
102
+ { err, memoryPath },
103
+ "Failed to ATTACH memory database; relocated memory tables will be unavailable",
104
+ ),
105
+ );
106
+ }
107
+ }
108
+
61
109
  function canonicalizePathThroughExistingParent(path: string): string {
62
110
  const resolvedPath = resolve(path);
63
111
  const pendingSegments: string[] = [];
@@ -130,6 +178,7 @@ export function getDb(): DrizzleDb {
130
178
  sqlite.exec("PRAGMA cache_size=-256000");
131
179
  sqlite.exec("PRAGMA temp_store=MEMORY");
132
180
  attachLogsDb(sqlite);
181
+ attachMemoryDb(sqlite);
133
182
  const db = drizzle(sqlite, { schema });
134
183
  setStoredDb(db, () => sqlite.close());
135
184
  return db;
@@ -10,12 +10,14 @@ import { tmpdir } from "node:os";
10
10
  import { dirname, join } from "node:path";
11
11
 
12
12
  import { getLogger } from "../util/logger.js";
13
+ import { getLogsDbPath } from "../util/logs-db-path.js";
13
14
  import { ensureDataDir, getDbPath } from "../util/platform.js";
14
15
  import { backfillAppConversationIds } from "./app-store.js";
15
16
  import { getDb, getSqlite } from "./db-connection.js";
16
17
  import { migrateToolCreatedItems } from "./graph/bootstrap.js";
17
18
  import {
18
19
  addCoreColumns,
20
+ complexMigrationSteps,
19
21
  createActivationSessionsTable,
20
22
  createApprovalPromptTsTrackerTable,
21
23
  createAssistantInboxTables,
@@ -26,7 +28,6 @@ import {
26
28
  createContactsAndTriageTables,
27
29
  createConversationAttentionTables,
28
30
  createCoreIndexes,
29
- createCoreTables,
30
31
  createExternalConversationBindingsTables,
31
32
  createFollowupsTables,
32
33
  createLifecycleEventsTable,
@@ -41,6 +42,7 @@ import {
41
42
  createTasksAndWorkItemsTables,
42
43
  createWatchersAndLogsTables,
43
44
  dropApprovalPromptTsTrackerTable,
45
+ lateMigrationSteps,
44
46
  migrate230AcpSessionHistory,
45
47
  migrate231RepairMemoryGraphEventDates,
46
48
  migrateA2ATasks,
@@ -86,6 +88,7 @@ import {
86
88
  migrateConversationsLastMessageAt,
87
89
  migrateConversationsSurfacedAt,
88
90
  migrateConversationsThreadTypeIndex,
91
+ migrateCoreTables,
89
92
  migrateCreateConversationGraphMemoryState,
90
93
  migrateCreateDocumentComments,
91
94
  migrateCreateDocumentConversations,
@@ -154,6 +157,7 @@ import {
154
157
  migrateMessagesConversationCreatedAtIndex,
155
158
  migrateMessagesFtsBackfill,
156
159
  migrateMessagesRoleCreatedAtIndex,
160
+ migrateMoveLlmRequestLogsToLogsDb,
157
161
  migrateNormalizePhoneIdentities,
158
162
  migrateNormalizeSlackExternalContent,
159
163
  migrateNormalizeUserFileByPrincipal,
@@ -230,9 +234,6 @@ import {
230
234
  migrateWorkflowJournalLeafTokens,
231
235
  migrateWorkflowRuns,
232
236
  migrateWorkflowRunTrust,
233
- recoverCrashedMigrations,
234
- runComplexMigrations,
235
- runLateMigrations,
236
237
  validateMigrationState,
237
238
  } from "./migrations/index.js";
238
239
  import { runMigrationSteps } from "./migrations/run-migrations.js";
@@ -265,12 +266,31 @@ function getTemplateDbPath(): string {
265
266
  );
266
267
  }
267
268
 
269
+ /**
270
+ * Template path for the attached `logs` database, kept alongside the main
271
+ * template. Both files must be captured/restored together: the migrated state
272
+ * now spans two files (llm_request_logs and its indexes live in `logs`), so
273
+ * restoring only the main DB would leave a fresh, empty logs DB with no
274
+ * `llm_request_logs` table.
275
+ */
276
+ function getLogsTemplateDbPath(): string {
277
+ return `${getTemplateDbPath()}.logs`;
278
+ }
279
+
268
280
  function tryRestoreTemplate(): boolean {
269
281
  const templatePath = getTemplateDbPath();
270
282
  if (!existsSync(templatePath)) return false;
271
283
  // getDb() hasn't run yet, so the data directory may not exist.
272
284
  ensureDataDir();
273
285
  copyFileSync(templatePath, getDbPath());
286
+ // Restore the attached logs DB before getDb() opens (and ATTACHes) it, so the
287
+ // relocated llm_request_logs table is present. Older templates may predate
288
+ // the split; the hash includes the migration files, so a stale template
289
+ // without this sibling won't be reused — but guard anyway.
290
+ const logsTemplate = getLogsTemplateDbPath();
291
+ if (existsSync(logsTemplate)) {
292
+ copyFileSync(logsTemplate, getLogsDbPath());
293
+ }
274
294
  // Open the pre-migrated copy — getDb() will set PRAGMAs but skip migrations.
275
295
  getDb();
276
296
  return true;
@@ -278,12 +298,18 @@ function tryRestoreTemplate(): boolean {
278
298
 
279
299
  function saveTemplate(): void {
280
300
  try {
281
- // Flush WAL to main DB file before copying.
301
+ // Flush each DB's WAL to its main file before copying.
282
302
  getSqlite().exec("PRAGMA wal_checkpoint(TRUNCATE)");
283
- const tmpFile = `${getTemplateDbPath()}.${process.pid}`;
284
- copyFileSync(getDbPath(), tmpFile);
285
- // Atomic rename — safe even with parallel test workers.
286
- renameSync(tmpFile, getTemplateDbPath());
303
+ getSqlite().exec("PRAGMA logs.wal_checkpoint(TRUNCATE)");
304
+
305
+ const mainTmp = `${getTemplateDbPath()}.${process.pid}`;
306
+ copyFileSync(getDbPath(), mainTmp);
307
+ const logsTmp = `${getLogsTemplateDbPath()}.${process.pid}`;
308
+ copyFileSync(getLogsDbPath(), logsTmp);
309
+
310
+ // Atomic renames — safe even with parallel test workers.
311
+ renameSync(mainTmp, getTemplateDbPath());
312
+ renameSync(logsTmp, getLogsTemplateDbPath());
287
313
  } catch {
288
314
  // Best effort — next file will just run migrations normally.
289
315
  }
@@ -302,11 +328,10 @@ export function initializeDb(): void {
302
328
  // Every migration step, in execution order. Each function accepts a
303
329
  // DrizzleDb and is identified by its .name.
304
330
  const migrationSteps = [
305
- createCoreTables,
306
- recoverCrashedMigrations,
331
+ migrateCoreTables,
307
332
  createWatchersAndLogsTables,
308
333
  addCoreColumns,
309
- runComplexMigrations,
334
+ ...complexMigrationSteps,
310
335
  createCoreIndexes,
311
336
  createContactsAndTriageTables,
312
337
  createCallSessionsTables,
@@ -320,7 +345,7 @@ export function initializeDb(): void {
320
345
  migrateGuardianVerificationPurpose,
321
346
  createMediaAssetsTables,
322
347
  createAssistantInboxTables,
323
- runLateMigrations,
348
+ ...lateMigrationSteps,
324
349
  migrateChannelInboundDeliveredSegments,
325
350
  migrateGuardianActionFollowup,
326
351
  migrateGuardianActionToolMetadata,
@@ -531,11 +556,22 @@ export function initializeDb(): void {
531
556
  migrateDropExternalUserId,
532
557
  dropApprovalPromptTsTrackerTable,
533
558
  migrateRewriteBalancedEconomyProfilePins,
559
+ migrateMoveLlmRequestLogsToLogsDb,
534
560
  ];
535
561
 
536
562
  // Run each migration step, catching and logging individual failures so one
537
563
  // broken migration doesn't prevent independent later ones from succeeding.
538
- const { failed } = runMigrationSteps(database, migrationSteps);
564
+ // The runner creates the checkpoint ledger, recovers crashed migrations, then
565
+ // records each step so an already-migrated database skips it on later boots.
566
+ const { failed, skipped } = runMigrationSteps(database, migrationSteps);
567
+
568
+ log.debug(
569
+ {
570
+ ranCount: migrationSteps.length - skipped.length,
571
+ skipped: skipped.length,
572
+ },
573
+ `DB migration steps complete (${skipped.length} skipped via checkpoint)`,
574
+ );
539
575
 
540
576
  if (failed.length > 0) {
541
577
  log.error(
@@ -58,11 +58,10 @@ async function runDbMaintenance(): Promise<void> {
58
58
  );
59
59
 
60
60
  // Prune finished workflow runs (and their journals) past the retention
61
- // window BEFORE VACUUM so the freed pages are reclaimed in the same pass.
62
- // This is a fast bounded DELETE on the small workflow tables, so it runs on
63
- // the main connection (`rawRun`) — unlike VACUUM/optimize it doesn't need the
64
- // async/subprocess path. The maintenance scheduler below already defers this
65
- // whole routine to an idle window.
61
+ // window. This is a fast bounded DELETE on the small workflow tables, so it
62
+ // runs on the main connection (`rawRun`). SQLite reuses the pages it frees
63
+ // for later writes — we deliberately do not VACUUM to hand them back to the
64
+ // OS (see the WAL note below).
66
65
  try {
67
66
  const deletedRuns = pruneRuns(getConfig().workflows.journalRetentionDays);
68
67
  if (deletedRuns > 0) {
@@ -72,18 +71,9 @@ async function runDbMaintenance(): Promise<void> {
72
71
  log.warn({ err }, "Workflow run pruning failed (non-fatal)");
73
72
  }
74
73
 
75
- // VACUUM is the long-running one — minutes on a multi-GB DB. PRAGMA
76
- // optimize is fast but routed through the same async path for
77
- // consistency and to keep both off the main thread when the CLI
78
- // backend is available.
79
- const vacuumResult = await runAsyncSqlite("VACUUM");
80
- if (!vacuumResult.ok) {
81
- log.warn(
82
- { error: vacuumResult.error, backend: vacuumResult.backend },
83
- "VACUUM failed (non-fatal)",
84
- );
85
- }
86
-
74
+ // Refresh the query planner's statistics. PRAGMA optimize is cheap; it is
75
+ // routed through the async path for consistency and to keep it off the main
76
+ // thread when the sqlite3 CLI backend is available.
87
77
  const optimizeResult = await runAsyncSqlite("PRAGMA optimize");
88
78
  if (!optimizeResult.ok) {
89
79
  log.warn(
@@ -92,26 +82,42 @@ async function runDbMaintenance(): Promise<void> {
92
82
  );
93
83
  }
94
84
 
95
- const after = getDbStats();
96
- const reclaimedPages = before.pageCount - after.pageCount;
97
- const reclaimedBytes =
98
- before.fileSizeBytes != null && after.fileSizeBytes != null
99
- ? before.fileSizeBytes - after.fileSizeBytes
100
- : null;
85
+ // Truncate the WAL so it doesn't sit at its high-water mark. We intentionally
86
+ // do NOT run a full VACUUM: in WAL mode VACUUM rewrites the whole database
87
+ // through the WAL, inflating it to ~the database size and needing up to 2x the
88
+ // DB size in free disk to finish. SQLite already reuses freed pages for new
89
+ // writes, so eager space return isn't worth that cost on a multi-GB database.
90
+ //
91
+ // The checkpoint goes through the async path (sqlite3 subprocess when one is
92
+ // available) for the same reason VACUUM/optimize do: a synchronous
93
+ // wal_checkpoint(TRUNCATE) on the shared connection blocks the event loop
94
+ // while it checkpoints frames and waits out readers — the health/IPC stall
95
+ // runAsyncSqlite exists to avoid. A checkpoint from a separate connection
96
+ // still truncates the shared WAL; if a reader holds it back it's a
97
+ // best-effort no-op and the next maintenance pass retries.
98
+ const checkpointResult = await runAsyncSqlite(
99
+ "PRAGMA wal_checkpoint(TRUNCATE)",
100
+ );
101
+ if (!checkpointResult.ok) {
102
+ log.warn(
103
+ { error: checkpointResult.error, backend: checkpointResult.backend },
104
+ "WAL checkpoint failed (non-fatal)",
105
+ );
106
+ }
101
107
 
108
+ const after = getDbStats();
102
109
  log.info(
103
110
  {
104
- backend: vacuumResult.backend,
105
- vacuumOk: vacuumResult.ok,
106
111
  optimizeOk: optimizeResult.ok,
107
- vacuumElapsedMs: vacuumResult.elapsedMs,
112
+ optimizeBackend: optimizeResult.backend,
108
113
  optimizeElapsedMs: optimizeResult.elapsedMs,
109
- beforePageCount: before.pageCount,
110
- afterPageCount: after.pageCount,
111
- reclaimedPages,
112
- beforeFileSizeBytes: before.fileSizeBytes,
113
- afterFileSizeBytes: after.fileSizeBytes,
114
- reclaimedBytes,
114
+ checkpointOk: checkpointResult.ok,
115
+ checkpointBackend: checkpointResult.backend,
116
+ checkpointResult: checkpointResult.stdout?.trim(),
117
+ checkpointElapsedMs: checkpointResult.elapsedMs,
118
+ pageCount: after.pageCount,
119
+ freelistCount: after.freelistCount,
120
+ fileSizeBytes: after.fileSizeBytes,
115
121
  },
116
122
  "Database maintenance complete",
117
123
  );
@@ -126,12 +132,11 @@ export async function maybeRunDbMaintenance(nowMs = Date.now()): Promise<void> {
126
132
  );
127
133
  if (nowMs - lastRun < intervalMs) return;
128
134
 
129
- // VACUUM holds an exclusive lock on the database for its full duration —
130
- // minutes on a multi-GB DB — during which every other write fails with
131
- // SQLITE_BUSY ("database is locked"). Defer maintenance until the user has
132
- // been quiet for `quietPeriodMs` so that lock never lands mid-conversation.
133
- // The checkpoint below is only written once maintenance actually runs, so a
134
- // deferred run is simply retried on a later (still-idle) worker tick.
135
+ // Maintenance still takes brief write locks (PRAGMA optimize and the
136
+ // truncating WAL checkpoint), so defer it until the user has been quiet for
137
+ // `quietPeriodMs` and those locks never land mid-conversation. The checkpoint
138
+ // below is only written once maintenance actually runs, so a deferred run is
139
+ // simply retried on a later (still-idle) worker tick.
135
140
  if (quietPeriodMs > 0) {
136
141
  const lastUserMessageAt = getLastUserMessageTimestamp();
137
142
  if (lastUserMessageAt > 0 && nowMs - lastUserMessageAt < quietPeriodMs) {
@@ -1,5 +1,6 @@
1
1
  import type { AssistantConfig } from "../../config/types.js";
2
2
  import { getLogger } from "../../util/logger.js";
3
+ import { getLogsDbPath } from "../../util/logs-db-path.js";
3
4
  import { runAsyncSqlite } from "../db-async-query.js";
4
5
  import { getDb } from "../db-connection.js";
5
6
  import { enqueueMemoryJob, type MemoryJob } from "../jobs-store.js";
@@ -49,9 +50,14 @@ export async function pruneOldLlmRequestLogsJob(
49
50
  // fallback backend in `db-async-query.ts` synthesizes the same shape
50
51
  // by capturing `changes()` atomically after `exec()`. Both backends
51
52
  // end up on the parser path below.
53
+ // llm_request_logs lives in the attached logs database. Point the sqlite3
54
+ // subprocess at that file directly (it can't see the daemon connection's
55
+ // ATTACH); the in-process fallback runs on the daemon connection, where the
56
+ // unqualified name already resolves to the attached table.
52
57
  const result = await runAsyncSqlite(
53
58
  `DELETE FROM llm_request_logs WHERE rowid IN (SELECT rowid FROM llm_request_logs WHERE created_at < ${cutoffMs} LIMIT ${PRUNE_LOG_BATCH_LIMIT});
54
59
  SELECT changes();`,
60
+ { dbPath: getLogsDbPath() },
55
61
  );
56
62
  if (!result.ok) {
57
63
  log.warn(
@@ -3,12 +3,16 @@ import type { DrizzleDb } from "../db-connection.js";
3
3
  /**
4
4
  * Core tables: conversations, messages, tool_invocations, memory_segments,
5
5
  * memory_items, memory_item_sources, memory_item_conflicts, memory_summaries,
6
- * memory_embeddings, memory_jobs, memory_checkpoints, attachments,
6
+ * memory_embeddings, memory_jobs, attachments,
7
7
  * message_attachments, message_surfaces, channel_inbound_events,
8
8
  * message_runs, cron_jobs, cron_runs, documents,
9
9
  * published_pages, shared_app_links, home_base_app_links.
10
+ *
11
+ * The migration bookkeeping table `memory_checkpoints` is created by the
12
+ * migration runner (`runMigrationSteps`) before any step runs, so it is not
13
+ * created here.
10
14
  */
11
- export function createCoreTables(database: DrizzleDb): void {
15
+ export function migrateCoreTables(database: DrizzleDb): void {
12
16
  database.run(/*sql*/ `
13
17
  CREATE TABLE IF NOT EXISTS conversations (
14
18
  id TEXT PRIMARY KEY,
@@ -150,14 +154,6 @@ export function createCoreTables(database: DrizzleDb): void {
150
154
  )
151
155
  `);
152
156
 
153
- database.run(/*sql*/ `
154
- CREATE TABLE IF NOT EXISTS memory_checkpoints (
155
- key TEXT PRIMARY KEY,
156
- value TEXT NOT NULL,
157
- updated_at INTEGER NOT NULL
158
- )
159
- `);
160
-
161
157
  database.run(/*sql*/ `
162
158
  CREATE TABLE IF NOT EXISTS attachments (
163
159
  id TEXT PRIMARY KEY,