diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62516472..92225d58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,10 @@ jobs: working-directory: app run: yarn install --frozen-lockfile + - name: Check SessionDb schema manifest + working-directory: app + run: yarn db:schema:check + - name: Run vitest with coverage working-directory: app run: yarn test:coverage diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..0fefebcd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,33 @@ +# Browser Use Desktop Agent Notes + +## Local Dev Profiles + +- The app's runtime database lives under Electron `userData`, not under the git + worktree. Do not assume the default profile when working across branches. +- Use `task worktree:profile:path` to get this branch's isolated profile path. + It resolves to `.task/user-data/` by default. +- Use `task worktree:up` to start the app against that isolated profile. +- Use `task worktree:profile:clean FORCE=1` to delete this branch's isolated + profile after quitting the app. +- Use `task db:worktree:copy FROM=default` to copy `sessions.db` from the + platform default profile into this branch profile. Pass `FROM=branch:` + to copy from another branch profile, including branch names with `/`. Use an + absolute path, `./relative/path`, `~/path`, or `FROM=path:` for explicit + filesystem paths. Pass `FORCE=1` if the target DB already exists. +- Use `task db:worktree:doctor` after copying or when local task runs fail. It + compares the target `sessions.db` schema version and schema ID against the + current checkout's `src/main/sessions/schema-manifest.json`. +- If the app was launched with `AGB_USER_DATA_DIR`, run `task agent:run` with + the same `AGB_USER_DATA_DIR`. The local task runner reads + `/local-task-server.json`; pointing the CLI at a different profile + will miss the running app even if `sessions.db` is valid. +- Quit the app before copying or cleaning profile files. SQLite WAL files and + Electron runtime files can be open while the app is running. + +## Session Schema Changes + +- `DB_SCHEMA_VERSION` gates migrations at runtime. +- `src/main/sessions/schema-manifest.json` tracks the expected fresh schema ID + for CI/main drift detection. +- Run `task db:schema:check` after touching `SessionDb` migrations. +- Run `task db:schema:update` only after an intentional schema change. diff --git a/Taskfile.yml b/Taskfile.yml index c1a2952f..1e54833b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -40,6 +40,65 @@ tasks: cmds: - npm run start:reset-sessions + worktree:profile:path: + desc: Print this branch's isolated local userData path + silent: true + cmds: + - | + NAME="{{default "" .NAME}}" + if [ -n "$NAME" ]; then + node app/scripts/dev-profile.mjs path --name "$NAME" + else + node app/scripts/dev-profile.mjs path + fi + + worktree:up: + desc: Start the app using this branch's isolated local userData profile + cmds: + - | + NAME="{{default "" .NAME}}" + if [ -n "$NAME" ]; then + PROFILE="$(node app/scripts/dev-profile.mjs path --name "$NAME")" + else + PROFILE="$(node app/scripts/dev-profile.mjs path)" + fi + AGB_USER_DATA_DIR="$PROFILE" task up + + worktree:profile:clean: + desc: Delete this branch's isolated local userData profile; requires FORCE=1 + silent: true + cmds: + - | + TARGET="{{default "" .TARGET}}" + FORCE="{{default "" .FORCE}}" + ALLOW_RUNNING="{{default "" .ALLOW_RUNNING}}" + TARGET="$TARGET" FORCE="$FORCE" ALLOW_RUNNING="$ALLOW_RUNNING" node app/scripts/dev-profile.mjs clean + + db:worktree:copy: + desc: Copy sessions.db from FROM profile into this branch profile, then check schema + silent: true + cmds: + - | + FROM="{{default "default" .FROM}}" + TO="{{default "" .TO}}" + FORCE="{{default "" .FORCE}}" + ALLOW_RUNNING="{{default "" .ALLOW_RUNNING}}" + DB_ONLY=1 FROM="$FROM" TO="$TO" FORCE="$FORCE" ALLOW_RUNNING="$ALLOW_RUNNING" node app/scripts/dev-profile.mjs copy + + db:worktree:doctor: + desc: Diagnose this branch profile's sessions.db schema against the checkout + silent: true + cmds: + - | + TARGET="{{default "" .TARGET}}" + JSON="{{default "" .JSON}}" + ALLOW_RUNNING="{{default "" .ALLOW_RUNNING}}" + if [ "$JSON" = "1" ]; then + JSON=1 TARGET="$TARGET" ALLOW_RUNNING="$ALLOW_RUNNING" node app/scripts/dev-profile.mjs doctor + else + TARGET="$TARGET" ALLOW_RUNNING="$ALLOW_RUNNING" node app/scripts/dev-profile.mjs doctor + fi + windows:node:check: desc: "Windows: verify local development is using Node 20.x or 22.x" platforms: [windows] @@ -369,6 +428,18 @@ tasks: cmds: - npm run typecheck + db:schema:check: + desc: Verify the tracked SessionDb schema identity manifest + dir: "{{.APP_DIR}}" + cmds: + - npm run db:schema:check + + db:schema:update: + desc: Regenerate the tracked SessionDb schema identity manifest + dir: "{{.APP_DIR}}" + cmds: + - npm run db:schema:update + logs:all: desc: Tail all local app logs with colored human-readable formatting silent: true diff --git a/app/docs/AGENT_LOCAL_RUNBOOK.md b/app/docs/AGENT_LOCAL_RUNBOOK.md index 3317d28e..2bcd437c 100644 --- a/app/docs/AGENT_LOCAL_RUNBOOK.md +++ b/app/docs/AGENT_LOCAL_RUNBOOK.md @@ -6,6 +6,11 @@ inspect task state, or debug app-spawned agent sessions. ## Local Commands - Start the app from the repo root: `task up` +- Start against this branch's isolated profile: `task worktree:up` +- Print this branch's profile path: `task worktree:profile:path` +- Clean this branch's isolated profile: `task worktree:profile:clean FORCE=1` +- Copy `sessions.db` into this branch profile: `task db:worktree:copy FROM=default` +- Diagnose the copied DB schema: `task db:worktree:doctor` - Start with a clean Vite cache: `task up:clean` - Submit a task to a running app: `task agent:run PROMPT="open example.com and report the title" ENGINE=codex` - Run checks: `task lint`, `task typecheck`, `cd app && npm run test` @@ -21,6 +26,22 @@ AGB_USER_DATA_DIR="$(mktemp -d)" task up `--user-data-dir=` overrides `AGB_USER_DATA_DIR`. The app logs the resolved user data path and CDP port in `main.startup`. +For branch/worktree-local development, prefer the repo-owned profile path: + +```bash +task db:worktree:copy FROM=default +task worktree:up +AGB_USER_DATA_DIR="$(task worktree:profile:path)" task agent:run \ + PROMPT="open example.com and report the title" ENGINE=codex +``` + +`task db:worktree:copy` copies only `sessions.db` plus WAL/SHM companions into +the current branch profile, then runs the schema doctor. Use `FROM=branch:` +to copy from another branch profile, including branch names with `/`. Use an +absolute path, `./relative/path`, `~/path`, or `FROM=path:` for explicit +filesystem paths. Quit the app before copying; pass `FORCE=1` only when +replacing an existing target DB is intentional. + ## Runtime State Default Electron `userData` locations: @@ -33,6 +54,10 @@ Important files under `userData`: - `sessions.db` - SQLite session store. Main tables are `sessions`, `session_events`, and `session_attachments`. +- Session schema changes are guarded by `DB_SCHEMA_VERSION` plus + `src/main/sessions/schema-manifest.json`. Run `task db:schema:check` after + intentional session schema edits, and `task db:schema:update` when a migration + intentionally changes the fresh database schema. - `sessions.db-wal` / `sessions.db-shm` - WAL files. Do not delete or edit them while the app is running. - `logs/` - JSONL logs. Core channels are `main.log`, `browser.log`, diff --git a/app/package.json b/app/package.json index 68725b29..3bdca56e 100644 --- a/app/package.json +++ b/app/package.json @@ -49,6 +49,9 @@ "visual:qa": "npm run visual:capture && npm run visual:diff", "qa:review": "open tests/visual/review.html", "qa": "npm run lint && npm run typecheck && npm run test", + "db:schema:check": "ts-node --project tsconfig.json --compiler-options '{\"module\":\"CommonJS\"}' scripts/session-schema-manifest.ts --check && vitest run tests/unit/sessions/SessionDb.schemaIdentity.test.ts", + "db:schema:update": "ts-node --project tsconfig.json --compiler-options '{\"module\":\"CommonJS\"}' scripts/session-schema-manifest.ts --write", + "dev:profile": "node scripts/dev-profile.mjs", "start:reset-onboarding": "node scripts/reset-onboarding.mjs", "start:reset-sessions": "node scripts/reset-sessions.mjs", "sync-domain-skills": "node scripts/sync-domain-skills.mjs", diff --git a/app/scripts/dev-profile.mjs b/app/scripts/dev-profile.mjs new file mode 100644 index 00000000..bc9e017f --- /dev/null +++ b/app/scripts/dev-profile.mjs @@ -0,0 +1,417 @@ +#!/usr/bin/env node +/** + * Manage local development userData profiles. + * + * Default per-worktree profiles live under: + * /.task/user-data/ + * + * Quit Browser Use before copy/clean operations. SQLite and Electron can keep + * files open while the app is running, and local-task-server.json is per-run + * control state that must not be cloned between profiles. + */ + +import { execFileSync, execSync } from 'node:child_process'; +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, isAbsolute, join, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Database from 'better-sqlite3'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const APP_DIR = resolve(__dirname, '..'); +const REPO_ROOT = resolve(APP_DIR, '..'); +const DEV_PROFILE_ROOT = join(REPO_ROOT, '.task', 'user-data'); +const SESSION_SCHEMA_MANIFEST = join(APP_DIR, 'src', 'main', 'sessions', 'schema-manifest.json'); +const SESSION_SCHEMA_QUERY = ` + SELECT type, name, tbl_name, sql + FROM sqlite_schema + WHERE name NOT LIKE 'sqlite_%' + AND type IN ('table', 'index', 'view', 'trigger') + ORDER BY type, name, tbl_name +`.trim(); + +const VOLATILE_ENTRIES = new Set([ + 'Crashpad', + 'harness', + 'local-task-server.json', + 'logs', + 'telemetry.jsonl', +]); + +function readProductName() { + const pkg = JSON.parse(readFileSync(join(APP_DIR, 'package.json'), 'utf8')); + return pkg.productName ?? pkg.name ?? 'app'; +} + +function readSchemaManifest() { + return JSON.parse(readFileSync(SESSION_SCHEMA_MANIFEST, 'utf8')); +} + +function defaultUserDataDir(productName) { + switch (process.platform) { + case 'darwin': + return join(homedir(), 'Library', 'Application Support', productName); + case 'win32': + return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), productName); + default: + return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), productName); + } +} + +function isAppRunning(productName) { + if (process.platform !== 'darwin') return false; + try { + const out = execSync(`pgrep -fl "${productName}.app" || true`, { encoding: 'utf8' }); + return out.trim().length > 0; + } catch { + return false; + } +} + +function currentBranchName() { + try { + const branch = execFileSync('git', ['branch', '--show-current'], { + cwd: REPO_ROOT, + encoding: 'utf8', + }).trim(); + if (branch) return branch; + } catch { + // Fall through to commit-based name. + } + + try { + return `detached-${execFileSync('git', ['rev-parse', '--short', 'HEAD'], { + cwd: REPO_ROOT, + encoding: 'utf8', + }).trim()}`; + } catch { + return 'default'; + } +} + +function sanitizeName(name) { + const cleaned = name + .trim() + .replace(/[/\\:]+/g, '-') + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^[-.]+|[-.]+$/g, ''); + return cleaned || 'default'; +} + +function expandHome(value) { + if (value === '~') return homedir(); + if (value.startsWith(`~${sep}`) || value.startsWith('~/')) { + return join(homedir(), value.slice(2)); + } + return value; +} + +function branchProfilePath(name) { + return join(DEV_PROFILE_ROOT, sanitizeName(name)); +} + +function looksLikePath(ref) { + return ref.startsWith('.') || ref.startsWith('~') || isAbsolute(ref); +} + +function resolveProfileRef(ref, productName) { + if (!ref) return branchProfilePath(process.env.NAME || currentBranchName()); + const value = ref; + if (value === 'default') return defaultUserDataDir(productName); + if (value.startsWith('branch:')) return branchProfilePath(value.slice('branch:'.length)); + if (value.startsWith('name:')) return branchProfilePath(value.slice('name:'.length)); + if (value.startsWith('path:')) return resolve(REPO_ROOT, expandHome(value.slice('path:'.length))); + if (looksLikePath(value)) return resolve(REPO_ROOT, expandHome(value)); + return branchProfilePath(value); +} + +function parseArgs(argv) { + const opts = { + allowRunning: process.env.ALLOW_RUNNING === '1', + dbOnly: process.env.DB_ONLY === '1', + force: process.env.FORCE === '1', + from: process.env.FROM, + json: process.env.JSON === '1', + name: process.env.NAME, + target: process.env.TARGET, + to: process.env.TO, + }; + + const positional = []; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--allow-running') opts.allowRunning = true; + else if (arg === '--db-only') opts.dbOnly = true; + else if (arg === '--force') opts.force = true; + else if (arg === '--from') opts.from = argv[++i]; + else if (arg.startsWith('--from=')) opts.from = arg.slice('--from='.length); + else if (arg === '--json') opts.json = true; + else if (arg === '--name') opts.name = argv[++i]; + else if (arg.startsWith('--name=')) opts.name = arg.slice('--name='.length); + else if (arg === '--target') opts.target = argv[++i]; + else if (arg.startsWith('--target=')) opts.target = arg.slice('--target='.length); + else if (arg === '--to') opts.to = argv[++i]; + else if (arg.startsWith('--to=')) opts.to = arg.slice('--to='.length); + else positional.push(arg); + } + + return { command: positional[0] ?? 'path', opts }; +} + +function ensureAppStopped(productName, opts) { + if (opts.allowRunning || !isAppRunning(productName)) return; + console.error(`[dev-profile] ERROR: ${productName} appears to be running. Quit it before copying or cleaning profiles.`); + console.error('[dev-profile] Re-run with ALLOW_RUNNING=1 only if you know the source and target profiles are not in use.'); + process.exit(1); +} + +function profileFilter(sourceRoot) { + return (src) => { + const rel = src === sourceRoot ? '' : src.slice(sourceRoot.length + 1); + const first = rel.split(/[\\/]/)[0]; + if (!first) return true; + return !VOLATILE_ENTRIES.has(first); + }; +} + +function copyDbFiles(source, target, force) { + const mainDb = join(source, 'sessions.db'); + if (!existsSync(mainDb)) { + throw new Error(`Source profile has no sessions.db: ${mainDb}`); + } + + mkdirSync(target, { recursive: true }); + for (const fileName of ['sessions.db', 'sessions.db-wal', 'sessions.db-shm']) { + const src = join(source, fileName); + const dest = join(target, fileName); + if (!existsSync(src)) continue; + if (existsSync(dest) && !force) { + throw new Error(`${dest} already exists; pass FORCE=1 or --force to replace DB files`); + } + if (existsSync(dest)) rmSync(dest, { force: true }); + cpSync(src, dest); + } +} + +function sessionSchemaId(version, hash) { + return `sessions:v${version}:sha256-${hash}`; +} + +async function computeDbSchemaIdentity(dbPath) { + const { createHash } = await import('node:crypto'); + const db = new Database(dbPath, { readonly: true, fileMustExist: true }); + try { + const version = db.pragma('user_version', { simple: true }) ?? 0; + const objects = db.prepare(SESSION_SCHEMA_QUERY).all().map((row) => ({ + type: row.type, + name: row.name, + tblName: row.tbl_name, + sql: row.sql, + })); + const hash = createHash('sha256').update(JSON.stringify(objects)).digest('hex'); + return { + version, + hash, + id: sessionSchemaId(version, hash), + objects: objects.length, + }; + } finally { + db.close(); + } +} + +async function doctorProfile(target) { + const manifest = readSchemaManifest(); + const dbPath = join(target, 'sessions.db'); + + if (!existsSync(dbPath)) { + return { + ok: false, + level: 'missing', + message: `No sessions.db at ${dbPath}. Run task db:worktree:copy FROM=default or start the app with this profile.`, + profile: target, + dbPath, + }; + } + + const identity = await computeDbSchemaIdentity(dbPath); + if (identity.version > manifest.version) { + return { + ok: false, + level: 'newer', + message: `Profile DB is newer than this checkout (${identity.version} > ${manifest.version}). Use a newer branch/main checkout, or copy from a compatible profile.`, + profile: target, + dbPath, + identity, + manifest, + }; + } + + if (identity.version < manifest.version) { + return { + ok: true, + level: 'older', + message: `Profile DB is older than this checkout (${identity.version} < ${manifest.version}); the app should migrate it on next launch.`, + profile: target, + dbPath, + identity, + manifest, + }; + } + + if (identity.id !== manifest.schemaId) { + return { + ok: false, + level: 'drift', + message: `Profile DB has DB_SCHEMA_VERSION ${identity.version}, but its schema ID differs from this checkout. If the checkout changed SessionDb intentionally, run task db:schema:update; otherwise copy from a compatible profile or branch.`, + profile: target, + dbPath, + identity, + manifest, + }; + } + + return { + ok: true, + level: 'match', + message: `Profile DB matches ${manifest.schemaId}`, + profile: target, + dbPath, + identity, + manifest, + }; +} + +function copyProfile(source, target, opts) { + if (!existsSync(source)) throw new Error(`Source profile does not exist: ${source}`); + + if (opts.dbOnly) { + copyDbFiles(source, target, opts.force); + return; + } + + if (existsSync(target)) { + if (!opts.force) { + throw new Error(`Target profile already exists: ${target}. Pass FORCE=1 or --force to replace it.`); + } + rmSync(target, { recursive: true, force: true }); + } + + mkdirSync(dirname(target), { recursive: true }); + cpSync(source, target, { + recursive: true, + filter: profileFilter(source), + }); +} + +function cleanProfile(target, opts) { + if (!existsSync(target)) { + console.log(`[dev-profile] already clean: ${target}`); + return; + } + if (!opts.force) { + throw new Error(`Refusing to delete ${target} without FORCE=1 or --force`); + } + rmSync(target, { recursive: true, force: true }); +} + +function printResult(opts, payload) { + if (opts.json) console.log(JSON.stringify(payload, null, 2)); + else if (payload.path) console.log(payload.path); + else console.log(`[dev-profile] ${payload.message}`); +} + +function printDoctorResult(opts, result) { + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(`[dev-profile] ${result.message}`); + console.log(`[dev-profile] profile=${result.profile}`); + if (result.identity) { + console.log(`[dev-profile] db=${result.identity.id}`); + console.log(`[dev-profile] expected=${result.manifest.schemaId}`); + } +} + +function usage() { + console.log(`Usage: + node scripts/dev-profile.mjs path [--name ] [--json] + node scripts/dev-profile.mjs copy [--from ] [--to ] [--force] [--db-only] + node scripts/dev-profile.mjs doctor [--target ] [--json] + node scripts/dev-profile.mjs clean [--target ] [--force] + +Refs: + default platform default Electron userData + branch: .task/user-data/ + name: .task/user-data/ + path: explicit filesystem path + ./ explicit filesystem path + / explicit filesystem path + shorthand for .task/user-data/ +`); +} + +async function main() { + const { command, opts } = parseArgs(process.argv.slice(2)); + const productName = readProductName(); + + if (command === 'path') { + const path = resolveProfileRef(opts.name, productName); + printResult(opts, { path, name: opts.name ?? currentBranchName() }); + return; + } + + if (command === 'copy') { + ensureAppStopped(productName, opts); + const source = resolveProfileRef(opts.from ?? 'default', productName); + const target = resolveProfileRef(opts.to ?? opts.name ?? currentBranchName(), productName); + copyProfile(source, target, opts); + const result = { + message: `copied ${opts.dbOnly ? 'session DB' : 'profile'} from ${source} to ${target}`, + source, + target, + }; + printResult(opts, result); + if (opts.dbOnly) { + const doctor = await doctorProfile(target); + printDoctorResult(opts, doctor); + if (!doctor.ok) process.exitCode = 1; + } + return; + } + + if (command === 'doctor') { + const target = resolveProfileRef(opts.target ?? opts.name ?? currentBranchName(), productName); + const result = await doctorProfile(target); + printDoctorResult(opts, result); + if (!result.ok) process.exitCode = 1; + return; + } + + if (command === 'clean') { + ensureAppStopped(productName, opts); + const target = resolveProfileRef(opts.target ?? opts.name ?? currentBranchName(), productName); + cleanProfile(target, opts); + printResult(opts, { message: `cleaned ${target}`, target }); + return; + } + + usage(); + process.exit(command === 'help' || command === '--help' ? 0 : 2); +} + +try { + await main(); +} catch (err) { + console.error(`[dev-profile] ERROR: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +} diff --git a/app/scripts/session-schema-manifest.ts b/app/scripts/session-schema-manifest.ts new file mode 100644 index 00000000..d06906cf --- /dev/null +++ b/app/scripts/session-schema-manifest.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env ts-node + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { DB_SCHEMA_VERSION } from '../src/main/sessions/db-constants'; +import { SessionDb } from '../src/main/sessions/SessionDb'; + +interface SessionSchemaManifest { + database: 'sessions'; + version: number; + schemaId: string; + hashAlgorithm: 'sha256'; + canonicalSource: string; +} + +const MANIFEST_PATH = path.join(__dirname, '../src/main/sessions/schema-manifest.json'); + +function readManifest(): SessionSchemaManifest { + return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')) as SessionSchemaManifest; +} + +function computeManifest(): SessionSchemaManifest { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-schema-manifest-')); + const dbPath = path.join(tempDir, 'sessions.db'); + const db = new SessionDb(dbPath); + + try { + const identity = db.getSchemaIdentity(); + return { + database: 'sessions', + version: DB_SCHEMA_VERSION, + schemaId: identity.id, + hashAlgorithm: 'sha256', + canonicalSource: 'sqlite_schema: type,name,tbl_name,sql excluding sqlite_% internals', + }; + } finally { + db.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function writeManifest(manifest: SessionSchemaManifest): void { + fs.writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`); +} + +function assertManifestMatches(expected: SessionSchemaManifest, actual: SessionSchemaManifest): void { + const expectedJson = JSON.stringify(expected, null, 2); + const actualJson = JSON.stringify(actual, null, 2); + + if (expectedJson === actualJson) { + console.log(`Session schema manifest matches ${actual.schemaId}`); + return; + } + + console.error('Session schema manifest is stale.'); + console.error(`Tracked: ${expected.schemaId}`); + console.error(`Current: ${actual.schemaId}`); + console.error('Run `npm run db:schema:update` after intentional SessionDb schema changes.'); + process.exitCode = 1; +} + +function main(): void { + const mode = process.argv[2] ?? '--check'; + const current = computeManifest(); + + if (mode === '--write') { + writeManifest(current); + console.log(`Updated SessionDb schema manifest to ${current.schemaId}`); + return; + } + + if (mode === '--print') { + console.log(JSON.stringify(current, null, 2)); + return; + } + + if (mode !== '--check') { + console.error(`Unknown mode: ${mode}`); + console.error('Usage: ts-node scripts/session-schema-manifest.ts [--check|--write|--print]'); + process.exitCode = 2; + return; + } + + assertManifestMatches(readManifest(), current); +} + +main(); diff --git a/app/src/main/hl/stock/AGENTS.md b/app/src/main/hl/stock/AGENTS.md index 2b7b0952..c5cf4318 100644 --- a/app/src/main/hl/stock/AGENTS.md +++ b/app/src/main/hl/stock/AGENTS.md @@ -211,6 +211,11 @@ one directory up from the harness: - Account state: `../account.json` - Local task control: `../local-task-server.json` +For repo-level local development, do not assume the platform default profile. +Coding agents should use the repo `AGENTS.md` and `task worktree:profile:path` +to keep `sessions.db` and `local-task-server.json` aligned for the active +worktree. + Do not print raw credentials, tokens, keychain values, or the local task bearer token. Use status checks and masked values. diff --git a/app/src/main/sessions/SessionDb.ts b/app/src/main/sessions/SessionDb.ts index d45a3ec2..ab8c691c 100644 --- a/app/src/main/sessions/SessionDb.ts +++ b/app/src/main/sessions/SessionDb.ts @@ -2,6 +2,7 @@ import Database from 'better-sqlite3'; import { mainLogger } from '../logger'; import { DB_SCHEMA_VERSION, RECOVERY_ERROR, VALID_STATUSES, MAX_ATTACHMENTS_PER_SESSION } from './db-constants'; import type { HlEvent, SessionStatus } from '../../shared/session-schemas'; +import { computeSessionSchemaIdentity, type SessionSchemaIdentity } from './schemaIdentity'; interface SessionRow { id: string; @@ -83,6 +84,10 @@ export class SessionDb { return (this.db.pragma('user_version', { simple: true }) as number) ?? 0; } + getSchemaIdentity(): SessionSchemaIdentity { + return computeSessionSchemaIdentity(this.db, DB_SCHEMA_VERSION); + } + private setVersion(v: number): void { this.db.pragma(`user_version = ${v}`); } diff --git a/app/src/main/sessions/schema-manifest.json b/app/src/main/sessions/schema-manifest.json new file mode 100644 index 00000000..ae75e2d1 --- /dev/null +++ b/app/src/main/sessions/schema-manifest.json @@ -0,0 +1,7 @@ +{ + "database": "sessions", + "version": 12, + "schemaId": "sessions:v12:sha256-8caa4156185e52e73e7b5b85c862be0ff31caba66e313e19148fc9fe5f9f8660", + "hashAlgorithm": "sha256", + "canonicalSource": "sqlite_schema: type,name,tbl_name,sql excluding sqlite_% internals" +} diff --git a/app/src/main/sessions/schemaIdentity.ts b/app/src/main/sessions/schemaIdentity.ts new file mode 100644 index 00000000..ca460ae4 --- /dev/null +++ b/app/src/main/sessions/schemaIdentity.ts @@ -0,0 +1,65 @@ +import { createHash } from 'node:crypto'; +import type Database from 'better-sqlite3'; + +export const SESSION_SCHEMA_CANONICAL_QUERY = ` + SELECT type, name, tbl_name, sql + FROM sqlite_schema + WHERE name NOT LIKE 'sqlite_%' + AND type IN ('table', 'index', 'view', 'trigger') + ORDER BY type, name, tbl_name +`.trim(); + +export interface SessionSchemaObject { + type: string; + name: string; + tblName: string; + sql: string | null; +} + +export interface SessionSchemaIdentity { + version: number; + hash: string; + id: string; + objects: SessionSchemaObject[]; +} + +interface SchemaRow { + type: string; + name: string; + tbl_name: string; + sql: string | null; +} + +export function readSessionSchemaObjects(db: Database.Database): SessionSchemaObject[] { + const rows = db.prepare(SESSION_SCHEMA_CANONICAL_QUERY).all() as SchemaRow[]; + return rows.map((row) => ({ + type: row.type, + name: row.name, + tblName: row.tbl_name, + sql: row.sql, + })); +} + +export function hashSessionSchemaObjects(objects: SessionSchemaObject[]): string { + return createHash('sha256') + .update(JSON.stringify(objects)) + .digest('hex'); +} + +export function sessionSchemaId(version: number, hash: string): string { + return `sessions:v${version}:sha256-${hash}`; +} + +export function computeSessionSchemaIdentity( + db: Database.Database, + version: number, +): SessionSchemaIdentity { + const objects = readSessionSchemaObjects(db); + const hash = hashSessionSchemaObjects(objects); + return { + version, + hash, + id: sessionSchemaId(version, hash), + objects, + }; +} diff --git a/app/tests/unit/sessions/SessionDb.schemaIdentity.test.ts b/app/tests/unit/sessions/SessionDb.schemaIdentity.test.ts new file mode 100644 index 00000000..87846b47 --- /dev/null +++ b/app/tests/unit/sessions/SessionDb.schemaIdentity.test.ts @@ -0,0 +1,88 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterEach, describe, expect, it } from 'vitest'; +import { DB_SCHEMA_VERSION } from '../../../src/main/sessions/db-constants'; +import { SessionDb } from '../../../src/main/sessions/SessionDb'; +import { + SESSION_SCHEMA_CANONICAL_QUERY, + computeSessionSchemaIdentity, +} from '../../../src/main/sessions/schemaIdentity'; +import SESSION_DB_SCHEMA_MANIFEST from '../../../src/main/sessions/schema-manifest.json'; + +let tempDirs: string[] = []; + +function tempDbPath(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-db-schema-')); + tempDirs.push(dir); + return path.join(dir, 'sessions.db'); +} + +afterEach(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + tempDirs = []; +}); + +describe('SessionDb schema identity', () => { + it('matches the tracked schema manifest after migrations', () => { + const db = new SessionDb(tempDbPath()); + try { + const identity = db.getSchemaIdentity(); + + expect(SESSION_DB_SCHEMA_MANIFEST.version).toBe(DB_SCHEMA_VERSION); + expect(identity.version).toBe(DB_SCHEMA_VERSION); + expect(identity.id).toBe(SESSION_DB_SCHEMA_MANIFEST.schemaId); + } finally { + db.close(); + } + }); + + it('changes when schema shape changes without a version bump', () => { + const dbPath = tempDbPath(); + const db = new SessionDb(dbPath); + let originalId: string; + try { + originalId = db.getSchemaIdentity().id; + } finally { + db.close(); + } + + const raw = new Database(dbPath); + try { + raw.exec('CREATE TABLE unexpected_schema_drift (id TEXT PRIMARY KEY)'); + const changed = computeSessionSchemaIdentity(raw, DB_SCHEMA_VERSION); + + expect(changed.id).not.toBe(originalId); + expect(changed.id).not.toBe(SESSION_DB_SCHEMA_MANIFEST.schemaId); + } finally { + raw.close(); + } + }); + + it('preserves whitespace inside quoted SQL literals when hashing schema SQL', () => { + const left = new Database(tempDbPath()); + const right = new Database(tempDbPath()); + try { + left.exec("CREATE TABLE literal_defaults (value TEXT DEFAULT 'a b')"); + right.exec("CREATE TABLE literal_defaults (value TEXT DEFAULT 'a b')"); + + const leftIdentity = computeSessionSchemaIdentity(left, DB_SCHEMA_VERSION); + const rightIdentity = computeSessionSchemaIdentity(right, DB_SCHEMA_VERSION); + + expect(leftIdentity.objects[0].sql).toContain("'a b'"); + expect(rightIdentity.objects[0].sql).toContain("'a b'"); + expect(leftIdentity.id).not.toBe(rightIdentity.id); + } finally { + left.close(); + right.close(); + } + }); + + it('documents the canonical SQLite schema source used for the hash', () => { + expect(SESSION_SCHEMA_CANONICAL_QUERY).toContain('sqlite_schema'); + expect(SESSION_SCHEMA_CANONICAL_QUERY).toContain("name NOT LIKE 'sqlite_%'"); + }); +});