@aexol/spectral 0.0.1

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.
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Graceful shutdown helper for `spectral serve`.
3
+ *
4
+ * Why a separate module:
5
+ * - The shutdown flag (`shutdownState.isShuttingDown`) is read by the relay
6
+ * dispatcher to reject new `client_message` envelopes. Putting it on
7
+ * `serve.ts` would create a circular import (dispatcher → serve → relay
8
+ * client → …); pulling it out keeps the dependency graph linear.
9
+ * - The orchestration is small but has enough edge cases (idempotent on a
10
+ * second SIGTERM, bounded wait for in-flight streams, best-effort store
11
+ * close) that it's worth one focused unit test.
12
+ *
13
+ * Behaviour summary (matches the contract in the Batch 6 brief):
14
+ * 1. First SIGINT/SIGTERM:
15
+ * - Log "Shutting down…" to stderr.
16
+ * - Flip `shutdownState.isShuttingDown = true`. From this point on the
17
+ * dispatcher refuses new `client_message` frames with an error
18
+ * wrapped as a `ws_event` (so the browser sees the rejection on the
19
+ * same stream it was using).
20
+ * - Wait up to `gracePeriodMs` (default 5_000 ms) for the in-flight
21
+ * stream count reported by `inFlightCount()` to drain to 0. Polled
22
+ * every 100 ms — any non-zero value indicates an active turn.
23
+ * - Close the relay (code 1000, reason "shutdown") and dispose the
24
+ * manager. The dispose order matters: relay first so the backend
25
+ * sees a clean close before our local state goes; manager second so
26
+ * any pi processes get torn down deterministically.
27
+ * - Close the SQLite store (best-effort — a failure here just gets
28
+ * logged; the process is exiting anyway).
29
+ * - Exit with the supplied code (default 0).
30
+ * 2. Second SIGINT/SIGTERM during the grace period: skip the wait, force
31
+ * immediate exit with code 1. We don't want a hung pi process to
32
+ * prevent operators from killing the server with a second Ctrl-C.
33
+ *
34
+ * The function is intentionally framework-free — it takes plain callbacks
35
+ * for everything I/O-shaped so the unit test can exercise the orchestration
36
+ * without spawning a real relay or sqlite handle.
37
+ */
38
+ /**
39
+ * Process-wide shutdown flag. Read by the dispatcher to gate new
40
+ * `client_message` envelopes; written by `gracefulShutdown` (and by tests
41
+ * via `resetShutdownState`).
42
+ *
43
+ * Module-level singleton because there is exactly one server process per
44
+ * `spectral serve` invocation; co-locating with serve.ts would require
45
+ * threading the flag through the dispatcher's deps (already a small
46
+ * object) and risks dispatcher tests carrying serve.ts state.
47
+ */
48
+ export const shutdownState = {
49
+ isShuttingDown: false,
50
+ };
51
+ /**
52
+ * Reset the singleton flag. Tests use this in `beforeEach` so a previous
53
+ * test's shutdown doesn't bleed into the next. Production code must NOT
54
+ * call this — once the process is shutting down, it's shutting down.
55
+ */
56
+ export function resetShutdownState() {
57
+ shutdownState.isShuttingDown = false;
58
+ }
59
+ /**
60
+ * Number of times `gracefulShutdown` has been entered in this process.
61
+ * 0 → first signal: do the full graceful sequence.
62
+ * ≥1 → second signal during grace: bail with code 1 immediately.
63
+ *
64
+ * Deliberately a module-level counter rather than a closure so the second
65
+ * signal handler (which closes over a different invocation of the function)
66
+ * still observes the increment.
67
+ */
68
+ let entryCount = 0;
69
+ /** Test-only: reset the entry counter alongside `shutdownState`. */
70
+ export function resetShutdownEntryCount() {
71
+ entryCount = 0;
72
+ }
73
+ /**
74
+ * Run the graceful shutdown sequence. Idempotent on the entry-count
75
+ * dimension: the second concurrent call resolves immediately after
76
+ * triggering an immediate exit.
77
+ *
78
+ * Returns once the supplied `exitFn` has been invoked (or would have been
79
+ * invoked in production where `process.exit` does not return). Tests can
80
+ * await it to assert ordering.
81
+ */
82
+ export async function gracefulShutdown(opts = {}) {
83
+ const logger = opts.logger ?? console;
84
+ const exitFn = opts.exitFn ?? ((code) => process.exit(code));
85
+ const sleep = opts.sleep ?? defaultSleep;
86
+ const gracePeriodMs = opts.gracePeriodMs ?? 5_000;
87
+ const pollIntervalMs = opts.pollIntervalMs ?? 100;
88
+ const exitCode = opts.exitCode ?? 0;
89
+ entryCount += 1;
90
+ // Second signal during a graceful shutdown: don't wait around, just go.
91
+ // Code 1 signals "abnormal exit" — operators wanted out NOW, not after
92
+ // pi finishes its turn.
93
+ if (entryCount > 1) {
94
+ try {
95
+ logger.error("Shutdown forced by repeated signal — exiting immediately.");
96
+ }
97
+ catch {
98
+ // ignore — best-effort
99
+ }
100
+ exitFn(1);
101
+ return;
102
+ }
103
+ // First signal: announce intent, flip the flag (dispatcher reads this on
104
+ // every inbound `client_message`), then drain.
105
+ try {
106
+ logger.error("Shutting down…");
107
+ }
108
+ catch {
109
+ // ignore — best-effort
110
+ }
111
+ shutdownState.isShuttingDown = true;
112
+ // Bounded wait for in-flight turns. We don't accept new ones (the flag
113
+ // is set), but we let the ones that already started finish so the user
114
+ // doesn't see a half-streamed assistant message orphaned in the UI.
115
+ if (opts.inFlightCount) {
116
+ const start = Date.now();
117
+ while (Date.now() - start < gracePeriodMs) {
118
+ let count;
119
+ try {
120
+ count = opts.inFlightCount();
121
+ }
122
+ catch {
123
+ // Defensive: a misbehaving counter shouldn't trap the shutdown.
124
+ // Treat it as "drained" and move on.
125
+ count = 0;
126
+ }
127
+ if (count <= 0)
128
+ break;
129
+ await sleep(pollIntervalMs);
130
+ }
131
+ }
132
+ // Order matters: relay → manager → store. See module-level comment.
133
+ if (opts.closeRelay) {
134
+ try {
135
+ await opts.closeRelay();
136
+ }
137
+ catch (err) {
138
+ try {
139
+ logger.warn(`Relay close failed during shutdown: ${err instanceof Error ? err.message : String(err)}`);
140
+ }
141
+ catch {
142
+ // ignore
143
+ }
144
+ }
145
+ }
146
+ if (opts.disposeManager) {
147
+ try {
148
+ await opts.disposeManager();
149
+ }
150
+ catch (err) {
151
+ try {
152
+ logger.warn(`Manager dispose failed during shutdown: ${err instanceof Error ? err.message : String(err)}`);
153
+ }
154
+ catch {
155
+ // ignore
156
+ }
157
+ }
158
+ }
159
+ if (opts.closeStore) {
160
+ try {
161
+ opts.closeStore();
162
+ }
163
+ catch (err) {
164
+ try {
165
+ logger.warn(`Store close failed during shutdown: ${err instanceof Error ? err.message : String(err)}`);
166
+ }
167
+ catch {
168
+ // ignore
169
+ }
170
+ }
171
+ }
172
+ exitFn(exitCode);
173
+ }
174
+ function defaultSleep(ms) {
175
+ return new Promise((resolve) => {
176
+ const t = setTimeout(resolve, ms);
177
+ // Don't keep the loop alive purely for the shutdown poll.
178
+ t.unref?.();
179
+ });
180
+ }
@@ -0,0 +1,491 @@
1
+ /**
2
+ * SQLite-backed project/session/message storage for `spectral serve`.
3
+ *
4
+ * Two-tier model:
5
+ * projects ─< sessions ─< messages
6
+ *
7
+ * Single-process, single-file. Using `better-sqlite3` because:
8
+ * - Synchronous API is a perfect fit for the per-WS-event write pattern.
9
+ * - No connection pool, no callback churn, transactions are trivial.
10
+ * - Native module ships prebuilt binaries for every Node version we support.
11
+ *
12
+ * Schema migrations:
13
+ * We track schema with the `user_version` PRAGMA. When opening a DB whose
14
+ * version does not match `SCHEMA_VERSION`, we DROP every known table and
15
+ * recreate. This is safe pre-1.0 because the agent UI is local-only and
16
+ * the data is conversation history we explicitly opted not to migrate
17
+ * for the project-tier rollout. Anything newer (real users with real
18
+ * data) needs a proper migration table.
19
+ *
20
+ * Foreign keys are enabled so DELETE FROM projects cascades to sessions
21
+ * cascades to messages. The route layer must still tear down any in-flight
22
+ * pi streams BEFORE deleting — see `SessionStreamManager.disposeProjectStreams`.
23
+ *
24
+ * The repo is intentionally a thin wrapper. We do NOT expose `Database`
25
+ * itself — callers go through the typed methods so we can swap the backend
26
+ * later (e.g. if we ever need to colocate with a remote service).
27
+ */
28
+ import Database from "better-sqlite3";
29
+ import { randomUUID } from "node:crypto";
30
+ import { mkdirSync, readFileSync } from "node:fs";
31
+ import { dirname, join } from "node:path";
32
+ import { stripJsoncComments } from "../studio-binding.js";
33
+ /**
34
+ * Schema version. Bump + the on-open migration drops & recreates every table.
35
+ * Since this is local-only conversation history pre-1.0, we explicitly do not
36
+ * preserve user data across schema changes.
37
+ */
38
+ const SCHEMA_VERSION = 2;
39
+ const SCHEMA_SQL = `
40
+ PRAGMA foreign_keys = ON;
41
+
42
+ CREATE TABLE IF NOT EXISTS projects (
43
+ id TEXT PRIMARY KEY,
44
+ name TEXT NOT NULL,
45
+ path TEXT NOT NULL,
46
+ created_at INTEGER NOT NULL,
47
+ updated_at INTEGER NOT NULL
48
+ );
49
+
50
+ CREATE TABLE IF NOT EXISTS sessions (
51
+ id TEXT PRIMARY KEY,
52
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
53
+ title TEXT NOT NULL,
54
+ created_at INTEGER NOT NULL,
55
+ updated_at INTEGER NOT NULL,
56
+ model_id TEXT
57
+ );
58
+
59
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_at DESC);
60
+
61
+ CREATE TABLE IF NOT EXISTS messages (
62
+ id TEXT PRIMARY KEY,
63
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
64
+ role TEXT NOT NULL CHECK (role IN ('user','assistant','system')),
65
+ content TEXT NOT NULL,
66
+ events_jsonl TEXT NOT NULL DEFAULT '',
67
+ created_at INTEGER NOT NULL
68
+ );
69
+
70
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
71
+ `;
72
+ /**
73
+ * Synchronous binding-file reader for a project at the given filesystem path.
74
+ * Returns null when no `.aexol/aexol.jsonc` exists at that path.
75
+ */
76
+ function readBindingSync(projectPath) {
77
+ try {
78
+ const raw = readFileSync(join(projectPath, ".aexol", "aexol.jsonc"), "utf8");
79
+ return JSON.parse(stripJsoncComments(raw));
80
+ }
81
+ catch (err) {
82
+ const code = err.code;
83
+ if (code === "ENOENT" || code === "ENOTDIR")
84
+ return null;
85
+ // Rethrow on unexpected I/O errors (permissions, etc.).
86
+ throw err;
87
+ }
88
+ }
89
+ /** Augment a WireProject row with Studio binding fields when present. */
90
+ function applyBindingFields(project) {
91
+ const binding = readBindingSync(project.path);
92
+ if (!binding)
93
+ return project;
94
+ return {
95
+ ...project,
96
+ studioProjectId: binding.projectId,
97
+ studioProjectName: binding.name || binding.projectId,
98
+ };
99
+ }
100
+ /** Tables we own — used by the migration drop step. */
101
+ const KNOWN_TABLES = ["messages", "sessions", "projects"];
102
+ export class SessionStore {
103
+ path;
104
+ db;
105
+ closed = false;
106
+ // Project statements
107
+ stmtListProjects;
108
+ stmtGetProject;
109
+ stmtCreateProject;
110
+ stmtUpdateProject;
111
+ stmtDeleteProject;
112
+ stmtListSessionsByProject;
113
+ stmtListSessionIdsByProject;
114
+ // Session statements
115
+ stmtGetSession;
116
+ stmtCreateSession;
117
+ stmtDeleteSession;
118
+ stmtListMessages;
119
+ stmtAppendMessage;
120
+ stmtTouchSession;
121
+ stmtRenameSession;
122
+ stmtSessionWithCount;
123
+ // Phase 3: per-session sticky model.
124
+ stmtGetSessionModel;
125
+ stmtSetSessionModel;
126
+ constructor(path) {
127
+ this.path = path;
128
+ // Make sure the parent directory exists. mkdirSync with recursive is a
129
+ // no-op when the dir already exists.
130
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
131
+ this.db = new Database(path);
132
+ this.db.pragma("journal_mode = WAL");
133
+ this.db.pragma("foreign_keys = ON");
134
+ // ---- migration --------------------------------------------------------
135
+ // Detect schema version and drop+recreate on mismatch. Pre-1.0: no data
136
+ // preservation. The first open on a brand-new file reports version 0
137
+ // and falls through to the create path below (drops are no-ops on
138
+ // missing tables).
139
+ const versionRow = this.db.pragma("user_version", { simple: true });
140
+ if (versionRow !== SCHEMA_VERSION) {
141
+ // Disable FKs for the duration of the drop so order doesn't matter
142
+ // (we drop children first regardless, but this keeps it bullet-proof
143
+ // if KNOWN_TABLES order is ever reshuffled).
144
+ this.db.pragma("foreign_keys = OFF");
145
+ const tx = this.db.transaction(() => {
146
+ for (const t of KNOWN_TABLES) {
147
+ this.db.exec(`DROP TABLE IF EXISTS ${t}`);
148
+ }
149
+ });
150
+ tx();
151
+ this.db.pragma("foreign_keys = ON");
152
+ }
153
+ this.db.exec(SCHEMA_SQL);
154
+ // Stamp the version AFTER schema creation so a crash mid-create is
155
+ // detected next open and re-runs the (idempotent) create.
156
+ this.db.pragma(`user_version = ${SCHEMA_VERSION}`);
157
+ // ---- additive column migration ---------------------------------------
158
+ // Phase 3 (Available Models): per-session sticky model selection lives in
159
+ // a new `sessions.model_id` column. We do this as an idempotent ALTER
160
+ // (not a SCHEMA_VERSION bump + drop) because the column is purely
161
+ // additive — older rows have NULL model_id and fall back to pi's own
162
+ // settings file, which is the pre-Phase-3 behaviour. Bumping
163
+ // SCHEMA_VERSION would also nuke conversation history we now want to
164
+ // preserve. The check is cheap (one PRAGMA per startup) and safe to run
165
+ // every time.
166
+ const sessionCols = this.db
167
+ .prepare(`PRAGMA table_info(sessions)`)
168
+ .all();
169
+ if (!sessionCols.some((c) => c.name === "model_id")) {
170
+ this.db.exec(`ALTER TABLE sessions ADD COLUMN model_id TEXT`);
171
+ }
172
+ // ---- one-time cleanup -------------------------------------------------
173
+ // Historical garbage from prior runs of the bridge that persisted every
174
+ // intermediate `message_end` pi emitted, including pure-framing rows
175
+ // with no visible content. Those rows were silently dropped by the
176
+ // client's hydration path, but they polluted message counts and were
177
+ // surfaced by `SELECT * FROM messages`. The bridge no longer emits
178
+ // these going forward — this DELETE catches the backlog. Conservative
179
+ // pattern: only rows with empty content, tiny JSONL, AND none of the
180
+ // four meaningful wire-event substrings. Idempotent and cheap; safe to
181
+ // run on every startup.
182
+ try {
183
+ const cleanup = this.db
184
+ .prepare(`DELETE FROM messages
185
+ WHERE role = 'assistant'
186
+ AND length(content) = 0
187
+ AND length(events_jsonl) < 200
188
+ AND events_jsonl NOT LIKE '%text_delta%'
189
+ AND events_jsonl NOT LIKE '%thinking_delta%'
190
+ AND events_jsonl NOT LIKE '%tool_call%'
191
+ AND events_jsonl NOT LIKE '%tool_result%'`)
192
+ .run();
193
+ if (cleanup.changes > 0) {
194
+ console.log(`[storage] Cleaned ${cleanup.changes} empty assistant message(s) from prior runs`);
195
+ }
196
+ }
197
+ catch (err) {
198
+ // Defensive: cleanup must never block startup. A failure here just
199
+ // leaves the rows in place — they were already harmless to the
200
+ // client (they hydrate to no segments and are skipped).
201
+ console.warn(`[storage] cleanup of empty assistant messages failed: ${err instanceof Error ? err.message : String(err)}`);
202
+ }
203
+ // ---- project statements ----------------------------------------------
204
+ this.stmtListProjects = this.db.prepare(`
205
+ SELECT p.id, p.name, p.path, p.created_at, p.updated_at,
206
+ (SELECT COUNT(*) FROM sessions s WHERE s.project_id = p.id) AS session_count
207
+ FROM projects p
208
+ ORDER BY p.updated_at DESC
209
+ `);
210
+ this.stmtGetProject = this.db.prepare(`SELECT id, name, path, created_at, updated_at FROM projects WHERE id = ?`);
211
+ this.stmtCreateProject = this.db.prepare(`INSERT INTO projects (id, name, path, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`);
212
+ this.stmtUpdateProject = this.db.prepare(`UPDATE projects SET name = ?, path = ?, updated_at = ? WHERE id = ?`);
213
+ this.stmtDeleteProject = this.db.prepare(`DELETE FROM projects WHERE id = ?`);
214
+ this.stmtListSessionsByProject = this.db.prepare(`
215
+ SELECT s.id, s.project_id, s.title, s.created_at, s.updated_at,
216
+ (SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS message_count
217
+ FROM sessions s
218
+ WHERE s.project_id = ?
219
+ ORDER BY s.updated_at DESC
220
+ `);
221
+ this.stmtListSessionIdsByProject = this.db.prepare(`SELECT id FROM sessions WHERE project_id = ?`);
222
+ // ---- session statements ----------------------------------------------
223
+ this.stmtGetSession = this.db.prepare(`SELECT id, project_id, title, created_at, updated_at FROM sessions WHERE id = ?`);
224
+ this.stmtCreateSession = this.db.prepare(`INSERT INTO sessions (id, project_id, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`);
225
+ this.stmtDeleteSession = this.db.prepare(`DELETE FROM sessions WHERE id = ?`);
226
+ this.stmtListMessages = this.db.prepare(`SELECT id, session_id, role, content, events_jsonl, created_at
227
+ FROM messages WHERE session_id = ? ORDER BY created_at ASC, id ASC`);
228
+ this.stmtAppendMessage = this.db.prepare(`INSERT INTO messages (id, session_id, role, content, events_jsonl, created_at)
229
+ VALUES (?, ?, ?, ?, ?, ?)`);
230
+ this.stmtTouchSession = this.db.prepare(`UPDATE sessions SET updated_at = ? WHERE id = ?`);
231
+ this.stmtRenameSession = this.db.prepare(`UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?`);
232
+ this.stmtSessionWithCount = this.db.prepare(`
233
+ SELECT s.id, s.project_id, s.title, s.created_at, s.updated_at,
234
+ (SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS message_count
235
+ FROM sessions s
236
+ WHERE s.id = ?
237
+ `);
238
+ this.stmtGetSessionModel = this.db.prepare(`SELECT model_id FROM sessions WHERE id = ?`);
239
+ this.stmtSetSessionModel = this.db.prepare(`UPDATE sessions SET model_id = ? WHERE id = ?`);
240
+ }
241
+ /** Smoke check: returns the names of the tables in the DB. */
242
+ listTables() {
243
+ const rows = this.db
244
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`)
245
+ .all();
246
+ return rows.map((r) => r.name);
247
+ }
248
+ // ----------------------------------------------------------------------
249
+ // Projects
250
+ // ----------------------------------------------------------------------
251
+ listProjects() {
252
+ return this.stmtListProjects.all().map((r) => {
253
+ const project = {
254
+ id: r.id,
255
+ name: r.name,
256
+ path: r.path,
257
+ createdAt: r.created_at,
258
+ updatedAt: r.updated_at,
259
+ sessionCount: r.session_count,
260
+ };
261
+ return applyBindingFields(project);
262
+ });
263
+ }
264
+ createProject(input) {
265
+ const id = input.id ?? randomUUID();
266
+ const name = input.name.trim() || "Untitled project";
267
+ const path = input.path;
268
+ const now = Date.now();
269
+ this.stmtCreateProject.run(id, name, path, now, now);
270
+ return {
271
+ id,
272
+ name,
273
+ path,
274
+ createdAt: now,
275
+ updatedAt: now,
276
+ sessionCount: 0,
277
+ };
278
+ }
279
+ /** Returns null if not found. */
280
+ getProject(id) {
281
+ const row = this.stmtGetProject.get(id);
282
+ if (!row)
283
+ return null;
284
+ // Re-fetch with count to keep the wire shape consistent. Cheap.
285
+ const withCount = this.stmtListProjects.all().find((r) => r.id === id);
286
+ const project = {
287
+ id: row.id,
288
+ name: row.name,
289
+ path: row.path,
290
+ createdAt: row.created_at,
291
+ updatedAt: row.updated_at,
292
+ sessionCount: withCount?.session_count ?? 0,
293
+ };
294
+ return applyBindingFields(project);
295
+ }
296
+ /**
297
+ * Update a project's name and/or path. At least one must be provided.
298
+ * Returns the updated project, or null if not found. Empty `name` falls
299
+ * back to "Untitled project" for parity with createProject.
300
+ */
301
+ updateProject(id, input) {
302
+ const existing = this.stmtGetProject.get(id);
303
+ if (!existing)
304
+ return null;
305
+ const nextName = input.name !== undefined
306
+ ? (input.name.trim() || "Untitled project")
307
+ : existing.name;
308
+ const nextPath = input.path !== undefined ? input.path : existing.path;
309
+ const now = Date.now();
310
+ this.stmtUpdateProject.run(nextName, nextPath, now, id);
311
+ return this.getProject(id);
312
+ }
313
+ /**
314
+ * Delete a project. Cascades to sessions and messages via FK.
315
+ * Caller MUST tear down any active SessionStream subscribers / pi
316
+ * processes for sessions in this project BEFORE invoking this.
317
+ * Returns the list of session ids that belonged to the project (so
318
+ * the caller can include them in the stream-teardown call).
319
+ */
320
+ deleteProject(id) {
321
+ const sessionIds = this.stmtListSessionIdsByProject.all(id).map((r) => r.id);
322
+ const info = this.stmtDeleteProject.run(id);
323
+ return { deleted: info.changes > 0, sessionIds };
324
+ }
325
+ /** Sessions belonging to a single project, newest-first. */
326
+ listSessionsByProject(projectId) {
327
+ return this.stmtListSessionsByProject.all(projectId).map((r) => ({
328
+ id: r.id,
329
+ projectId: r.project_id,
330
+ title: r.title,
331
+ createdAt: r.created_at,
332
+ updatedAt: r.updated_at,
333
+ messageCount: r.message_count,
334
+ }));
335
+ }
336
+ // ----------------------------------------------------------------------
337
+ // Sessions
338
+ // ----------------------------------------------------------------------
339
+ createSession(input) {
340
+ if (!input.projectId) {
341
+ throw new Error("createSession requires projectId");
342
+ }
343
+ // Verify the project exists so we get a clean error rather than a raw
344
+ // FK violation at INSERT time.
345
+ const project = this.stmtGetProject.get(input.projectId);
346
+ if (!project)
347
+ throw new Error(`Unknown projectId: ${input.projectId}`);
348
+ const id = input.id ?? randomUUID();
349
+ const title = input.title?.trim() || "New conversation";
350
+ const now = Date.now();
351
+ this.stmtCreateSession.run(id, input.projectId, title, now, now);
352
+ // Touch the project so it floats to the top of the project list.
353
+ this.stmtUpdateProject.run(project.name, project.path, now, project.id);
354
+ return {
355
+ id,
356
+ projectId: input.projectId,
357
+ title,
358
+ createdAt: now,
359
+ updatedAt: now,
360
+ messageCount: 0,
361
+ };
362
+ }
363
+ /** Returns null if not found. */
364
+ getSession(id) {
365
+ const row = this.stmtGetSession.get(id);
366
+ if (!row)
367
+ return null;
368
+ const messages = this.getMessages(id);
369
+ return {
370
+ id: row.id,
371
+ projectId: row.project_id,
372
+ title: row.title,
373
+ createdAt: row.created_at,
374
+ updatedAt: row.updated_at,
375
+ messages,
376
+ };
377
+ }
378
+ /** Convenience for routes/managers that just need the projectId. */
379
+ getSessionProjectId(id) {
380
+ const row = this.stmtGetSession.get(id);
381
+ return row ? row.project_id : null;
382
+ }
383
+ getMessages(sessionId) {
384
+ return this.stmtListMessages.all(sessionId).map((r) => ({
385
+ id: r.id,
386
+ role: r.role,
387
+ content: r.content,
388
+ events: r.events_jsonl,
389
+ createdAt: r.created_at,
390
+ }));
391
+ }
392
+ /**
393
+ * Append a message and bump the session's updated_at in one transaction.
394
+ * Throws if the session doesn't exist (FK violation).
395
+ */
396
+ appendMessage(sessionId, msg) {
397
+ const id = msg.id ?? randomUUID();
398
+ const createdAt = msg.createdAt ?? Date.now();
399
+ const eventsJsonl = msg.eventsJsonl ?? "";
400
+ const tx = this.db.transaction(() => {
401
+ this.stmtAppendMessage.run(id, sessionId, msg.role, msg.content, eventsJsonl, createdAt);
402
+ this.stmtTouchSession.run(createdAt, sessionId);
403
+ });
404
+ tx();
405
+ return {
406
+ id,
407
+ role: msg.role,
408
+ content: msg.content,
409
+ events: eventsJsonl,
410
+ createdAt,
411
+ };
412
+ }
413
+ /**
414
+ * Rename a session. Bumps `updated_at`. Returns the updated summary, or
415
+ * null if the session doesn't exist. Title is trimmed; an empty title falls
416
+ * back to "New conversation" for parity with createSession().
417
+ */
418
+ renameSession(id, title) {
419
+ const trimmed = title.trim() || "New conversation";
420
+ const now = Date.now();
421
+ const info = this.stmtRenameSession.run(trimmed, now, id);
422
+ if (info.changes === 0)
423
+ return null;
424
+ const row = this.stmtSessionWithCount.get(id);
425
+ if (!row)
426
+ return null;
427
+ return {
428
+ id: row.id,
429
+ projectId: row.project_id,
430
+ title: row.title,
431
+ createdAt: row.created_at,
432
+ updatedAt: row.updated_at,
433
+ messageCount: row.message_count,
434
+ };
435
+ }
436
+ /** Returns true if a row was deleted. Cascades to messages. */
437
+ deleteSession(id) {
438
+ const info = this.stmtDeleteSession.run(id);
439
+ return info.changes > 0;
440
+ }
441
+ // ----------------------------------------------------------------------
442
+ // Per-session sticky model (Phase 3 — Available Models whitelist)
443
+ // ----------------------------------------------------------------------
444
+ /**
445
+ * Get the persisted modelId for a session, or null if none was ever set or
446
+ * the session does not exist. The CLI uses this for cross-restart recovery:
447
+ * when an envelope arrives WITHOUT a `modelId` but SQLite has a value
448
+ * persisted from an earlier turn, we apply the persisted value before
449
+ * forwarding to pi. When neither envelope nor SQLite have a value, we
450
+ * leave model selection to pi's own settings file (pre-Phase-3 behaviour).
451
+ */
452
+ getSessionModel(sessionId) {
453
+ const row = this.stmtGetSessionModel.get(sessionId);
454
+ return row?.model_id ?? null;
455
+ }
456
+ /**
457
+ * Persist the modelId for a session. Pass `null` to clear. Does NOT bump
458
+ * `updated_at` — model selection is metadata, not user activity, so it
459
+ * shouldn't promote a session to the top of the sidebar. Silently no-ops
460
+ * when the session does not exist (the caller has already validated the
461
+ * sessionId via attach/getSession in the normal flow).
462
+ */
463
+ setSessionModel(sessionId, modelId) {
464
+ this.stmtSetSessionModel.run(modelId, sessionId);
465
+ }
466
+ close() {
467
+ if (this.closed)
468
+ return;
469
+ this.closed = true;
470
+ this.db.close();
471
+ }
472
+ }
473
+ export function preflightSqlite(dbPath) {
474
+ try {
475
+ const store = new SessionStore(dbPath);
476
+ // Touch the schema check to make sure the DB is readable.
477
+ store.listTables();
478
+ store.close();
479
+ return { ok: true };
480
+ }
481
+ catch (err) {
482
+ const msg = err instanceof Error ? err.message : String(err);
483
+ return {
484
+ ok: false,
485
+ error: `Failed to load native sqlite module (${msg}).\n` +
486
+ ` This usually means the native binary couldn't be built for your Node.js version.\n` +
487
+ ` Try: cd ~/.spectral && npm rebuild better-sqlite3\n` +
488
+ ` Or reinstall: npm install -g @aexol/spectral`,
489
+ };
490
+ }
491
+ }