From 34869e72c9654195ddd06cf5f9b166bbdd30e83f Mon Sep 17 00:00:00 2001 From: Midhat-dot Date: Wed, 28 Oct 2026 00:59:10 +0500 Subject: [PATCH] Add L1 local DB schema and types --- sql/001_init.sql | 97 ++++++++++++++++++++++++++++++++ src/index.ts | 5 +- src/local-db.ts | 128 ++++++++++++++++++++++++++++++++++++++++++ test/local-db.test.ts | 25 +++++++++ 4 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 sql/001_init.sql create mode 100644 src/local-db.ts create mode 100644 test/local-db.test.ts diff --git a/sql/001_init.sql b/sql/001_init.sql new file mode 100644 index 0000000..70b3930 --- /dev/null +++ b/sql/001_init.sql @@ -0,0 +1,97 @@ +-- L1: local database contract for ~/.codehands/state.db (SQLite) +-- RULE: one writer per table. SQLite can't enforce this, so every table +-- below says who may write it. Everyone else only reads. + +-- WRITER: daemon +create table if not exists schema_migrations ( + version integer primary key, + applied_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +-- WRITER: app +-- repo_id = hash of the origin URL (same project on every laptop). +-- path = folder on THIS laptop only. It never syncs. +create table if not exists projects ( + id text primary key, + name text not null, + repo_id text not null unique, + path text not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +-- WRITER: app +-- A task with no session yet is waiting for the daemon to pick it up. +create table if not exists tasks ( + id text primary key, + project_id text not null references projects(id), + prompt text not null, + agent text not null check (agent in ('claude-code','codex','codehands')), + models text not null default '{}', -- JSON: model per phase + created_by text not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +-- WRITER: app +create table if not exists commands ( + id text primary key, + session_id text, -- null only for 'start' + project_id text, + kind text not null check (kind in ('start','stop','resume','approve','reject')), + created_by text not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +-- WRITER: daemon +-- unique(task_id): a task can get only one session, so the daemon claims +-- each task exactly once. A second insert for the same task fails. +create table if not exists sessions ( + id text primary key, + task_id text not null unique references tasks(id), + project_id text not null references projects(id), + status text not null default 'queued' + check (status in ('queued','running','stopped','awaiting_approval', + 'approved','rejected','failed')), + agent text not null, + model text not null, + reason text, -- why failed or refused + cost_usd real not null default 0, + branch text, + base_commit text, + diff text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +-- WRITER: daemon. Append only. +-- (session_id, seq) is the key, so a resend can't create a duplicate. +create table if not exists events ( + session_id text not null references sessions(id), + seq integer not null, -- 1, 2, 3... per session + type text not null, + payload text not null default '{}', -- JSON + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + primary key (session_id, seq) +); + +-- WRITER: daemon +create table if not exists provider_status ( + agent text primary key, -- 'claude-code' | 'codex' + state text not null check (state in + ('ok','not_installed','not_logged_in','rate_limited','usage_limited','outage')), + reason text, + retry_at text, + checked_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +-- WRITER: daemon +-- The daemon can't edit the app's commands table, so it records +-- "handled" here instead. +create table if not exists command_receipts ( + command_id text primary key references commands(id), + outcome text not null check (outcome in ('done','refused')), + reason text, + handled_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +create index if not exists sessions_status_idx on sessions(status); +create index if not exists commands_session_idx on commands(session_id); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 3144a6a..2d453ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,3 @@ -// Proposed Mon Oct 19, frozen Fri Oct 23. -// After the freeze, a change is a PR approved by every repo that consumes it, -// with a version bump and the matching stand-in updated in the same PR. - export * from "./session.js"; export * from "./runner.js"; +export * from "./local-db.js"; \ No newline at end of file diff --git a/src/local-db.ts b/src/local-db.ts new file mode 100644 index 0000000..6eafaed --- /dev/null +++ b/src/local-db.ts @@ -0,0 +1,128 @@ +// L1: local database contract for ~/.codehands/state.db (SQLite). +// Matches sql/001_init.sql. One writer per table: SQLite cannot enforce +// this, so TABLE_WRITERS is the agreement every repo follows. +// +// These are RAW ROW shapes: snake_case columns, JSON stored as text, dates as +// ISO strings. The app-facing shapes (Session, Command, Project) live in +// session.ts. Row names end in "Row" so the two never clash. + +import type { AgentKind, CommandKind, SessionStatus } from "./session.js"; + +export const LOCAL_DB_FILENAME = "state.db"; // lives in ~/.codehands/ +export const LOCAL_DB_SCHEMA_VERSION = 1; + +export type DbWriter = "app" | "daemon"; + +/** Who may write each table. Everyone else only reads. */ +export const TABLE_WRITERS = { + schema_migrations: "daemon", + projects: "app", + tasks: "app", + commands: "app", + sessions: "daemon", + events: "daemon", + provider_status: "daemon", + command_receipts: "daemon", +} as const satisfies Record; + +export type TableName = keyof typeof TABLE_WRITERS; + +// ---- written by the daemon ---- + +export interface SchemaMigrationRow { + version: number; + applied_at: string; +} + +// ---- written by the app ---- + +export interface ProjectRow { + id: string; + name: string; + /** Hash of the origin URL: the same project on every laptop. */ + repo_id: string; + /** Folder on THIS laptop only. Never synced. */ + path: string; + created_at: string; +} + +/** Model chosen per phase, e.g. { plan: "...", implement: "..." }. */ +export type TaskModels = Record; + +export interface TaskRow { + id: string; + project_id: string; + prompt: string; + agent: AgentKind; + /** JSON text of TaskModels. */ + models: string; + created_by: string; + created_at: string; +} + +export interface CommandRow { + id: string; + /** Null only for "start". */ + session_id: string | null; + project_id: string | null; + kind: CommandKind; + created_by: string; + created_at: string; +} + +// ---- written by the daemon ---- + +export interface SessionRow { + id: string; + /** Unique: a task gets at most one session, so it is claimed exactly once. */ + task_id: string; + project_id: string; + status: SessionStatus; + agent: string; + model: string; + /** Why a session failed or a task was refused. */ + reason: string | null; + cost_usd: number; + branch: string | null; + base_commit: string | null; + diff: string | null; + created_at: string; + updated_at: string; +} + +/** Append only. (session_id, seq) is the key, so a resend is harmless. */ +export interface EventRow { + session_id: string; + /** 1, 2, 3... per session. */ + seq: number; + type: string; + /** JSON text. */ + payload: string; + created_at: string; +} + +export type ProviderState = + | "ok" + | "not_installed" + | "not_logged_in" + | "rate_limited" + | "usage_limited" + | "outage"; + +export interface ProviderStatusRow { + /** "claude-code" or "codex". */ + agent: string; + state: ProviderState; + reason: string | null; + /** When a rate or usage limit lifts. */ + retry_at: string | null; + checked_at: string; +} + +/** The daemon cannot edit the app's commands table, so it records the outcome here. */ +export interface CommandReceiptRow { + command_id: string; + outcome: "done" | "refused"; + reason: string | null; + handled_at: string; +} \ No newline at end of file diff --git a/test/local-db.test.ts b/test/local-db.test.ts new file mode 100644 index 0000000..45da5b0 --- /dev/null +++ b/test/local-db.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { SESSION_TRANSITIONS, TABLE_WRITERS } from "../src/index.js"; + +const sql = readFileSync(new URL("../sql/001_init.sql", import.meta.url), "utf8"); + +describe("local db contract", () => { + it("has a SQL table for every table in TABLE_WRITERS", () => { + for (const table of Object.keys(TABLE_WRITERS)) { + expect(sql).toContain(`create table if not exists ${table} (`); + } + }); + + it("has no SQL table missing from TABLE_WRITERS", () => { + const found = [...sql.matchAll(/create table if not exists (\w+) \(/g)].map((m) => m[1]); + expect(found.sort()).toEqual(Object.keys(TABLE_WRITERS).sort()); + }); + + it("allows exactly the session statuses from session.ts", () => { + const block = sql.match(/status\s+text not null default 'queued'\s+check \(status in \(([^)]*)\)/s); + expect(block).not.toBeNull(); + const inSql = [...block![1].matchAll(/'(\w+)'/g)].map((m) => m[1]); + expect(inSql.sort()).toEqual(Object.keys(SESSION_TRANSITIONS).sort()); + }); +}); \ No newline at end of file