feat: add environment configuration and SQLite migration support

This commit is contained in:
Zoe
2026-09-19 15:00:32 -05:00
parent 75b3a01aee
commit 118a5387cb
10 changed files with 193 additions and 2 deletions
+3
View File
@@ -0,0 +1,3 @@
LLAMA_CPP_ORIGIN=http://berlin:9931
LLAMA_CPP_MODEL_ID=Qwen3.6-35B-A3B-UD-Q4_K_M.gguf
DATABASE_PATH=./data/token.sqlite
+7
View File
@@ -3,3 +3,10 @@ dist/
artifacts/ artifacts/
__pycache__/ __pycache__/
*.pyc *.pyc
.env
.env.*
!.env.example
data/
*.sqlite
*.sqlite-shm
*.sqlite-wal
+13 -1
View File
@@ -4,7 +4,7 @@ Manual SSH and X11 controls. There is **no model loop, persistent conversation,
## On berlin (or your development computer) ## On berlin (or your development computer)
Requires Node.js 22+ and an SSH client. Configure an SSH alias `home` for the VM's desktop user, with key authentication. Connect manually first to verify and save its host key: Requires Node.js 22.13+ (for built-in SQLite) and an SSH client. Configure an SSH alias `home` for the VM's desktop user, with key authentication. Connect manually first to verify and save its host key:
```sh ```sh
ssh home ssh home
@@ -99,6 +99,18 @@ python3 -m unittest discover -s test -p 'test_*.py'
Desktop dependencies are imported only when operating the display, so validation tests can run without X11 or Pillow. Desktop dependencies are imported only when operating the display, so validation tests can run without X11 or Pillow.
## Configuration and SQLite
```sh
cp .env.example .env
npm run build
npm run setup:check
```
`.env.example` contains berlin's llama.cpp origin and model ID. Edit `.env` for your installation. The origin excludes `/v1`; future model requests will append the API path. `DATABASE_PATH` defaults to `./data/token.sqlite`, relative to the process working directory. `.env` and database files are ignored by Git. The check validates configuration and opens/checks SQLite; it does not contact the model or operate the desktop.
`src/database.ts` exports a general `openDatabase()` connection using Node's built-in SQLite, with foreign keys, WAL, and a five-second busy timeout. The caller owns the connection and must close it. Use one connection in the eventual supervisor and pass it to domain modules; it is not tied to conversations or logs. Append numbered SQL migrations to `migrations` as actual schemas are introduced (projects, schedules, messages, etc.). Applied migration SQL is recorded and checked against subsequent builds. Pending migrations run transactionally; incompatible history fails rather than silently changing existing data. There is intentionally no speculative domain schema or ORM yet. Back up live databases using a SQLite-aware backup mechanism, not by copying only the main file while WAL is active.
## Next milestone ## Next milestone
Once capture, Unicode insertion, key combinations, and shell timeout work on the real VM, add the llama.cpp adapter, durable tool-call records, and a single model/tool loop. Wake/sleep and restart recovery follow. No SSH connection or real graphical session is available in the development sandbox, so those checks must be run on your setup. Once capture, Unicode insertion, key combinations, and shell timeout work on the real VM, add the llama.cpp adapter, durable tool-call records, and a single model/tool loop. Wake/sleep and restart recovery follow. No SSH connection or real graphical session is available in the development sandbox, so those checks must be run on your setup.
+3
View File
@@ -9,6 +9,9 @@
"devDependencies": { "devDependencies": {
"@types/node": "^25.0.0", "@types/node": "^25.0.0",
"typescript": "^5.9.0" "typescript": "^5.9.0"
},
"engines": {
"node": ">=22.13.0"
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
+5 -1
View File
@@ -1,12 +1,16 @@
{ {
"name": "desktop-harness", "name": "desktop-harness",
"private": true, "private": true,
"engines": {
"node": ">=22.13.0"
},
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"check": "tsc --noEmit", "check": "tsc --noEmit",
"test": "npm run build && node --test dist/test/*.test.js", "test": "npm run build && node --test dist/test/*.test.js",
"home": "node dist/src/main.js" "home": "node dist/src/main.js",
"setup:check": "node --env-file=.env dist/src/check.js"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.0.0", "@types/node": "^25.0.0",
+21
View File
@@ -0,0 +1,21 @@
import { readConfig } from "./config.js";
import { openDatabase } from "./database.js";
try {
const config = readConfig();
const database = openDatabase(config.databasePath);
try {
console.log(JSON.stringify({
modelOrigin: config.llamaCppOrigin,
modelId: config.modelId,
databasePath: config.databasePath,
integrity: database.prepare("PRAGMA quick_check").get(),
migrations: database.prepare("SELECT version, name FROM schema_migrations ORDER BY version").all(),
}, null, 2));
} finally {
database.close();
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
+23
View File
@@ -0,0 +1,23 @@
export interface Config {
llamaCppOrigin: string;
modelId: string;
databasePath: string;
}
export function readConfig(environment: NodeJS.ProcessEnv = process.env): Config {
const origin = environment.LLAMA_CPP_ORIGIN?.trim();
const modelId = environment.LLAMA_CPP_MODEL_ID?.trim();
if (!origin || !modelId) {
throw new Error("Set LLAMA_CPP_ORIGIN and LLAMA_CPP_MODEL_ID in .env (see .env.example).");
}
const url = new URL(origin);
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password
|| url.pathname !== "/" || url.search || url.hash) {
throw new Error("LLAMA_CPP_ORIGIN must be an HTTP(S) origin without credentials, path, query, or fragment.");
}
return {
llamaCppOrigin: url.origin,
modelId,
databasePath: environment.DATABASE_PATH?.trim() || "./data/token.sqlite",
};
}
+62
View File
@@ -0,0 +1,62 @@
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { DatabaseSync } from "node:sqlite";
export interface Migration {
version: number;
name: string;
sql: string;
}
// Append migrations here; never edit an already-deployed migration.
export const migrations: readonly Migration[] = [];
export function migrate(database: DatabaseSync, steps: readonly Migration[]): void {
for (const [index, step] of steps.entries()) {
if (step.version !== index + 1 || !step.name || !step.sql.trim()) {
throw new Error("Migrations must have consecutive versions starting at 1, names, and SQL.");
}
}
database.exec("BEGIN IMMEDIATE");
try {
database.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
sql TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
) STRICT;
`);
const applied = database.prepare("SELECT version, name, sql FROM schema_migrations ORDER BY version").all();
for (const [index, row] of applied.entries()) {
const step = steps[index];
if (!step || row.version !== step.version || row.name !== step.name || row.sql !== step.sql) {
throw new Error("Database migration history differs from this build; use the matching or newer application version.");
}
}
const record = database.prepare("INSERT INTO schema_migrations (version, name, sql) VALUES (?, ?, ?)");
for (const step of steps.slice(applied.length)) {
database.exec(step.sql);
record.run(step.version, step.name, step.sql);
}
database.exec("COMMIT");
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
}
export function openDatabase(path: string, steps: readonly Migration[] = migrations): DatabaseSync {
if (path !== ":memory:") {
mkdirSync(dirname(path), { recursive: true });
}
const database = new DatabaseSync(path);
try {
database.exec("PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;");
migrate(database, steps);
return database;
} catch (error) {
database.close();
throw error;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readConfig } from "../src/config.js";
test("configuration requires model settings and normalizes origin", () => {
assert.throws(() => readConfig({}), /Set LLAMA_CPP/);
const config = readConfig({ LLAMA_CPP_ORIGIN: "http://berlin:9931/", LLAMA_CPP_MODEL_ID: "model" });
assert.equal(config.llamaCppOrigin, "http://berlin:9931");
assert.equal(config.databasePath, "./data/token.sqlite");
for (const origin of ["http://berlin/v1", "ftp://berlin", "http://user:pass@berlin", "http://berlin?x=1"]) {
assert.throws(() => readConfig({ LLAMA_CPP_ORIGIN: origin, LLAMA_CPP_MODEL_ID: "model" }));
}
});
+43
View File
@@ -0,0 +1,43 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { openDatabase, migrate } from "../src/database.js";
const steps = [{ version: 1, name: "projects", sql: "CREATE TABLE projects (id INTEGER PRIMARY KEY, name TEXT NOT NULL) STRICT;" }];
test("migrations persist, reopen idempotently, and allow unrelated tables", () => {
const directory = mkdtempSync(join(tmpdir(), "token-db-"));
try {
const path = join(directory, "nested/state.sqlite");
const first = openDatabase(path, steps);
first.prepare("INSERT INTO projects (name) VALUES (?)").run("garden");
first.close();
const second = openDatabase(path, steps);
try {
assert.equal(second.prepare("SELECT name FROM projects").get()?.name, "garden");
assert.equal(second.prepare("PRAGMA foreign_keys").get()?.foreign_keys, 1);
assert.equal(second.prepare("PRAGMA journal_mode").get()?.journal_mode, "wal");
assert.throws(() => migrate(second, []), /history differs/);
assert.throws(() => migrate(second, [{ version: 1, name: "projects", sql: "SELECT 1" }]), /history differs/);
} finally {
second.close();
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("failed migrations roll back schema changes and history", () => {
const database = openDatabase(":memory:", steps);
try {
assert.throws(() => migrate(database, [...steps, {
version: 2, name: "broken", sql: "CREATE TABLE temporary_table (id INTEGER); INVALID SQL;",
}]));
assert.equal(database.prepare("SELECT count(*) AS count FROM schema_migrations").get()?.count, 1);
assert.equal(database.prepare("SELECT name FROM sqlite_master WHERE name = 'temporary_table'").get(), undefined);
} finally {
database.close();
}
});