@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.
- package/package.json +1 -1
- package/src/__tests__/agent-loop-callsite-precedence.test.ts +1 -40
- package/src/__tests__/agent-wake-override-profile.test.ts +2 -0
- package/src/__tests__/app-source-watcher.test.ts +30 -10
- package/src/__tests__/config-schema.test.ts +34 -0
- package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +3 -0
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +3 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +3 -0
- package/src/__tests__/conversation-agent-loop.test.ts +3 -0
- package/src/__tests__/conversation-process-callsite.test.ts +0 -14
- package/src/__tests__/db-llm-request-log-provider-migration.test.ts +6 -1
- package/src/__tests__/db-migration-rollback.test.ts +69 -1
- package/src/__tests__/heartbeat-disk-pressure.test.ts +3 -0
- package/src/__tests__/heartbeat-service.test.ts +6 -0
- package/src/__tests__/list-messages-attachments.test.ts +41 -0
- package/src/__tests__/plugin-source-watcher.test.ts +33 -1
- package/src/__tests__/usage-cache-backfill-migration.test.ts +17 -2
- package/src/acp/__tests__/session-manager.test.ts +72 -1
- package/src/acp/index.ts +10 -0
- package/src/acp/session-manager.ts +35 -0
- package/src/agent/loop.ts +28 -22
- package/src/cli/commands/db/status.ts +35 -1
- package/src/config/schemas/memory-lifecycle.ts +5 -3
- package/src/config/schemas/timeouts.ts +24 -0
- package/src/daemon/app-source-watcher.ts +31 -18
- package/src/daemon/conversation-agent-loop.ts +8 -5
- package/src/daemon/conversation.ts +30 -41
- package/src/daemon/handlers/conversations.ts +7 -0
- package/src/daemon/plugin-source-watcher.ts +5 -0
- package/src/daemon/workspace-tools-watcher.ts +4 -0
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +6 -0
- package/src/heartbeat/heartbeat-service.ts +3 -4
- package/src/memory/__tests__/db-maintenance.test.ts +27 -35
- package/src/memory/__tests__/db-memory-attach.test.ts +126 -0
- package/src/memory/conversation-crud.ts +9 -3
- package/src/memory/db-connection.ts +49 -0
- package/src/memory/db-init.ts +50 -14
- package/src/memory/db-maintenance.ts +43 -38
- package/src/memory/job-handlers/cleanup.ts +6 -0
- package/src/memory/migrations/{100-core-tables.ts → 000-core-tables.ts} +6 -10
- package/src/memory/migrations/103-complex-migrations.ts +13 -12
- package/src/memory/migrations/104-core-indexes.ts +1 -1
- package/src/memory/migrations/113-late-migrations.ts +16 -14
- package/src/memory/migrations/297-move-llm-request-logs-to-logs-db.ts +130 -0
- package/src/memory/migrations/__tests__/297-move-llm-request-logs.test.ts +159 -0
- package/src/memory/migrations/__tests__/run-migrations.test.ts +147 -5
- package/src/memory/migrations/index.ts +5 -4
- package/src/memory/migrations/run-migrations.ts +162 -12
- package/src/memory/migrations/validate-migration-state.ts +14 -56
- package/src/plugin-api/index.ts +7 -0
- package/src/plugin-api/vision-support.ts +75 -0
- package/src/prompts/system-prompt.ts +1 -1
- package/src/runtime/__tests__/agent-wake.test.ts +6 -4
- package/src/runtime/agent-wake.ts +15 -7
- package/src/runtime/routes/conversation-routes.ts +24 -3
- package/src/runtime/routes/migration-routes.ts +35 -39
- package/src/schedule/scheduler.ts +5 -9
- package/src/tools/ask-question/ask-question-tool.test.ts +60 -52
- package/src/tools/ask-question/ask-question-tool.ts +14 -73
- package/src/util/fs-watcher-error.ts +36 -0
- 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
|
|
6
|
-
* 1. `runDbMaintenance` runs `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
60
|
-
|
|
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("
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
/**
|
|
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 (
|
|
2144
|
-
|
|
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
|
-
|
|
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;
|
package/src/memory/db-init.ts
CHANGED
|
@@ -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
|
|
301
|
+
// Flush each DB's WAL to its main file before copying.
|
|
282
302
|
getSqlite().exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
306
|
-
recoverCrashedMigrations,
|
|
331
|
+
migrateCoreTables,
|
|
307
332
|
createWatchersAndLogsTables,
|
|
308
333
|
addCoreColumns,
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
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
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
112
|
+
optimizeBackend: optimizeResult.backend,
|
|
108
113
|
optimizeElapsedMs: optimizeResult.elapsedMs,
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
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,
|
|
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
|
|
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,
|