@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.
- package/CHANGELOG.md +106 -0
- package/LICENSE +21 -0
- package/README.md +213 -0
- package/dist/cli.js +206 -0
- package/dist/commands/bind.js +96 -0
- package/dist/commands/login.js +109 -0
- package/dist/commands/logout.js +24 -0
- package/dist/commands/serve.js +374 -0
- package/dist/commands/unbind.js +36 -0
- package/dist/config.js +92 -0
- package/dist/extensions/aexol-mcp.js +117 -0
- package/dist/mcp-client.js +116 -0
- package/dist/preflight.js +36 -0
- package/dist/relay/client.js +240 -0
- package/dist/relay/dispatcher.js +504 -0
- package/dist/relay/machine-store.js +116 -0
- package/dist/relay/models-fetch.js +108 -0
- package/dist/relay/registration.js +135 -0
- package/dist/server/handlers/errors.js +34 -0
- package/dist/server/handlers/projects.js +86 -0
- package/dist/server/handlers/sessions.js +42 -0
- package/dist/server/paths.js +78 -0
- package/dist/server/pi-bridge.js +572 -0
- package/dist/server/session-stream.js +579 -0
- package/dist/server/shutdown.js +180 -0
- package/dist/server/storage.js +491 -0
- package/dist/server/title-generator.js +196 -0
- package/dist/server/wire.js +12 -0
- package/dist/studio-binding.js +97 -0
- package/package.json +67 -0
|
@@ -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
|
+
}
|