Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions sql/001_init.sql
Original file line number Diff line number Diff line change
@@ -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);
7 changes: 2 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
// 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 "./cloud-api.js"
export * from "./local-db.js";
export * from "./cloud-api.js"
128 changes: 128 additions & 0 deletions src/local-db.ts
Original file line number Diff line number Diff line change
@@ -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<string, DbWriter>;

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<string, string>;

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;
}
25 changes: 25 additions & 0 deletions test/local-db.test.ts
Original file line number Diff line number Diff line change
@@ -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());
});
});