@castlekit/castle 0.1.5 → 0.3.0
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/drizzle.config.ts +7 -0
- package/next.config.ts +1 -0
- package/package.json +25 -4
- package/src/app/api/avatars/[id]/route.ts +122 -25
- package/src/app/api/openclaw/agents/[id]/avatar/route.ts +216 -0
- package/src/app/api/openclaw/agents/route.ts +77 -41
- package/src/app/api/openclaw/agents/status/route.ts +55 -0
- package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
- package/src/app/api/openclaw/chat/channels/route.ts +214 -0
- package/src/app/api/openclaw/chat/route.ts +272 -0
- package/src/app/api/openclaw/chat/search/route.ts +149 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/config/route.ts +45 -4
- package/src/app/api/openclaw/events/route.ts +31 -2
- package/src/app/api/openclaw/logs/route.ts +20 -5
- package/src/app/api/openclaw/restart/route.ts +12 -4
- package/src/app/api/openclaw/session/status/route.ts +42 -0
- package/src/app/api/settings/avatar/route.ts +190 -0
- package/src/app/api/settings/route.ts +88 -0
- package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
- package/src/app/chat/[channelId]/page.tsx +305 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +89 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +147 -28
- package/src/app/settings/page.tsx +300 -0
- package/src/cli/onboarding.ts +202 -37
- package/src/components/chat/agent-mention-popup.tsx +89 -0
- package/src/components/chat/archived-channels.tsx +190 -0
- package/src/components/chat/channel-list.tsx +140 -0
- package/src/components/chat/chat-input.tsx +310 -0
- package/src/components/chat/create-channel-dialog.tsx +171 -0
- package/src/components/chat/markdown-content.tsx +205 -0
- package/src/components/chat/message-bubble.tsx +152 -0
- package/src/components/chat/message-list.tsx +508 -0
- package/src/components/chat/message-queue.tsx +68 -0
- package/src/components/chat/session-divider.tsx +61 -0
- package/src/components/chat/session-stats-panel.tsx +139 -0
- package/src/components/chat/storage-indicator.tsx +76 -0
- package/src/components/layout/sidebar.tsx +126 -45
- package/src/components/layout/user-menu.tsx +29 -4
- package/src/components/providers/presence-provider.tsx +8 -0
- package/src/components/providers/search-provider.tsx +81 -0
- package/src/components/search/search-dialog.tsx +269 -0
- package/src/components/ui/avatar.tsx +11 -9
- package/src/components/ui/dialog.tsx +10 -4
- package/src/components/ui/tooltip.tsx +25 -8
- package/src/components/ui/twemoji-text.tsx +37 -0
- package/src/lib/api-security.ts +188 -0
- package/src/lib/config.ts +36 -4
- package/src/lib/date-utils.ts +79 -0
- package/src/lib/db/__tests__/queries.test.ts +318 -0
- package/src/lib/db/index.ts +642 -0
- package/src/lib/db/queries.ts +1017 -0
- package/src/lib/db/schema.ts +160 -0
- package/src/lib/device-identity.ts +303 -0
- package/src/lib/gateway-connection.ts +273 -36
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +775 -0
- package/src/lib/hooks/use-openclaw.ts +105 -70
- package/src/lib/hooks/use-search.ts +113 -0
- package/src/lib/hooks/use-session-stats.ts +57 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/types/chat.ts +186 -0
- package/src/lib/types/search.ts +60 -0
- package/src/middleware.ts +52 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import {
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
chmodSync,
|
|
8
|
+
copyFileSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
statSync,
|
|
12
|
+
} from "fs";
|
|
13
|
+
import { platform } from "os";
|
|
14
|
+
import { getCastleDir } from "@/lib/config";
|
|
15
|
+
import * as schema from "./schema";
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Constants
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
/** Maximum number of backup files to keep */
|
|
22
|
+
const MAX_BACKUPS = 5;
|
|
23
|
+
|
|
24
|
+
/** How often to checkpoint WAL (ms) — every 5 minutes */
|
|
25
|
+
const CHECKPOINT_INTERVAL_MS = 5 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
/** Current schema version — bump when adding new migrations */
|
|
28
|
+
const SCHEMA_VERSION = 5;
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// Singleton
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
const DB_KEY = "__castle_db__" as const;
|
|
35
|
+
const SQLITE_KEY = "__castle_sqlite__" as const;
|
|
36
|
+
const DB_MIGRATED_KEY = "__castle_db_migrated__" as const;
|
|
37
|
+
const CHECKPOINT_TIMER_KEY = "__castle_checkpoint_timer__" as const;
|
|
38
|
+
const SHUTDOWN_REGISTERED_KEY = "__castle_shutdown_registered__" as const;
|
|
39
|
+
|
|
40
|
+
interface GlobalWithDb {
|
|
41
|
+
[DB_KEY]?: ReturnType<typeof drizzle>;
|
|
42
|
+
[SQLITE_KEY]?: InstanceType<typeof Database>;
|
|
43
|
+
[DB_MIGRATED_KEY]?: number;
|
|
44
|
+
[CHECKPOINT_TIMER_KEY]?: ReturnType<typeof setInterval>;
|
|
45
|
+
[SHUTDOWN_REGISTERED_KEY]?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getGlobalDb(): ReturnType<typeof drizzle> | undefined {
|
|
49
|
+
return (globalThis as unknown as GlobalWithDb)[DB_KEY];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getGlobalSqlite(): InstanceType<typeof Database> | undefined {
|
|
53
|
+
return (globalThis as unknown as GlobalWithDb)[SQLITE_KEY];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function setGlobals(
|
|
57
|
+
db: ReturnType<typeof drizzle>,
|
|
58
|
+
sqlite: InstanceType<typeof Database>
|
|
59
|
+
): void {
|
|
60
|
+
(globalThis as unknown as GlobalWithDb)[DB_KEY] = db;
|
|
61
|
+
(globalThis as unknown as GlobalWithDb)[SQLITE_KEY] = sqlite;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getMigratedVersion(): number {
|
|
65
|
+
return (globalThis as unknown as GlobalWithDb)[DB_MIGRATED_KEY] ?? 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function setMigratedVersion(v: number): void {
|
|
69
|
+
(globalThis as unknown as GlobalWithDb)[DB_MIGRATED_KEY] = v;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getCheckpointTimer(): ReturnType<typeof setInterval> | undefined {
|
|
73
|
+
return (globalThis as unknown as GlobalWithDb)[CHECKPOINT_TIMER_KEY];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function setCheckpointTimer(timer: ReturnType<typeof setInterval>): void {
|
|
77
|
+
(globalThis as unknown as GlobalWithDb)[CHECKPOINT_TIMER_KEY] = timer;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isShutdownRegistered(): boolean {
|
|
81
|
+
return (globalThis as unknown as GlobalWithDb)[SHUTDOWN_REGISTERED_KEY] ?? false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function setShutdownRegistered(): void {
|
|
85
|
+
(globalThis as unknown as GlobalWithDb)[SHUTDOWN_REGISTERED_KEY] = true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// Backup & Recovery
|
|
90
|
+
// ============================================================================
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Create a timestamped backup of the database file.
|
|
94
|
+
* Rotates old backups to keep only MAX_BACKUPS.
|
|
95
|
+
* Returns the backup path on success, null on failure.
|
|
96
|
+
*/
|
|
97
|
+
function backupDatabase(dbPath: string, reason: string): string | null {
|
|
98
|
+
if (!existsSync(dbPath)) return null;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const backupDir = join(getCastleDir(), "data", "backups");
|
|
102
|
+
if (!existsSync(backupDir)) {
|
|
103
|
+
mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
107
|
+
const backupPath = join(backupDir, `castle-${timestamp}.db`);
|
|
108
|
+
|
|
109
|
+
copyFileSync(dbPath, backupPath);
|
|
110
|
+
|
|
111
|
+
// Also copy WAL if it exists (contains uncommitted data)
|
|
112
|
+
const walPath = `${dbPath}-wal`;
|
|
113
|
+
if (existsSync(walPath)) {
|
|
114
|
+
copyFileSync(walPath, `${backupPath}-wal`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
console.log(`[Castle DB] Backup created (${reason}): ${backupPath}`);
|
|
118
|
+
|
|
119
|
+
// Rotate old backups
|
|
120
|
+
rotateBackups(backupDir);
|
|
121
|
+
|
|
122
|
+
return backupPath;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.error(`[Castle DB] Backup failed (${reason}):`, (err as Error).message);
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Keep only the newest MAX_BACKUPS backup files. Deletes oldest first.
|
|
131
|
+
*/
|
|
132
|
+
function rotateBackups(backupDir: string): void {
|
|
133
|
+
try {
|
|
134
|
+
const files = readdirSync(backupDir)
|
|
135
|
+
.filter((f) => f.startsWith("castle-") && f.endsWith(".db"))
|
|
136
|
+
.map((f) => ({
|
|
137
|
+
name: f,
|
|
138
|
+
path: join(backupDir, f),
|
|
139
|
+
mtime: statSync(join(backupDir, f)).mtimeMs,
|
|
140
|
+
}))
|
|
141
|
+
.sort((a, b) => b.mtime - a.mtime); // newest first
|
|
142
|
+
|
|
143
|
+
// Remove excess backups (and their WAL files)
|
|
144
|
+
for (const file of files.slice(MAX_BACKUPS)) {
|
|
145
|
+
try {
|
|
146
|
+
unlinkSync(file.path);
|
|
147
|
+
const wal = `${file.path}-wal`;
|
|
148
|
+
if (existsSync(wal)) unlinkSync(wal);
|
|
149
|
+
console.log(`[Castle DB] Rotated old backup: ${file.name}`);
|
|
150
|
+
} catch {
|
|
151
|
+
// non-critical
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// non-critical
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ============================================================================
|
|
160
|
+
// WAL Checkpoint
|
|
161
|
+
// ============================================================================
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Checkpoint the WAL — flushes all WAL data into the main database file.
|
|
165
|
+
* This is critical for crash safety: data in the WAL but not in the main
|
|
166
|
+
* DB file can be lost if the process is killed with SIGKILL (kill -9).
|
|
167
|
+
*
|
|
168
|
+
* PASSIVE mode: checkpoints without blocking readers/writers.
|
|
169
|
+
* TRUNCATE mode: checkpoints and truncates WAL to zero size (used on shutdown).
|
|
170
|
+
*/
|
|
171
|
+
function checkpointWal(
|
|
172
|
+
sqlite: InstanceType<typeof Database>,
|
|
173
|
+
mode: "PASSIVE" | "TRUNCATE" = "PASSIVE"
|
|
174
|
+
): void {
|
|
175
|
+
try {
|
|
176
|
+
const result = sqlite.pragma(`wal_checkpoint(${mode})`) as {
|
|
177
|
+
busy: number;
|
|
178
|
+
checkpointed: number;
|
|
179
|
+
log: number;
|
|
180
|
+
}[];
|
|
181
|
+
if (result?.[0]) {
|
|
182
|
+
const { busy, checkpointed, log } = result[0];
|
|
183
|
+
if (log > 0) {
|
|
184
|
+
console.log(
|
|
185
|
+
`[Castle DB] WAL checkpoint (${mode}): ${checkpointed}/${log} pages flushed${
|
|
186
|
+
busy ? " (some pages busy)" : ""
|
|
187
|
+
}`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
console.error("[Castle DB] WAL checkpoint failed:", (err as Error).message);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Start periodic WAL checkpoints. The WAL is the main risk surface for
|
|
198
|
+
* data loss on crash — periodic checkpoints minimize how much unflushed
|
|
199
|
+
* data can be lost.
|
|
200
|
+
*/
|
|
201
|
+
function startPeriodicCheckpoint(sqlite: InstanceType<typeof Database>): void {
|
|
202
|
+
// Don't double-register
|
|
203
|
+
const existing = getCheckpointTimer();
|
|
204
|
+
if (existing) return;
|
|
205
|
+
|
|
206
|
+
const timer = setInterval(() => {
|
|
207
|
+
try {
|
|
208
|
+
if (sqlite.open) {
|
|
209
|
+
checkpointWal(sqlite, "PASSIVE");
|
|
210
|
+
}
|
|
211
|
+
} catch {
|
|
212
|
+
// DB might have been closed
|
|
213
|
+
}
|
|
214
|
+
}, CHECKPOINT_INTERVAL_MS);
|
|
215
|
+
|
|
216
|
+
// Don't prevent Node from exiting
|
|
217
|
+
if (timer.unref) timer.unref();
|
|
218
|
+
setCheckpointTimer(timer);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ============================================================================
|
|
222
|
+
// Graceful Shutdown
|
|
223
|
+
// ============================================================================
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Register process signal handlers to checkpoint WAL before exit.
|
|
227
|
+
* This ensures data is flushed to the main DB file on clean shutdown.
|
|
228
|
+
* Note: SIGKILL (kill -9) cannot be caught — that's why we also
|
|
229
|
+
* checkpoint periodically and on startup.
|
|
230
|
+
*/
|
|
231
|
+
function registerShutdownHandlers(sqlite: InstanceType<typeof Database>): void {
|
|
232
|
+
if (isShutdownRegistered()) return;
|
|
233
|
+
setShutdownRegistered();
|
|
234
|
+
|
|
235
|
+
const shutdown = (signal: string) => {
|
|
236
|
+
console.log(`[Castle DB] ${signal} received — checkpointing WAL...`);
|
|
237
|
+
try {
|
|
238
|
+
if (sqlite.open) {
|
|
239
|
+
checkpointWal(sqlite, "TRUNCATE");
|
|
240
|
+
sqlite.close();
|
|
241
|
+
console.log("[Castle DB] Database closed cleanly");
|
|
242
|
+
}
|
|
243
|
+
} catch (err) {
|
|
244
|
+
console.error("[Castle DB] Shutdown checkpoint failed:", (err as Error).message);
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
process.on("SIGTERM", () => {
|
|
249
|
+
shutdown("SIGTERM");
|
|
250
|
+
process.exit(0);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
process.on("SIGINT", () => {
|
|
254
|
+
shutdown("SIGINT");
|
|
255
|
+
process.exit(0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// beforeExit fires when the event loop is empty (clean exit)
|
|
259
|
+
process.on("beforeExit", () => {
|
|
260
|
+
shutdown("beforeExit");
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ============================================================================
|
|
265
|
+
// Integrity Check
|
|
266
|
+
// ============================================================================
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Run a quick integrity check on the database.
|
|
270
|
+
* Returns true if the DB passes, false if corrupted.
|
|
271
|
+
*/
|
|
272
|
+
function checkIntegrity(sqlite: InstanceType<typeof Database>): boolean {
|
|
273
|
+
try {
|
|
274
|
+
const result = sqlite.pragma("quick_check") as { quick_check: string }[];
|
|
275
|
+
const ok = result?.[0]?.quick_check === "ok";
|
|
276
|
+
if (!ok) {
|
|
277
|
+
console.error(
|
|
278
|
+
"[Castle DB] INTEGRITY CHECK FAILED:",
|
|
279
|
+
result?.map((r) => r.quick_check).join(", ")
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
return ok;
|
|
283
|
+
} catch (err) {
|
|
284
|
+
console.error("[Castle DB] Integrity check error:", (err as Error).message);
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ============================================================================
|
|
290
|
+
// FTS5 virtual table SQL (run raw — Drizzle doesn't support virtual tables)
|
|
291
|
+
// ============================================================================
|
|
292
|
+
|
|
293
|
+
// FTS5 setup: standalone table (not external content) synced via triggers.
|
|
294
|
+
// IMPORTANT: Do NOT drop/recreate on every startup — that causes "SQL logic error"
|
|
295
|
+
// when triggers try to delete old FTS5 entries that no longer exist after the drop.
|
|
296
|
+
const FTS5_CREATE = `
|
|
297
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts
|
|
298
|
+
USING fts5(content, tokenize='unicode61');
|
|
299
|
+
|
|
300
|
+
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
|
|
301
|
+
INSERT INTO messages_fts(rowid, content) VALUES (NEW.rowid, NEW.content);
|
|
302
|
+
END;
|
|
303
|
+
|
|
304
|
+
CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
|
|
305
|
+
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', OLD.rowid, OLD.content);
|
|
306
|
+
END;
|
|
307
|
+
|
|
308
|
+
CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE OF content ON messages BEGIN
|
|
309
|
+
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', OLD.rowid, OLD.content);
|
|
310
|
+
INSERT INTO messages_fts(rowid, content) VALUES (NEW.rowid, NEW.content);
|
|
311
|
+
END;
|
|
312
|
+
`;
|
|
313
|
+
|
|
314
|
+
// ============================================================================
|
|
315
|
+
// Schema creation SQL (generated from Drizzle schema)
|
|
316
|
+
// We use push-based migration — creates tables if they don't exist.
|
|
317
|
+
// ============================================================================
|
|
318
|
+
|
|
319
|
+
const TABLE_SQL = `
|
|
320
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
321
|
+
key TEXT PRIMARY KEY,
|
|
322
|
+
value TEXT NOT NULL,
|
|
323
|
+
updated_at INTEGER NOT NULL
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
CREATE TABLE IF NOT EXISTS channels (
|
|
327
|
+
id TEXT PRIMARY KEY,
|
|
328
|
+
name TEXT NOT NULL,
|
|
329
|
+
default_agent_id TEXT NOT NULL,
|
|
330
|
+
created_at INTEGER NOT NULL,
|
|
331
|
+
updated_at INTEGER,
|
|
332
|
+
last_accessed_at INTEGER,
|
|
333
|
+
archived_at INTEGER
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
CREATE TABLE IF NOT EXISTS agent_statuses (
|
|
337
|
+
agent_id TEXT PRIMARY KEY,
|
|
338
|
+
status TEXT NOT NULL DEFAULT 'idle',
|
|
339
|
+
updated_at INTEGER NOT NULL
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
CREATE TABLE IF NOT EXISTS channel_agents (
|
|
343
|
+
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
344
|
+
agent_id TEXT NOT NULL,
|
|
345
|
+
PRIMARY KEY (channel_id, agent_id)
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
349
|
+
id TEXT PRIMARY KEY,
|
|
350
|
+
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
351
|
+
session_key TEXT,
|
|
352
|
+
started_at INTEGER NOT NULL,
|
|
353
|
+
ended_at INTEGER,
|
|
354
|
+
summary TEXT,
|
|
355
|
+
total_input_tokens INTEGER DEFAULT 0,
|
|
356
|
+
total_output_tokens INTEGER DEFAULT 0
|
|
357
|
+
);
|
|
358
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_channel ON sessions(channel_id, started_at);
|
|
359
|
+
|
|
360
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
361
|
+
id TEXT PRIMARY KEY,
|
|
362
|
+
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
363
|
+
session_id TEXT REFERENCES sessions(id),
|
|
364
|
+
sender_type TEXT NOT NULL,
|
|
365
|
+
sender_id TEXT NOT NULL,
|
|
366
|
+
sender_name TEXT,
|
|
367
|
+
content TEXT NOT NULL DEFAULT '',
|
|
368
|
+
status TEXT NOT NULL DEFAULT 'complete',
|
|
369
|
+
mentioned_agent_id TEXT,
|
|
370
|
+
run_id TEXT,
|
|
371
|
+
session_key TEXT,
|
|
372
|
+
input_tokens INTEGER,
|
|
373
|
+
output_tokens INTEGER,
|
|
374
|
+
created_at INTEGER NOT NULL
|
|
375
|
+
);
|
|
376
|
+
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at);
|
|
377
|
+
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
|
|
378
|
+
CREATE INDEX IF NOT EXISTS idx_messages_run_id ON messages(run_id);
|
|
379
|
+
|
|
380
|
+
CREATE TABLE IF NOT EXISTS message_attachments (
|
|
381
|
+
id TEXT PRIMARY KEY,
|
|
382
|
+
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
383
|
+
attachment_type TEXT NOT NULL,
|
|
384
|
+
file_path TEXT NOT NULL,
|
|
385
|
+
mime_type TEXT,
|
|
386
|
+
file_size INTEGER,
|
|
387
|
+
original_name TEXT,
|
|
388
|
+
created_at INTEGER NOT NULL
|
|
389
|
+
);
|
|
390
|
+
CREATE INDEX IF NOT EXISTS idx_attachments_message ON message_attachments(message_id);
|
|
391
|
+
|
|
392
|
+
CREATE TABLE IF NOT EXISTS recent_searches (
|
|
393
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
394
|
+
query TEXT NOT NULL,
|
|
395
|
+
created_at INTEGER NOT NULL
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
CREATE TABLE IF NOT EXISTS message_reactions (
|
|
399
|
+
id TEXT PRIMARY KEY,
|
|
400
|
+
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
401
|
+
agent_id TEXT,
|
|
402
|
+
emoji TEXT NOT NULL,
|
|
403
|
+
emoji_char TEXT NOT NULL,
|
|
404
|
+
created_at INTEGER NOT NULL
|
|
405
|
+
);
|
|
406
|
+
CREATE INDEX IF NOT EXISTS idx_reactions_message ON message_reactions(message_id);
|
|
407
|
+
`;
|
|
408
|
+
|
|
409
|
+
// ============================================================================
|
|
410
|
+
// Migrations
|
|
411
|
+
// ============================================================================
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Run idempotent schema migrations against the raw SQLite connection.
|
|
415
|
+
* Creates a backup before running any migration.
|
|
416
|
+
* Tracked via a globalThis version flag so they only run once per process,
|
|
417
|
+
* but also runs when code changes during HMR bump the version.
|
|
418
|
+
*/
|
|
419
|
+
function runMigrations(
|
|
420
|
+
sqlite: InstanceType<typeof Database>,
|
|
421
|
+
dbPath: string
|
|
422
|
+
): void {
|
|
423
|
+
if (getMigratedVersion() >= SCHEMA_VERSION) return;
|
|
424
|
+
|
|
425
|
+
// Back up before any migration
|
|
426
|
+
backupDatabase(dbPath, `pre-migration-v${SCHEMA_VERSION}`);
|
|
427
|
+
|
|
428
|
+
// --- Migration 1: Add last_accessed_at column to channels ---
|
|
429
|
+
const channelCols = sqlite.prepare("PRAGMA table_info(channels)").all() as {
|
|
430
|
+
name: string;
|
|
431
|
+
}[];
|
|
432
|
+
if (!channelCols.some((c) => c.name === "last_accessed_at")) {
|
|
433
|
+
console.log("[Castle DB] Migration: adding last_accessed_at to channels");
|
|
434
|
+
sqlite.exec("ALTER TABLE channels ADD COLUMN last_accessed_at INTEGER");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// --- Migration 2: Create settings table ---
|
|
438
|
+
const tables = sqlite
|
|
439
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='settings'")
|
|
440
|
+
.get() as { name: string } | undefined;
|
|
441
|
+
if (!tables) {
|
|
442
|
+
console.log("[Castle DB] Migration: creating settings table");
|
|
443
|
+
sqlite.exec(`
|
|
444
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
445
|
+
key TEXT PRIMARY KEY,
|
|
446
|
+
value TEXT NOT NULL,
|
|
447
|
+
updated_at INTEGER NOT NULL
|
|
448
|
+
)
|
|
449
|
+
`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// --- Migration 3: Add archived_at column to channels ---
|
|
453
|
+
const channelColsV3 = sqlite.prepare("PRAGMA table_info(channels)").all() as {
|
|
454
|
+
name: string;
|
|
455
|
+
}[];
|
|
456
|
+
if (!channelColsV3.some((c) => c.name === "archived_at")) {
|
|
457
|
+
console.log("[Castle DB] Migration: adding archived_at to channels");
|
|
458
|
+
sqlite.exec("ALTER TABLE channels ADD COLUMN archived_at INTEGER");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// --- Migration 4: Create agent_statuses table ---
|
|
462
|
+
const agentStatusTable = sqlite
|
|
463
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='agent_statuses'")
|
|
464
|
+
.get() as { name: string } | undefined;
|
|
465
|
+
if (!agentStatusTable) {
|
|
466
|
+
console.log("[Castle DB] Migration: creating agent_statuses table");
|
|
467
|
+
sqlite.exec(`
|
|
468
|
+
CREATE TABLE IF NOT EXISTS agent_statuses (
|
|
469
|
+
agent_id TEXT PRIMARY KEY,
|
|
470
|
+
status TEXT NOT NULL DEFAULT 'idle',
|
|
471
|
+
updated_at INTEGER NOT NULL
|
|
472
|
+
)
|
|
473
|
+
`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// --- Migration 5: Create recent_searches table ---
|
|
477
|
+
const recentSearchesTable = sqlite
|
|
478
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='recent_searches'")
|
|
479
|
+
.get() as { name: string } | undefined;
|
|
480
|
+
if (!recentSearchesTable) {
|
|
481
|
+
console.log("[Castle DB] Migration: creating recent_searches table");
|
|
482
|
+
sqlite.exec(`
|
|
483
|
+
CREATE TABLE IF NOT EXISTS recent_searches (
|
|
484
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
485
|
+
query TEXT NOT NULL,
|
|
486
|
+
created_at INTEGER NOT NULL
|
|
487
|
+
)
|
|
488
|
+
`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Checkpoint after migration to persist changes to main DB file immediately
|
|
492
|
+
checkpointWal(sqlite, "TRUNCATE");
|
|
493
|
+
|
|
494
|
+
setMigratedVersion(SCHEMA_VERSION);
|
|
495
|
+
console.log(`[Castle DB] Migrations complete (schema v${SCHEMA_VERSION})`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ============================================================================
|
|
499
|
+
// Initialize
|
|
500
|
+
// ============================================================================
|
|
501
|
+
|
|
502
|
+
function createDb(): {
|
|
503
|
+
db: ReturnType<typeof drizzle>;
|
|
504
|
+
sqlite: InstanceType<typeof Database>;
|
|
505
|
+
} {
|
|
506
|
+
const dataDir = join(getCastleDir(), "data");
|
|
507
|
+
if (!existsSync(dataDir)) {
|
|
508
|
+
mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const dbPath = join(dataDir, "castle.db");
|
|
512
|
+
const isNew = !existsSync(dbPath);
|
|
513
|
+
|
|
514
|
+
// Back up existing DB on every fresh open (process start / HMR reload)
|
|
515
|
+
if (!isNew) {
|
|
516
|
+
backupDatabase(dbPath, "startup");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const sqlite = new Database(dbPath);
|
|
520
|
+
|
|
521
|
+
// Enable WAL mode for better concurrent read performance
|
|
522
|
+
sqlite.pragma("journal_mode = WAL");
|
|
523
|
+
sqlite.pragma("foreign_keys = ON");
|
|
524
|
+
|
|
525
|
+
// ----- Integrity check on existing databases -----
|
|
526
|
+
if (!isNew) {
|
|
527
|
+
const ok = checkIntegrity(sqlite);
|
|
528
|
+
if (!ok) {
|
|
529
|
+
console.error(
|
|
530
|
+
"[Castle DB] WARNING: Database integrity check failed! " +
|
|
531
|
+
"Data may be corrupted. A backup was created above."
|
|
532
|
+
);
|
|
533
|
+
// Continue anyway — SQLite can often still read valid rows
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ----- Checkpoint any stale WAL from a previous crash -----
|
|
538
|
+
// This is critical: if the previous process was killed, the WAL may
|
|
539
|
+
// contain data that hasn't been written to the main DB file.
|
|
540
|
+
// Checkpointing now ensures it's flushed before we proceed.
|
|
541
|
+
if (!isNew) {
|
|
542
|
+
checkpointWal(sqlite, "TRUNCATE");
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Create tables (idempotent)
|
|
546
|
+
sqlite.exec(TABLE_SQL);
|
|
547
|
+
|
|
548
|
+
// Create FTS5 virtual table and triggers (only if they don't already exist)
|
|
549
|
+
sqlite.exec(FTS5_CREATE);
|
|
550
|
+
|
|
551
|
+
// Ensure FTS5 index is in sync with the messages table.
|
|
552
|
+
// This handles: empty index, out-of-sync entries (e.g. after crashes or
|
|
553
|
+
// previous bugs), and any other corruption that would cause "SQL logic error"
|
|
554
|
+
// when the FTS5 delete trigger fires.
|
|
555
|
+
const ftsCount = sqlite.prepare(
|
|
556
|
+
"SELECT COUNT(*) as c FROM messages_fts"
|
|
557
|
+
).get() as { c: number };
|
|
558
|
+
const msgCount = sqlite.prepare(
|
|
559
|
+
"SELECT COUNT(*) as c FROM messages"
|
|
560
|
+
).get() as { c: number };
|
|
561
|
+
|
|
562
|
+
if (ftsCount.c !== msgCount.c) {
|
|
563
|
+
console.log(
|
|
564
|
+
`[Castle DB] FTS5 index out of sync (fts: ${ftsCount.c}, messages: ${msgCount.c}). Rebuilding...`
|
|
565
|
+
);
|
|
566
|
+
// Full rebuild: drop all FTS5 content, re-insert from messages table
|
|
567
|
+
sqlite.exec("DELETE FROM messages_fts");
|
|
568
|
+
sqlite.exec(
|
|
569
|
+
"INSERT INTO messages_fts(rowid, content) SELECT rowid, content FROM messages"
|
|
570
|
+
);
|
|
571
|
+
console.log("[Castle DB] FTS5 index rebuilt successfully.");
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Checkpoint after all schema setup to flush everything to main file
|
|
575
|
+
checkpointWal(sqlite, "TRUNCATE");
|
|
576
|
+
|
|
577
|
+
// Secure file permissions on creation
|
|
578
|
+
if (isNew && platform() !== "win32") {
|
|
579
|
+
try {
|
|
580
|
+
chmodSync(dbPath, 0o600);
|
|
581
|
+
} catch {
|
|
582
|
+
// May fail on some filesystems
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ----- Safety infrastructure -----
|
|
587
|
+
// Start periodic WAL checkpoints (every 5 minutes)
|
|
588
|
+
startPeriodicCheckpoint(sqlite);
|
|
589
|
+
|
|
590
|
+
// Register graceful shutdown handlers (SIGTERM, SIGINT, beforeExit)
|
|
591
|
+
registerShutdownHandlers(sqlite);
|
|
592
|
+
|
|
593
|
+
const stats = !isNew
|
|
594
|
+
? ` (${msgCount.c} messages, ${
|
|
595
|
+
(
|
|
596
|
+
sqlite.prepare("SELECT COUNT(*) as c FROM channels").get() as {
|
|
597
|
+
c: number;
|
|
598
|
+
}
|
|
599
|
+
).c
|
|
600
|
+
} channels)`
|
|
601
|
+
: "";
|
|
602
|
+
|
|
603
|
+
console.log(
|
|
604
|
+
`[Castle DB] ${isNew ? "Created" : "Opened"} database at ${dbPath}${stats}`
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
return { db: drizzle(sqlite, { schema }), sqlite };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ============================================================================
|
|
611
|
+
// Exports
|
|
612
|
+
// ============================================================================
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Get the Castle database instance (Drizzle ORM).
|
|
616
|
+
* Uses globalThis singleton to persist across Next.js HMR in dev mode.
|
|
617
|
+
* Migrations always run if schema version has changed.
|
|
618
|
+
*/
|
|
619
|
+
export function getDb() {
|
|
620
|
+
let db = getGlobalDb();
|
|
621
|
+
let sqlite = getGlobalSqlite();
|
|
622
|
+
|
|
623
|
+
if (!db || !sqlite) {
|
|
624
|
+
const created = createDb();
|
|
625
|
+
db = created.db;
|
|
626
|
+
sqlite = created.sqlite;
|
|
627
|
+
setGlobals(db, sqlite);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Always ensure migrations have run (even if singleton was cached from before code change)
|
|
631
|
+
const dbPath = join(getCastleDir(), "data", "castle.db");
|
|
632
|
+
runMigrations(sqlite, dbPath);
|
|
633
|
+
|
|
634
|
+
return db;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Get the path to the SQLite database file.
|
|
639
|
+
*/
|
|
640
|
+
export function getDbPath(): string {
|
|
641
|
+
return join(getCastleDir(), "data", "castle.db");
|
|
642
|
+
}
|