feat: add environment configuration and SQLite migration support
This commit is contained in:
@@ -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
|
||||
@@ -3,3 +3,10 @@ dist/
|
||||
artifacts/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
data/
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+3
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" }));
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user