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/
__pycache__/
*.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)
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
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.
## 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
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": {
"@types/node": "^25.0.0",
"typescript": "^5.9.0"
},
"engines": {
"node": ">=22.13.0"
}
},
"node_modules/@types/node": {
+5 -1
View File
@@ -1,12 +1,16 @@
{
"name": "desktop-harness",
"private": true,
"engines": {
"node": ">=22.13.0"
},
"type": "module",
"scripts": {
"build": "tsc",
"check": "tsc --noEmit",
"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": {
"@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();
}
});