diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..553b7a2 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 152109f..87c88eb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,10 @@ dist/ artifacts/ __pycache__/ *.pyc +.env +.env.* +!.env.example +data/ +*.sqlite +*.sqlite-shm +*.sqlite-wal diff --git a/README.md b/README.md index bf3de91..11347be 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 8391e78..30080ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "devDependencies": { "@types/node": "^25.0.0", "typescript": "^5.9.0" + }, + "engines": { + "node": ">=22.13.0" } }, "node_modules/@types/node": { diff --git a/package.json b/package.json index aad2d25..f6de21b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/check.ts b/src/check.ts new file mode 100644 index 0000000..2a34077 --- /dev/null +++ b/src/check.ts @@ -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; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..358a152 --- /dev/null +++ b/src/config.ts @@ -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", + }; +} diff --git a/src/database.ts b/src/database.ts new file mode 100644 index 0000000..8b835f0 --- /dev/null +++ b/src/database.ts @@ -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; + } +} diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..8361d71 --- /dev/null +++ b/test/config.test.ts @@ -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" })); + } +}); diff --git a/test/database.test.ts b/test/database.test.ts new file mode 100644 index 0000000..c7e0b09 --- /dev/null +++ b/test/database.test.ts @@ -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(); + } +});