From 8174de491af729a13ccfcdc2e0aff99e77de26f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 1 Mar 2026 21:57:17 +0100 Subject: [PATCH 1/6] chore(gastown): migrate DO SQLite to Drizzle ORM Replace raw SQL query() calls and Zod-based table definitions with Drizzle ORM query builder across all 3 DOs (TownDO, GastownUserDO, AgentDO) and 5 sub-modules (~110 query sites total). - Add drizzle-orm/drizzle-kit deps and sqlite-schema.ts with 11 tables - Wire drizzle() + migrate() in each DO constructor - Convert all queries to type-safe query builder (select/insert/update/delete) - Delete 19 legacy table files, query.util.ts, and table.ts - Update AGENTS.md SQL conventions for Drizzle patterns - Migration SQL uses IF NOT EXISTS for backward compatibility --- cloudflare-gastown/AGENTS.md | 19 +- cloudflare-gastown/drizzle.config.ts | 8 + .../drizzle/0000_mushy_elektra.sql | 135 +++ .../drizzle/meta/0000_snapshot.json | 853 +++++++++++++++ cloudflare-gastown/drizzle/meta/_journal.json | 13 + cloudflare-gastown/drizzle/migrations.d.ts | 5 + cloudflare-gastown/drizzle/migrations.js | 9 + cloudflare-gastown/package.json | 2 + cloudflare-gastown/src/db/sqlite-schema.ts | 272 +++++ .../src/db/tables/agent-metadata.table.ts | 47 - .../src/db/tables/bead-dependencies.table.ts | 29 - .../src/db/tables/bead-events.table.ts | 63 -- .../src/db/tables/beads.table.ts | 140 --- .../src/db/tables/convoy-metadata.table.ts | 22 - .../db/tables/escalation-metadata.table.ts | 31 - .../src/db/tables/review-metadata.table.ts | 26 - .../src/db/tables/rig-agent-events.table.ts | 34 - .../src/db/tables/rig-agents.table.ts | 44 - .../src/db/tables/rig-bead-events.table.ts | 63 -- .../src/db/tables/rig-beads.table.ts | 57 - .../src/db/tables/rig-mail.table.ts | 36 - .../src/db/tables/rig-molecules.table.ts | 30 - .../src/db/tables/rig-review-queue.table.ts | 34 - .../src/db/tables/town-convoy-beads.table.ts | 24 - .../src/db/tables/town-convoys.table.ts | 32 - .../src/db/tables/town-escalations.table.ts | 36 - .../src/db/tables/user-rigs.table.ts | 32 - .../src/db/tables/user-towns.table.ts | 24 - cloudflare-gastown/src/dos/Agent.do.ts | 114 +- cloudflare-gastown/src/dos/GastownUser.do.ts | 173 +--- cloudflare-gastown/src/dos/Town.do.ts | 979 +++++++++--------- cloudflare-gastown/src/dos/town/agents.ts | 476 ++++----- cloudflare-gastown/src/dos/town/beads.ts | 386 +++---- cloudflare-gastown/src/dos/town/mail.ts | 161 ++- .../src/dos/town/review-queue.ts | 560 +++++----- cloudflare-gastown/src/dos/town/rigs.ts | 101 +- cloudflare-gastown/src/types.ts | 36 +- cloudflare-gastown/src/util/query.util.ts | 29 - cloudflare-gastown/src/util/table.ts | 84 -- cloudflare-gastown/wrangler.jsonc | 1 + docs/migrate-gastown-do-to-drizzle.md | 320 ++++++ pnpm-lock.yaml | 9 + pnpm-workspace.yaml | 1 + 43 files changed, 2979 insertions(+), 2571 deletions(-) create mode 100644 cloudflare-gastown/drizzle.config.ts create mode 100644 cloudflare-gastown/drizzle/0000_mushy_elektra.sql create mode 100644 cloudflare-gastown/drizzle/meta/0000_snapshot.json create mode 100644 cloudflare-gastown/drizzle/meta/_journal.json create mode 100644 cloudflare-gastown/drizzle/migrations.d.ts create mode 100644 cloudflare-gastown/drizzle/migrations.js create mode 100644 cloudflare-gastown/src/db/sqlite-schema.ts delete mode 100644 cloudflare-gastown/src/db/tables/agent-metadata.table.ts delete mode 100644 cloudflare-gastown/src/db/tables/bead-dependencies.table.ts delete mode 100644 cloudflare-gastown/src/db/tables/bead-events.table.ts delete mode 100644 cloudflare-gastown/src/db/tables/beads.table.ts delete mode 100644 cloudflare-gastown/src/db/tables/convoy-metadata.table.ts delete mode 100644 cloudflare-gastown/src/db/tables/escalation-metadata.table.ts delete mode 100644 cloudflare-gastown/src/db/tables/review-metadata.table.ts delete mode 100644 cloudflare-gastown/src/db/tables/rig-agent-events.table.ts delete mode 100644 cloudflare-gastown/src/db/tables/rig-agents.table.ts delete mode 100644 cloudflare-gastown/src/db/tables/rig-bead-events.table.ts delete mode 100644 cloudflare-gastown/src/db/tables/rig-beads.table.ts delete mode 100644 cloudflare-gastown/src/db/tables/rig-mail.table.ts delete mode 100644 cloudflare-gastown/src/db/tables/rig-molecules.table.ts delete mode 100644 cloudflare-gastown/src/db/tables/rig-review-queue.table.ts delete mode 100644 cloudflare-gastown/src/db/tables/town-convoy-beads.table.ts delete mode 100644 cloudflare-gastown/src/db/tables/town-convoys.table.ts delete mode 100644 cloudflare-gastown/src/db/tables/town-escalations.table.ts delete mode 100644 cloudflare-gastown/src/db/tables/user-rigs.table.ts delete mode 100644 cloudflare-gastown/src/db/tables/user-towns.table.ts delete mode 100644 cloudflare-gastown/src/util/query.util.ts delete mode 100644 cloudflare-gastown/src/util/table.ts create mode 100644 docs/migrate-gastown-do-to-drizzle.md diff --git a/cloudflare-gastown/AGENTS.md b/cloudflare-gastown/AGENTS.md index 563a9a66f..8b66d74c3 100644 --- a/cloudflare-gastown/AGENTS.md +++ b/cloudflare-gastown/AGENTS.md @@ -20,16 +20,15 @@ ## SQL queries -- Use the type-safe `query()` helper from `util/query.util.ts` for all SQL queries. -- Prefix SQL template strings with `/* sql */` for syntax highlighting and to signal intent, e.g. `query(this.sql, /* sql */ \`SELECT ...\`, [...])`. -- Format queries for human readability: use multi-line strings with one clause per line (`SELECT`, `FROM`, `WHERE`, `SET`, etc.). -- Reference tables and columns via the table interpolator objects exported from `db/tables/*.table.ts` (created with `getTableFromZodSchema` from `util/table.ts`). Never use raw table/column name strings in queries. The interpolator has three access patterns — use the right one for context: - - `${beads}` → bare table name. Use for `FROM`, `INSERT INTO`, `DELETE FROM`. - - `${beads.columns.status}` → bare column name. Use for `SET` clauses and `INSERT` column lists where the table is already implied. - - `${beads.status}` → qualified `table.column`. Use for `SELECT`, `WHERE`, `JOIN ON`, `ORDER BY`, and anywhere a column could be ambiguous. -- Prefer static queries over dynamically constructed ones. Move conditional logic into the query itself using SQL constructs like `COALESCE`, `CASE`, `NULLIF`, or `WHERE (? IS NULL OR col = ?)` patterns so the full query is always visible as a single readable string. -- Always parse query results with the Zod `Record` schemas from `db/tables/*.table.ts`. Never use ad-hoc `as Record` casts or `String(row.col)` to extract fields — use `.pick()` for partial selects and `.array()` for lists, e.g. `BeadRecord.pick({ bead_id: true }).array().parse(rows)`. This keeps row parsing type-safe and co-located with the schema definition. -- When a column has a SQL `CHECK` constraint that restricts it to a set of values (i.e. an enum), mirror that in the Record schema using `z.enum()` rather than `z.string()`, e.g. `role: z.enum(['polecat', 'refinery', 'mayor', 'witness'])`. +- Use the Drizzle query builder (`db.select()`, `db.insert()`, `db.update()`, `db.delete()`) for all database operations. Do not use raw SQL strings. +- Import table objects from `db/sqlite-schema.ts`. Reference columns via the table object (e.g. `beads.bead_id`, `agent_metadata.status`). +- Use `$inferSelect` / `$inferInsert` types from `db/sqlite-schema.ts` for row types. Do not define ad-hoc row types or use Zod schemas for DB result parsing. +- For JSON columns stored as `text` (`labels`, `metadata`, `config`, `checkpoint`, `data`), parse with `JSON.parse()` after reading and serialize with `JSON.stringify()` before writing. +- Use `.get()` for single-row results, `.all()` for multi-row results, `.run()` for write operations. +- Use `eq()`, `and()`, `or()`, `inArray()`, `gt()`, `lt()`, `isNull()`, `isNotNull()` from `drizzle-orm` for WHERE conditions. +- Use `.innerJoin(table, condition)` for joins. +- For conditional filters, build a `conditions: SQL[]` array and pass to `and(...conditions)`. +- Reference `docs/do-sqlite-drizzle.md` for the drizzle migration workflow (schema changes, generating migrations). ## HTTP routes diff --git a/cloudflare-gastown/drizzle.config.ts b/cloudflare-gastown/drizzle.config.ts new file mode 100644 index 000000000..27cf9ffb7 --- /dev/null +++ b/cloudflare-gastown/drizzle.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + out: './drizzle', + schema: './src/db/sqlite-schema.ts', + dialect: 'sqlite', + driver: 'durable-sqlite', +}); diff --git a/cloudflare-gastown/drizzle/0000_mushy_elektra.sql b/cloudflare-gastown/drizzle/0000_mushy_elektra.sql new file mode 100644 index 000000000..32d24c6b8 --- /dev/null +++ b/cloudflare-gastown/drizzle/0000_mushy_elektra.sql @@ -0,0 +1,135 @@ +CREATE TABLE IF NOT EXISTS `agent_metadata` ( + `bead_id` text PRIMARY KEY NOT NULL, + `role` text NOT NULL, + `identity` text NOT NULL, + `container_process_id` text, + `status` text DEFAULT 'idle' NOT NULL, + `current_hook_bead_id` text, + `dispatch_attempts` integer DEFAULT 0 NOT NULL, + `checkpoint` text, + `last_activity_at` text, + FOREIGN KEY (`bead_id`) REFERENCES `beads`(`bead_id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`current_hook_bead_id`) REFERENCES `beads`(`bead_id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "check_agent_metadata_role" CHECK("agent_metadata"."role" in ('polecat', 'refinery', 'mayor', 'witness')), + CONSTRAINT "check_agent_metadata_status" CHECK("agent_metadata"."status" in ('idle', 'working', 'stalled', 'dead')) +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `agent_metadata_identity_unique` ON `agent_metadata` (`identity`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `bead_dependencies` ( + `bead_id` text NOT NULL, + `depends_on_bead_id` text NOT NULL, + `dependency_type` text DEFAULT 'blocks' NOT NULL, + FOREIGN KEY (`bead_id`) REFERENCES `beads`(`bead_id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`depends_on_bead_id`) REFERENCES `beads`(`bead_id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "check_bead_deps_type" CHECK("bead_dependencies"."dependency_type" in ('blocks', 'tracks', 'parent-child')) +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `idx_bead_deps_pk` ON `bead_dependencies` (`bead_id`,`depends_on_bead_id`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bead_deps_depends_on` ON `bead_dependencies` (`depends_on_bead_id`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `bead_events` ( + `bead_event_id` text PRIMARY KEY NOT NULL, + `bead_id` text NOT NULL, + `agent_id` text, + `event_type` text NOT NULL, + `old_value` text, + `new_value` text, + `metadata` text DEFAULT '{}', + `created_at` text NOT NULL +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bead_events_bead` ON `bead_events` (`bead_id`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bead_events_created` ON `bead_events` (`created_at`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bead_events_type` ON `bead_events` (`event_type`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `beads` ( + `bead_id` text PRIMARY KEY NOT NULL, + `type` text NOT NULL, + `status` text DEFAULT 'open' NOT NULL, + `title` text NOT NULL, + `body` text, + `rig_id` text, + `parent_bead_id` text, + `assignee_agent_bead_id` text, + `priority` text DEFAULT 'medium', + `labels` text DEFAULT '[]', + `metadata` text DEFAULT '{}', + `created_by` text, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + `closed_at` text, + FOREIGN KEY (`parent_bead_id`) REFERENCES `beads`(`bead_id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "check_beads_type" CHECK("beads"."type" in ('issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent')), + CONSTRAINT "check_beads_status" CHECK("beads"."status" in ('open', 'in_progress', 'closed', 'failed')), + CONSTRAINT "check_beads_priority" CHECK("beads"."priority" in ('low', 'medium', 'high', 'critical')) +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_beads_type_status` ON `beads` (`type`,`status`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_beads_parent` ON `beads` (`parent_bead_id`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_beads_rig_status` ON `beads` (`rig_id`,`status`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_beads_assignee` ON `beads` (`assignee_agent_bead_id`,`type`,`status`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `convoy_metadata` ( + `bead_id` text PRIMARY KEY NOT NULL, + `total_beads` integer DEFAULT 0 NOT NULL, + `closed_beads` integer DEFAULT 0 NOT NULL, + `landed_at` text, + FOREIGN KEY (`bead_id`) REFERENCES `beads`(`bead_id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `escalation_metadata` ( + `bead_id` text PRIMARY KEY NOT NULL, + `severity` text NOT NULL, + `category` text, + `acknowledged` integer DEFAULT 0 NOT NULL, + `re_escalation_count` integer DEFAULT 0 NOT NULL, + `acknowledged_at` text, + FOREIGN KEY (`bead_id`) REFERENCES `beads`(`bead_id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "check_escalation_severity" CHECK("escalation_metadata"."severity" in ('low', 'medium', 'high', 'critical')) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `review_metadata` ( + `bead_id` text PRIMARY KEY NOT NULL, + `branch` text NOT NULL, + `target_branch` text DEFAULT 'main' NOT NULL, + `merge_commit` text, + `pr_url` text, + `retry_count` integer DEFAULT 0, + FOREIGN KEY (`bead_id`) REFERENCES `beads`(`bead_id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `rig_agent_events` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `agent_id` text NOT NULL, + `event_type` text NOT NULL, + `data` text DEFAULT '{}' NOT NULL, + `created_at` text NOT NULL +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_rig_agent_events_agent_id` ON `rig_agent_events` (`agent_id`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_rig_agent_events_agent_created` ON `rig_agent_events` (`agent_id`,`id`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `rigs` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `git_url` text DEFAULT '' NOT NULL, + `default_branch` text DEFAULT 'main' NOT NULL, + `config` text DEFAULT '{}', + `created_at` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `idx_rigs_name` ON `rigs` (`name`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `user_rigs` ( + `id` text PRIMARY KEY NOT NULL, + `town_id` text NOT NULL, + `name` text NOT NULL, + `git_url` text NOT NULL, + `default_branch` text DEFAULT 'main' NOT NULL, + `platform_integration_id` text, + `created_at` text NOT NULL, + `updated_at` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `user_towns` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `owner_user_id` text NOT NULL, + `created_at` text NOT NULL, + `updated_at` text NOT NULL +); diff --git a/cloudflare-gastown/drizzle/meta/0000_snapshot.json b/cloudflare-gastown/drizzle/meta/0000_snapshot.json new file mode 100644 index 000000000..46e01c6d3 --- /dev/null +++ b/cloudflare-gastown/drizzle/meta/0000_snapshot.json @@ -0,0 +1,853 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ce126ed7-fbd1-4d8d-91de-5c9114db2a0b", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "agent_metadata": { + "name": "agent_metadata", + "columns": { + "bead_id": { + "name": "bead_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "container_process_id": { + "name": "container_process_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "current_hook_bead_id": { + "name": "current_hook_bead_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dispatch_attempts": { + "name": "dispatch_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "checkpoint": { + "name": "checkpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "agent_metadata_identity_unique": { + "name": "agent_metadata_identity_unique", + "columns": ["identity"], + "isUnique": true + } + }, + "foreignKeys": { + "agent_metadata_bead_id_beads_bead_id_fk": { + "name": "agent_metadata_bead_id_beads_bead_id_fk", + "tableFrom": "agent_metadata", + "tableTo": "beads", + "columnsFrom": ["bead_id"], + "columnsTo": ["bead_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_metadata_current_hook_bead_id_beads_bead_id_fk": { + "name": "agent_metadata_current_hook_bead_id_beads_bead_id_fk", + "tableFrom": "agent_metadata", + "tableTo": "beads", + "columnsFrom": ["current_hook_bead_id"], + "columnsTo": ["bead_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "check_agent_metadata_role": { + "name": "check_agent_metadata_role", + "value": "\"agent_metadata\".\"role\" in ('polecat', 'refinery', 'mayor', 'witness')" + }, + "check_agent_metadata_status": { + "name": "check_agent_metadata_status", + "value": "\"agent_metadata\".\"status\" in ('idle', 'working', 'stalled', 'dead')" + } + } + }, + "bead_dependencies": { + "name": "bead_dependencies", + "columns": { + "bead_id": { + "name": "bead_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "depends_on_bead_id": { + "name": "depends_on_bead_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dependency_type": { + "name": "dependency_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'blocks'" + } + }, + "indexes": { + "idx_bead_deps_pk": { + "name": "idx_bead_deps_pk", + "columns": ["bead_id", "depends_on_bead_id"], + "isUnique": true + }, + "idx_bead_deps_depends_on": { + "name": "idx_bead_deps_depends_on", + "columns": ["depends_on_bead_id"], + "isUnique": false + } + }, + "foreignKeys": { + "bead_dependencies_bead_id_beads_bead_id_fk": { + "name": "bead_dependencies_bead_id_beads_bead_id_fk", + "tableFrom": "bead_dependencies", + "tableTo": "beads", + "columnsFrom": ["bead_id"], + "columnsTo": ["bead_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bead_dependencies_depends_on_bead_id_beads_bead_id_fk": { + "name": "bead_dependencies_depends_on_bead_id_beads_bead_id_fk", + "tableFrom": "bead_dependencies", + "tableTo": "beads", + "columnsFrom": ["depends_on_bead_id"], + "columnsTo": ["bead_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "check_bead_deps_type": { + "name": "check_bead_deps_type", + "value": "\"bead_dependencies\".\"dependency_type\" in ('blocks', 'tracks', 'parent-child')" + } + } + }, + "bead_events": { + "name": "bead_events", + "columns": { + "bead_event_id": { + "name": "bead_event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bead_id": { + "name": "bead_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_bead_events_bead": { + "name": "idx_bead_events_bead", + "columns": ["bead_id"], + "isUnique": false + }, + "idx_bead_events_created": { + "name": "idx_bead_events_created", + "columns": ["created_at"], + "isUnique": false + }, + "idx_bead_events_type": { + "name": "idx_bead_events_type", + "columns": ["event_type"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "beads": { + "name": "beads", + "columns": { + "bead_id": { + "name": "bead_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rig_id": { + "name": "rig_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_bead_id": { + "name": "parent_bead_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assignee_agent_bead_id": { + "name": "assignee_agent_bead_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'medium'" + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_beads_type_status": { + "name": "idx_beads_type_status", + "columns": ["type", "status"], + "isUnique": false + }, + "idx_beads_parent": { + "name": "idx_beads_parent", + "columns": ["parent_bead_id"], + "isUnique": false + }, + "idx_beads_rig_status": { + "name": "idx_beads_rig_status", + "columns": ["rig_id", "status"], + "isUnique": false + }, + "idx_beads_assignee": { + "name": "idx_beads_assignee", + "columns": ["assignee_agent_bead_id", "type", "status"], + "isUnique": false + } + }, + "foreignKeys": { + "beads_parent_bead_id_beads_bead_id_fk": { + "name": "beads_parent_bead_id_beads_bead_id_fk", + "tableFrom": "beads", + "tableTo": "beads", + "columnsFrom": ["parent_bead_id"], + "columnsTo": ["bead_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "check_beads_type": { + "name": "check_beads_type", + "value": "\"beads\".\"type\" in ('issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent')" + }, + "check_beads_status": { + "name": "check_beads_status", + "value": "\"beads\".\"status\" in ('open', 'in_progress', 'closed', 'failed')" + }, + "check_beads_priority": { + "name": "check_beads_priority", + "value": "\"beads\".\"priority\" in ('low', 'medium', 'high', 'critical')" + } + } + }, + "convoy_metadata": { + "name": "convoy_metadata", + "columns": { + "bead_id": { + "name": "bead_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "total_beads": { + "name": "total_beads", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "closed_beads": { + "name": "closed_beads", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "landed_at": { + "name": "landed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "convoy_metadata_bead_id_beads_bead_id_fk": { + "name": "convoy_metadata_bead_id_beads_bead_id_fk", + "tableFrom": "convoy_metadata", + "tableTo": "beads", + "columnsFrom": ["bead_id"], + "columnsTo": ["bead_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "escalation_metadata": { + "name": "escalation_metadata", + "columns": { + "bead_id": { + "name": "bead_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acknowledged": { + "name": "acknowledged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "re_escalation_count": { + "name": "re_escalation_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "escalation_metadata_bead_id_beads_bead_id_fk": { + "name": "escalation_metadata_bead_id_beads_bead_id_fk", + "tableFrom": "escalation_metadata", + "tableTo": "beads", + "columnsFrom": ["bead_id"], + "columnsTo": ["bead_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "check_escalation_severity": { + "name": "check_escalation_severity", + "value": "\"escalation_metadata\".\"severity\" in ('low', 'medium', 'high', 'critical')" + } + } + }, + "review_metadata": { + "name": "review_metadata", + "columns": { + "bead_id": { + "name": "bead_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_branch": { + "name": "target_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'main'" + }, + "merge_commit": { + "name": "merge_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "review_metadata_bead_id_beads_bead_id_fk": { + "name": "review_metadata_bead_id_beads_bead_id_fk", + "tableFrom": "review_metadata", + "tableTo": "beads", + "columnsFrom": ["bead_id"], + "columnsTo": ["bead_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rig_agent_events": { + "name": "rig_agent_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_rig_agent_events_agent_id": { + "name": "idx_rig_agent_events_agent_id", + "columns": ["agent_id"], + "isUnique": false + }, + "idx_rig_agent_events_agent_created": { + "name": "idx_rig_agent_events_agent_created", + "columns": ["agent_id", "id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rigs": { + "name": "rigs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_url": { + "name": "git_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'main'" + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_rigs_name": { + "name": "idx_rigs_name", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_rigs": { + "name": "user_rigs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "town_id": { + "name": "town_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_url": { + "name": "git_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'main'" + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_towns": { + "name": "user_towns", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/cloudflare-gastown/drizzle/meta/_journal.json b/cloudflare-gastown/drizzle/meta/_journal.json new file mode 100644 index 000000000..6d61a92d3 --- /dev/null +++ b/cloudflare-gastown/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1772390800659, + "tag": "0000_mushy_elektra", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/cloudflare-gastown/drizzle/migrations.d.ts b/cloudflare-gastown/drizzle/migrations.d.ts new file mode 100644 index 000000000..69273ba91 --- /dev/null +++ b/cloudflare-gastown/drizzle/migrations.d.ts @@ -0,0 +1,5 @@ +declare const migrations: { + journal: { entries: { idx: number; when: number; tag: string; breakpoints: boolean }[] }; + migrations: Record; +}; +export default migrations; diff --git a/cloudflare-gastown/drizzle/migrations.js b/cloudflare-gastown/drizzle/migrations.js new file mode 100644 index 000000000..91f1d74b1 --- /dev/null +++ b/cloudflare-gastown/drizzle/migrations.js @@ -0,0 +1,9 @@ +import journal from './meta/_journal.json'; +import m0000 from './0000_mushy_elektra.sql'; + +export default { + journal, + migrations: { + m0000, + }, +}; diff --git a/cloudflare-gastown/package.json b/cloudflare-gastown/package.json index 3872c2cd2..c0a002cfb 100644 --- a/cloudflare-gastown/package.json +++ b/cloudflare-gastown/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@cloudflare/containers": "^0.1.0", + "drizzle-orm": "catalog:", "hono": "catalog:", "itty-time": "^1.0.6", "jsonwebtoken": "catalog:", @@ -29,6 +30,7 @@ "@types/jsonwebtoken": "catalog:", "@types/node": "^22", "@typescript/native-preview": "7.0.0-dev.20251019.1", + "drizzle-kit": "catalog:", "typescript": "catalog:", "vitest": "^3.2.4", "wrangler": "catalog:" diff --git a/cloudflare-gastown/src/db/sqlite-schema.ts b/cloudflare-gastown/src/db/sqlite-schema.ts new file mode 100644 index 000000000..6644531bc --- /dev/null +++ b/cloudflare-gastown/src/db/sqlite-schema.ts @@ -0,0 +1,272 @@ +import { sql } from 'drizzle-orm'; +import { sqliteTable, text, integer, index, uniqueIndex, check } from 'drizzle-orm/sqlite-core'; + +// --------------------------------------------------------------------------- +// TownDO tables +// --------------------------------------------------------------------------- + +// 1. beads +export const beads = sqliteTable( + 'beads', + { + bead_id: text('bead_id').primaryKey(), + type: text('type', { + enum: ['issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent'], + }).notNull(), + status: text('status', { + enum: ['open', 'in_progress', 'closed', 'failed'], + }) + .notNull() + .default('open'), + title: text('title').notNull(), + body: text('body'), + rig_id: text('rig_id'), + parent_bead_id: text('parent_bead_id').references((): any => beads.bead_id), + assignee_agent_bead_id: text('assignee_agent_bead_id'), + priority: text('priority', { + enum: ['low', 'medium', 'high', 'critical'], + }).default('medium'), + labels: text('labels').default('[]'), + metadata: text('metadata').default('{}'), + created_by: text('created_by'), + created_at: text('created_at').notNull(), + updated_at: text('updated_at').notNull(), + closed_at: text('closed_at'), + }, + table => [ + index('idx_beads_type_status').on(table.type, table.status), + index('idx_beads_parent').on(table.parent_bead_id), + index('idx_beads_rig_status').on(table.rig_id, table.status), + index('idx_beads_assignee').on(table.assignee_agent_bead_id, table.type, table.status), + check( + 'check_beads_type', + sql`${table.type} in ('issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent')` + ), + check( + 'check_beads_status', + sql`${table.status} in ('open', 'in_progress', 'closed', 'failed')` + ), + check('check_beads_priority', sql`${table.priority} in ('low', 'medium', 'high', 'critical')`), + ] +); + +// 2. bead_events +export const bead_events = sqliteTable( + 'bead_events', + { + bead_event_id: text('bead_event_id').primaryKey(), + bead_id: text('bead_id').notNull(), + agent_id: text('agent_id'), + event_type: text('event_type').notNull(), + old_value: text('old_value'), + new_value: text('new_value'), + metadata: text('metadata').default('{}'), + created_at: text('created_at').notNull(), + }, + table => [ + index('idx_bead_events_bead').on(table.bead_id), + index('idx_bead_events_created').on(table.created_at), + index('idx_bead_events_type').on(table.event_type), + ] +); + +// 3. bead_dependencies (no explicit primary key) +export const bead_dependencies = sqliteTable( + 'bead_dependencies', + { + bead_id: text('bead_id') + .notNull() + .references(() => beads.bead_id), + depends_on_bead_id: text('depends_on_bead_id') + .notNull() + .references(() => beads.bead_id), + dependency_type: text('dependency_type', { + enum: ['blocks', 'tracks', 'parent-child'], + }) + .notNull() + .default('blocks'), + }, + table => [ + uniqueIndex('idx_bead_deps_pk').on(table.bead_id, table.depends_on_bead_id), + index('idx_bead_deps_depends_on').on(table.depends_on_bead_id), + check( + 'check_bead_deps_type', + sql`${table.dependency_type} in ('blocks', 'tracks', 'parent-child')` + ), + ] +); + +// 4. agent_metadata +export const agent_metadata = sqliteTable( + 'agent_metadata', + { + bead_id: text('bead_id') + .primaryKey() + .references(() => beads.bead_id), + role: text('role', { + enum: ['polecat', 'refinery', 'mayor', 'witness'], + }).notNull(), + identity: text('identity').notNull().unique(), + container_process_id: text('container_process_id'), + status: text('status', { + enum: ['idle', 'working', 'stalled', 'dead'], + }) + .notNull() + .default('idle'), + current_hook_bead_id: text('current_hook_bead_id').references(() => beads.bead_id), + dispatch_attempts: integer('dispatch_attempts').notNull().default(0), + checkpoint: text('checkpoint'), + last_activity_at: text('last_activity_at'), + }, + table => [ + check( + 'check_agent_metadata_role', + sql`${table.role} in ('polecat', 'refinery', 'mayor', 'witness')` + ), + check( + 'check_agent_metadata_status', + sql`${table.status} in ('idle', 'working', 'stalled', 'dead')` + ), + ] +); + +// 5. review_metadata +export const review_metadata = sqliteTable('review_metadata', { + bead_id: text('bead_id') + .primaryKey() + .references(() => beads.bead_id), + branch: text('branch').notNull(), + target_branch: text('target_branch').notNull().default('main'), + merge_commit: text('merge_commit'), + pr_url: text('pr_url'), + retry_count: integer('retry_count').default(0), +}); + +// 6. escalation_metadata +export const escalation_metadata = sqliteTable( + 'escalation_metadata', + { + bead_id: text('bead_id') + .primaryKey() + .references(() => beads.bead_id), + severity: text('severity', { + enum: ['low', 'medium', 'high', 'critical'], + }).notNull(), + category: text('category'), + acknowledged: integer('acknowledged').notNull().default(0), + re_escalation_count: integer('re_escalation_count').notNull().default(0), + acknowledged_at: text('acknowledged_at'), + }, + table => [ + check( + 'check_escalation_severity', + sql`${table.severity} in ('low', 'medium', 'high', 'critical')` + ), + ] +); + +// 7. convoy_metadata +export const convoy_metadata = sqliteTable('convoy_metadata', { + bead_id: text('bead_id') + .primaryKey() + .references(() => beads.bead_id), + total_beads: integer('total_beads').notNull().default(0), + closed_beads: integer('closed_beads').notNull().default(0), + landed_at: text('landed_at'), +}); + +// 8. rigs +export const rigs = sqliteTable( + 'rigs', + { + id: text('id').primaryKey(), + name: text('name').notNull(), + git_url: text('git_url').notNull().default(''), + default_branch: text('default_branch').notNull().default('main'), + config: text('config').default('{}'), + created_at: text('created_at').notNull(), + }, + table => [uniqueIndex('idx_rigs_name').on(table.name)] +); + +// --------------------------------------------------------------------------- +// GastownUserDO tables +// --------------------------------------------------------------------------- + +// 9. user_towns +export const user_towns = sqliteTable('user_towns', { + id: text('id').primaryKey(), + name: text('name').notNull(), + owner_user_id: text('owner_user_id').notNull(), + created_at: text('created_at').notNull(), + updated_at: text('updated_at').notNull(), +}); + +// 10. user_rigs +export const user_rigs = sqliteTable('user_rigs', { + id: text('id').primaryKey(), + town_id: text('town_id').notNull(), + name: text('name').notNull(), + git_url: text('git_url').notNull(), + default_branch: text('default_branch').notNull().default('main'), + platform_integration_id: text('platform_integration_id'), + created_at: text('created_at').notNull(), + updated_at: text('updated_at').notNull(), +}); + +// --------------------------------------------------------------------------- +// AgentDO tables +// --------------------------------------------------------------------------- + +// 11. rig_agent_events +export const rig_agent_events = sqliteTable( + 'rig_agent_events', + { + id: integer('id', { mode: 'number' }).primaryKey({ autoIncrement: true }), + agent_id: text('agent_id').notNull(), + event_type: text('event_type').notNull(), + data: text('data').notNull().default('{}'), + created_at: text('created_at').notNull(), + }, + table => [ + index('idx_rig_agent_events_agent_id').on(table.agent_id), + index('idx_rig_agent_events_agent_created').on(table.agent_id, table.id), + ] +); + +// --------------------------------------------------------------------------- +// Inferred types +// --------------------------------------------------------------------------- + +export type BeadsSelect = typeof beads.$inferSelect; +export type BeadsInsert = typeof beads.$inferInsert; + +export type BeadEventsSelect = typeof bead_events.$inferSelect; +export type BeadEventsInsert = typeof bead_events.$inferInsert; + +export type BeadDependenciesSelect = typeof bead_dependencies.$inferSelect; +export type BeadDependenciesInsert = typeof bead_dependencies.$inferInsert; + +export type AgentMetadataSelect = typeof agent_metadata.$inferSelect; +export type AgentMetadataInsert = typeof agent_metadata.$inferInsert; + +export type ReviewMetadataSelect = typeof review_metadata.$inferSelect; +export type ReviewMetadataInsert = typeof review_metadata.$inferInsert; + +export type EscalationMetadataSelect = typeof escalation_metadata.$inferSelect; +export type EscalationMetadataInsert = typeof escalation_metadata.$inferInsert; + +export type ConvoyMetadataSelect = typeof convoy_metadata.$inferSelect; +export type ConvoyMetadataInsert = typeof convoy_metadata.$inferInsert; + +export type RigsSelect = typeof rigs.$inferSelect; +export type RigsInsert = typeof rigs.$inferInsert; + +export type UserTownsSelect = typeof user_towns.$inferSelect; +export type UserTownsInsert = typeof user_towns.$inferInsert; + +export type UserRigsSelect = typeof user_rigs.$inferSelect; +export type UserRigsInsert = typeof user_rigs.$inferInsert; + +export type RigAgentEventsSelect = typeof rig_agent_events.$inferSelect; +export type RigAgentEventsInsert = typeof rig_agent_events.$inferInsert; diff --git a/cloudflare-gastown/src/db/tables/agent-metadata.table.ts b/cloudflare-gastown/src/db/tables/agent-metadata.table.ts deleted file mode 100644 index c1abf1546..000000000 --- a/cloudflare-gastown/src/db/tables/agent-metadata.table.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; - -const AgentRole = z.enum(['polecat', 'refinery', 'mayor', 'witness']); -const AgentProcessStatus = z.enum(['idle', 'working', 'stalled', 'dead']); - -export const AgentMetadataRecord = z.object({ - bead_id: z.string(), - role: AgentRole, - identity: z.string(), - container_process_id: z.string().nullable(), - status: AgentProcessStatus, - current_hook_bead_id: z.string().nullable(), - dispatch_attempts: z.number().default(0), - checkpoint: z - .string() - .nullable() - .transform((v, ctx) => { - if (v === null) return null; - try { - return JSON.parse(v); - } catch { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid JSON in checkpoint' }); - return null; - } - }) - .pipe(z.unknown()), - last_activity_at: z.string().nullable(), -}); - -export type AgentMetadataRecord = z.output; - -export const agent_metadata = getTableFromZodSchema('agent_metadata', AgentMetadataRecord); - -export function createTableAgentMetadata(): string { - return getCreateTableQueryFromTable(agent_metadata, { - bead_id: `text primary key references beads(bead_id)`, - role: `text not null check(role in ('polecat', 'refinery', 'mayor', 'witness'))`, - identity: `text not null unique`, - container_process_id: `text`, - status: `text not null default 'idle' check(status in ('idle', 'working', 'stalled', 'dead'))`, - current_hook_bead_id: `text references beads(bead_id)`, - dispatch_attempts: `integer not null default 0`, - checkpoint: `text`, - last_activity_at: `text`, - }); -} diff --git a/cloudflare-gastown/src/db/tables/bead-dependencies.table.ts b/cloudflare-gastown/src/db/tables/bead-dependencies.table.ts deleted file mode 100644 index 8a739ee42..000000000 --- a/cloudflare-gastown/src/db/tables/bead-dependencies.table.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; - -export const DependencyType = z.enum(['blocks', 'tracks', 'parent-child']); - -export const BeadDependencyRecord = z.object({ - bead_id: z.string(), - depends_on_bead_id: z.string(), - dependency_type: DependencyType, -}); - -export type BeadDependencyRecord = z.output; - -export const bead_dependencies = getTableFromZodSchema('bead_dependencies', BeadDependencyRecord); - -export function createTableBeadDependencies(): string { - return getCreateTableQueryFromTable(bead_dependencies, { - bead_id: `text not null references beads(bead_id)`, - depends_on_bead_id: `text not null references beads(bead_id)`, - dependency_type: `text not null default 'blocks' check(dependency_type in ('blocks', 'tracks', 'parent-child'))`, - }); -} - -export function getIndexesBeadDependencies(): string[] { - return [ - `CREATE UNIQUE INDEX IF NOT EXISTS idx_bead_deps_pk ON ${bead_dependencies}(${bead_dependencies.columns.bead_id}, ${bead_dependencies.columns.depends_on_bead_id})`, - `CREATE INDEX IF NOT EXISTS idx_bead_deps_depends_on ON ${bead_dependencies}(${bead_dependencies.columns.depends_on_bead_id})`, - ]; -} diff --git a/cloudflare-gastown/src/db/tables/bead-events.table.ts b/cloudflare-gastown/src/db/tables/bead-events.table.ts deleted file mode 100644 index bf2aaa4ad..000000000 --- a/cloudflare-gastown/src/db/tables/bead-events.table.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; - -export const BeadEventType = z.enum([ - 'created', - 'assigned', - 'hooked', - 'unhooked', - 'status_changed', - 'closed', - 'escalated', - 'notification_failed', - 'mail_sent', - 'review_submitted', - 'review_completed', - 'agent_spawned', - 'agent_exited', -]); - -export type BeadEventType = z.infer; - -export const BeadEventRecord = z.object({ - bead_event_id: z.string().default(() => crypto.randomUUID()), - bead_id: z.string(), - agent_id: z.string().nullable(), - event_type: BeadEventType, - old_value: z.string().nullable(), - new_value: z.string().nullable(), - metadata: z.string().transform((v, ctx): Record => { - try { - return JSON.parse(v); - } catch { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid JSON in metadata' }); - return {}; - } - }), - created_at: z.string(), -}); - -export type BeadEventRecord = z.output; - -export const bead_events = getTableFromZodSchema('bead_events', BeadEventRecord); - -export function createTableBeadEvents(): string { - return getCreateTableQueryFromTable(bead_events, { - bead_event_id: `text primary key`, - bead_id: `text not null`, - agent_id: `text`, - event_type: `text not null`, - old_value: `text`, - new_value: `text`, - metadata: `text default '{}'`, - created_at: `text not null`, - }); -} - -export function getIndexesBeadEvents(): string[] { - return [ - `CREATE INDEX IF NOT EXISTS idx_bead_events_bead ON ${bead_events}(${bead_events.columns.bead_id})`, - `CREATE INDEX IF NOT EXISTS idx_bead_events_created ON ${bead_events}(${bead_events.columns.created_at})`, - `CREATE INDEX IF NOT EXISTS idx_bead_events_type ON ${bead_events}(${bead_events.columns.event_type})`, - ]; -} diff --git a/cloudflare-gastown/src/db/tables/beads.table.ts b/cloudflare-gastown/src/db/tables/beads.table.ts deleted file mode 100644 index 4b0bbdb4b..000000000 --- a/cloudflare-gastown/src/db/tables/beads.table.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; -import { AgentMetadataRecord } from './agent-metadata.table'; -import { ReviewMetadataRecord } from './review-metadata.table'; -import { EscalationMetadataRecord } from './escalation-metadata.table'; -import { ConvoyMetadataRecord } from './convoy-metadata.table'; - -export const BeadType = z.enum([ - 'issue', - 'message', - 'escalation', - 'merge_request', - 'convoy', - 'molecule', - 'agent', -]); - -export const BeadStatus = z.enum(['open', 'in_progress', 'closed', 'failed']); -export const BeadPriority = z.enum(['low', 'medium', 'high', 'critical']); - -export const BeadRecord = z.object({ - bead_id: z.string(), - type: BeadType, - status: BeadStatus, - title: z.string(), - body: z.string().nullable(), - rig_id: z.string().nullable(), - parent_bead_id: z.string().nullable(), - assignee_agent_bead_id: z.string().nullable(), - priority: BeadPriority, - labels: z - .string() - .transform((v, ctx) => { - try { - return JSON.parse(v); - } catch { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid JSON in labels' }); - return []; - } - }) - .pipe(z.array(z.string())), - metadata: z - .string() - .transform((v, ctx) => { - try { - return JSON.parse(v); - } catch { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid JSON in metadata' }); - return {}; - } - }) - .pipe(z.record(z.string(), z.unknown())), - created_by: z.string().nullable(), - created_at: z.string(), - updated_at: z.string(), - closed_at: z.string().nullable(), -}); - -export type BeadRecord = z.output; - -// ── Per-type bead + metadata schemas ──────────────────────────────── -// Each narrows the `type` discriminant to a literal and extends with -// the satellite metadata columns. Use these to parse JOIN query results. - -export const IssueBeadRecord = BeadRecord.extend({ type: z.literal('issue') }); -export type IssueBeadRecord = z.output; - -export const MessageBeadRecord = BeadRecord.extend({ type: z.literal('message') }); -export type MessageBeadRecord = z.output; - -export const MoleculeBeadRecord = BeadRecord.extend({ type: z.literal('molecule') }); -export type MoleculeBeadRecord = z.output; - -export const AgentBeadRecord = BeadRecord.extend({ - type: z.literal('agent'), - ...AgentMetadataRecord.shape, -}); -export type AgentBeadRecord = z.output; - -export const MergeRequestBeadRecord = BeadRecord.extend({ - type: z.literal('merge_request'), - ...ReviewMetadataRecord.shape, -}); -export type MergeRequestBeadRecord = z.output; - -export const EscalationBeadRecord = BeadRecord.extend({ - type: z.literal('escalation'), - ...EscalationMetadataRecord.shape, -}); -export type EscalationBeadRecord = z.output; - -export const ConvoyBeadRecord = BeadRecord.extend({ - type: z.literal('convoy'), - ...ConvoyMetadataRecord.shape, -}); -export type ConvoyBeadRecord = z.output; - -export const BeadRecordWithMetadata = z.discriminatedUnion('type', [ - IssueBeadRecord, - MessageBeadRecord, - MoleculeBeadRecord, - AgentBeadRecord, - MergeRequestBeadRecord, - EscalationBeadRecord, - ConvoyBeadRecord, -]); -export type BeadRecordWithMetadata = z.output; - -// ── Table definition ──────────────────────────────────────────────── - -export const beads = getTableFromZodSchema('beads', BeadRecord); - -export function createTableBeads(): string { - return getCreateTableQueryFromTable(beads, { - bead_id: `text primary key`, - type: `text not null check(type in ('issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent'))`, - status: `text not null default 'open' check(status in ('open', 'in_progress', 'closed', 'failed'))`, - title: `text not null`, - body: `text`, - rig_id: `text`, - parent_bead_id: `text references beads(bead_id)`, - assignee_agent_bead_id: `text`, - priority: `text default 'medium' check(priority in ('low', 'medium', 'high', 'critical'))`, - labels: `text default '[]'`, - metadata: `text default '{}'`, - created_by: `text`, - created_at: `text not null`, - updated_at: `text not null`, - closed_at: `text`, - }); -} - -export function getIndexesBeads(): string[] { - return [ - `CREATE INDEX IF NOT EXISTS idx_beads_type_status ON ${beads}(${beads.columns.type}, ${beads.columns.status})`, - `CREATE INDEX IF NOT EXISTS idx_beads_parent ON ${beads}(${beads.columns.parent_bead_id})`, - `CREATE INDEX IF NOT EXISTS idx_beads_rig_status ON ${beads}(${beads.columns.rig_id}, ${beads.columns.status})`, - `CREATE INDEX IF NOT EXISTS idx_beads_assignee ON ${beads}(${beads.columns.assignee_agent_bead_id}, ${beads.columns.type}, ${beads.columns.status})`, - ]; -} diff --git a/cloudflare-gastown/src/db/tables/convoy-metadata.table.ts b/cloudflare-gastown/src/db/tables/convoy-metadata.table.ts deleted file mode 100644 index 4c0deb280..000000000 --- a/cloudflare-gastown/src/db/tables/convoy-metadata.table.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; - -export const ConvoyMetadataRecord = z.object({ - bead_id: z.string(), - total_beads: z.number(), - closed_beads: z.number(), - landed_at: z.string().nullable(), -}); - -export type ConvoyMetadataRecord = z.output; - -export const convoy_metadata = getTableFromZodSchema('convoy_metadata', ConvoyMetadataRecord); - -export function createTableConvoyMetadata(): string { - return getCreateTableQueryFromTable(convoy_metadata, { - bead_id: `text primary key references beads(bead_id)`, - total_beads: `integer not null default 0`, - closed_beads: `integer not null default 0`, - landed_at: `text`, - }); -} diff --git a/cloudflare-gastown/src/db/tables/escalation-metadata.table.ts b/cloudflare-gastown/src/db/tables/escalation-metadata.table.ts deleted file mode 100644 index b62957f96..000000000 --- a/cloudflare-gastown/src/db/tables/escalation-metadata.table.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; - -export const EscalationSeverity = z.enum(['low', 'medium', 'high', 'critical']); - -export const EscalationMetadataRecord = z.object({ - bead_id: z.string(), - severity: EscalationSeverity, - category: z.string().nullable(), - acknowledged: z.number(), - re_escalation_count: z.number(), - acknowledged_at: z.string().nullable(), -}); - -export type EscalationMetadataRecord = z.output; - -export const escalation_metadata = getTableFromZodSchema( - 'escalation_metadata', - EscalationMetadataRecord -); - -export function createTableEscalationMetadata(): string { - return getCreateTableQueryFromTable(escalation_metadata, { - bead_id: `text primary key references beads(bead_id)`, - severity: `text not null check(severity in ('low', 'medium', 'high', 'critical'))`, - category: `text`, - acknowledged: `integer not null default 0`, - re_escalation_count: `integer not null default 0`, - acknowledged_at: `text`, - }); -} diff --git a/cloudflare-gastown/src/db/tables/review-metadata.table.ts b/cloudflare-gastown/src/db/tables/review-metadata.table.ts deleted file mode 100644 index 8dab287a2..000000000 --- a/cloudflare-gastown/src/db/tables/review-metadata.table.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; - -export const ReviewMetadataRecord = z.object({ - bead_id: z.string(), - branch: z.string(), - target_branch: z.string(), - merge_commit: z.string().nullable(), - pr_url: z.string().nullable(), - retry_count: z.number(), -}); - -export type ReviewMetadataRecord = z.output; - -export const review_metadata = getTableFromZodSchema('review_metadata', ReviewMetadataRecord); - -export function createTableReviewMetadata(): string { - return getCreateTableQueryFromTable(review_metadata, { - bead_id: `text primary key references beads(bead_id)`, - branch: `text not null`, - target_branch: `text not null default 'main'`, - merge_commit: `text`, - pr_url: `text`, - retry_count: `integer default 0`, - }); -} diff --git a/cloudflare-gastown/src/db/tables/rig-agent-events.table.ts b/cloudflare-gastown/src/db/tables/rig-agent-events.table.ts deleted file mode 100644 index 2a02d6819..000000000 --- a/cloudflare-gastown/src/db/tables/rig-agent-events.table.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; - -export const RigAgentEventRecord = z.object({ - id: z.number(), - agent_id: z.string(), - event_type: z.string(), - data: z - .string() - .transform(v => JSON.parse(v)) - .pipe(z.record(z.string(), z.unknown())), - created_at: z.string(), -}); - -export type RigAgentEventRecord = z.output; - -export const rig_agent_events = getTableFromZodSchema('rig_agent_events', RigAgentEventRecord); - -export function createTableRigAgentEvents(): string { - return getCreateTableQueryFromTable(rig_agent_events, { - id: `integer primary key autoincrement`, - agent_id: `text not null`, - event_type: `text not null`, - data: `text not null default '{}'`, - created_at: `text not null`, - }); -} - -export function getIndexesRigAgentEvents(): string[] { - return [ - `CREATE INDEX IF NOT EXISTS idx_rig_agent_events_agent_id ON ${rig_agent_events}(${rig_agent_events.columns.agent_id})`, - `CREATE INDEX IF NOT EXISTS idx_rig_agent_events_agent_created ON ${rig_agent_events}(${rig_agent_events.columns.agent_id}, ${rig_agent_events.columns.id})`, - ]; -} diff --git a/cloudflare-gastown/src/db/tables/rig-agents.table.ts b/cloudflare-gastown/src/db/tables/rig-agents.table.ts deleted file mode 100644 index f4e081699..000000000 --- a/cloudflare-gastown/src/db/tables/rig-agents.table.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; - -const AgentRole = z.enum(['polecat', 'refinery', 'mayor', 'witness']); -const AgentStatus = z.enum(['idle', 'working', 'blocked', 'dead']); - -export const RigAgentRecord = z.object({ - id: z.string(), - rig_id: z.string().nullable(), - role: AgentRole, - name: z.string(), - identity: z.string(), - status: AgentStatus, - current_hook_bead_id: z.string().nullable(), - dispatch_attempts: z.number().default(0), - last_activity_at: z.string().nullable(), - checkpoint: z - .string() - .nullable() - .transform(v => (v === null ? null : JSON.parse(v))) - .pipe(z.unknown()), - created_at: z.string(), -}); - -export type RigAgentRecord = z.output; - -// TODO: This should be called town_agents -export const rig_agents = getTableFromZodSchema('rig_agents', RigAgentRecord); - -export function createTableRigAgents(): string { - return getCreateTableQueryFromTable(rig_agents, { - id: `text primary key`, - rig_id: `text`, - role: `text not null check(role in ('polecat', 'refinery', 'mayor', 'witness'))`, - name: `text not null`, - identity: `text not null unique`, - status: `text not null default 'idle' check(status in ('idle', 'working', 'blocked', 'dead'))`, - current_hook_bead_id: `text references rig_beads(id)`, - dispatch_attempts: `integer not null default 0`, - last_activity_at: `text`, - checkpoint: `text`, - created_at: `text not null`, - }); -} diff --git a/cloudflare-gastown/src/db/tables/rig-bead-events.table.ts b/cloudflare-gastown/src/db/tables/rig-bead-events.table.ts deleted file mode 100644 index fe30d2aae..000000000 --- a/cloudflare-gastown/src/db/tables/rig-bead-events.table.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; - -export const BeadEventType = z.enum([ - 'created', - 'assigned', - 'hooked', - 'unhooked', - 'status_changed', - 'closed', - 'escalated', - 'notification_failed', - 'mail_sent', - 'review_submitted', - 'review_completed', - 'agent_spawned', - 'agent_exited', -]); - -export type BeadEventType = z.infer; - -export const RigBeadEventRecord = z.object({ - id: z.string(), - bead_id: z.string(), - agent_id: z.string().nullable(), - event_type: BeadEventType, - old_value: z.string().nullable(), - new_value: z.string().nullable(), - metadata: z.string().transform((v, ctx): Record => { - try { - return JSON.parse(v); - } catch { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid JSON in metadata' }); - return {}; - } - }), - created_at: z.string(), -}); - -export type RigBeadEventRecord = z.output; - -export const rig_bead_events = getTableFromZodSchema('rig_bead_events', RigBeadEventRecord); - -export function createTableRigBeadEvents(): string { - return getCreateTableQueryFromTable(rig_bead_events, { - id: `text primary key`, - bead_id: `text not null`, - agent_id: `text`, - event_type: `text not null`, - old_value: `text`, - new_value: `text`, - metadata: `text default '{}'`, - created_at: `text not null`, - }); -} - -export function getIndexesRigBeadEvents(): string[] { - return [ - `CREATE INDEX IF NOT EXISTS idx_rig_bead_events_bead ON ${rig_bead_events}(${rig_bead_events.columns.bead_id})`, - `CREATE INDEX IF NOT EXISTS idx_rig_bead_events_created ON ${rig_bead_events}(${rig_bead_events.columns.created_at})`, - `CREATE INDEX IF NOT EXISTS idx_rig_bead_events_type ON ${rig_bead_events}(${rig_bead_events.columns.event_type})`, - ]; -} diff --git a/cloudflare-gastown/src/db/tables/rig-beads.table.ts b/cloudflare-gastown/src/db/tables/rig-beads.table.ts deleted file mode 100644 index 327af6d7c..000000000 --- a/cloudflare-gastown/src/db/tables/rig-beads.table.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; - -const BeadType = z.enum(['issue', 'message', 'escalation', 'merge_request']); -const BeadStatus = z.enum(['open', 'in_progress', 'closed', 'failed']); -const BeadPriority = z.enum(['low', 'medium', 'high', 'critical']); - -export const RigBeadRecord = z.object({ - id: z.string(), - rig_id: z.string().nullable(), - type: BeadType, - status: BeadStatus, - title: z.string(), - body: z.string().nullable(), - assignee_agent_id: z.string().nullable(), - convoy_id: z.string().nullable(), - molecule_id: z.string().nullable(), - priority: BeadPriority, - labels: z.string().transform(v => JSON.parse(v) as string[]), - metadata: z.string().transform(v => JSON.parse(v) as Record), - created_at: z.string(), - updated_at: z.string(), - closed_at: z.string().nullable(), -}); - -export type RigBeadRecord = z.output; - -export const rig_beads = getTableFromZodSchema('rig_beads', RigBeadRecord); - -export function createTableRigBeads(): string { - return getCreateTableQueryFromTable(rig_beads, { - id: `text primary key`, - rig_id: `text`, - type: `text not null check(type in ('issue', 'message', 'escalation', 'merge_request'))`, - status: `text not null default 'open' check(status in ('open', 'in_progress', 'closed', 'failed'))`, - title: `text not null`, - body: `text`, - assignee_agent_id: `text`, - convoy_id: `text`, - molecule_id: `text`, - priority: `text default 'medium' check(priority in ('low', 'medium', 'high', 'critical'))`, - labels: `text default '[]'`, - metadata: `text default '{}'`, - created_at: `text not null`, - updated_at: `text not null`, - closed_at: `text`, - }); -} - -export function getIndexesRigBeads(): string[] { - return [ - `CREATE INDEX IF NOT EXISTS idx_rig_beads_status ON ${rig_beads}(${rig_beads.columns.status})`, - `CREATE INDEX IF NOT EXISTS idx_rig_beads_type ON ${rig_beads}(${rig_beads.columns.type})`, - `CREATE INDEX IF NOT EXISTS idx_rig_beads_assignee ON ${rig_beads}(${rig_beads.columns.assignee_agent_id})`, - `CREATE INDEX IF NOT EXISTS idx_rig_beads_convoy ON ${rig_beads}(${rig_beads.columns.convoy_id})`, - ]; -} diff --git a/cloudflare-gastown/src/db/tables/rig-mail.table.ts b/cloudflare-gastown/src/db/tables/rig-mail.table.ts deleted file mode 100644 index c20d4a239..000000000 --- a/cloudflare-gastown/src/db/tables/rig-mail.table.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; - -export const RigMailRecord = z.object({ - id: z.string(), - from_agent_id: z.string(), - to_agent_id: z.string(), - subject: z.string(), - body: z.string(), - delivered: z.number().transform(v => Boolean(v)), - created_at: z.string(), - delivered_at: z.string().nullable(), -}); - -export type RigMailRecord = z.output; - -export const rig_mail = getTableFromZodSchema('rig_mail', RigMailRecord); - -export function createTableRigMail(): string { - return getCreateTableQueryFromTable(rig_mail, { - id: `text primary key`, - from_agent_id: `text not null references rig_agents(id)`, - to_agent_id: `text not null references rig_agents(id)`, - subject: `text not null`, - body: `text not null`, - delivered: `integer not null default 0`, - created_at: `text not null`, - delivered_at: `text`, - }); -} - -export function getIndexesRigMail(): string[] { - return [ - `CREATE INDEX IF NOT EXISTS idx_rig_mail_undelivered ON ${rig_mail}(${rig_mail.columns.to_agent_id}) WHERE ${rig_mail.columns.delivered} = 0`, - ]; -} diff --git a/cloudflare-gastown/src/db/tables/rig-molecules.table.ts b/cloudflare-gastown/src/db/tables/rig-molecules.table.ts deleted file mode 100644 index 90207329c..000000000 --- a/cloudflare-gastown/src/db/tables/rig-molecules.table.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; - -const MoleculeStatus = z.enum(['active', 'completed', 'failed']); - -export const RigMoleculeRecord = z.object({ - id: z.string(), - bead_id: z.string(), - formula: z.string().transform(v => JSON.parse(v) as unknown), - current_step: z.number(), - status: MoleculeStatus, - created_at: z.string(), - updated_at: z.string(), -}); - -export type RigMoleculeRecord = z.output; - -export const rig_molecules = getTableFromZodSchema('rig_molecules', RigMoleculeRecord); - -export function createTableRigMolecules(): string { - return getCreateTableQueryFromTable(rig_molecules, { - id: `text primary key`, - bead_id: `text not null references rig_beads(id)`, - formula: `text not null`, - current_step: `integer not null default 0`, - status: `text not null default 'active' check(status in ('active', 'completed', 'failed'))`, - created_at: `text not null`, - updated_at: `text not null`, - }); -} diff --git a/cloudflare-gastown/src/db/tables/rig-review-queue.table.ts b/cloudflare-gastown/src/db/tables/rig-review-queue.table.ts deleted file mode 100644 index 208977689..000000000 --- a/cloudflare-gastown/src/db/tables/rig-review-queue.table.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; - -const ReviewStatus = z.enum(['pending', 'running', 'merged', 'failed']); - -export const RigReviewQueueRecord = z.object({ - id: z.string(), - agent_id: z.string(), - bead_id: z.string(), - branch: z.string(), - pr_url: z.string().nullable(), - status: ReviewStatus, - summary: z.string().nullable(), - created_at: z.string(), - processed_at: z.string().nullable(), -}); - -export type RigReviewQueueRecord = z.output; - -export const rig_review_queue = getTableFromZodSchema('rig_review_queue', RigReviewQueueRecord); - -export function createTableRigReviewQueue(): string { - return getCreateTableQueryFromTable(rig_review_queue, { - id: `text primary key`, - agent_id: `text not null references rig_agents(id)`, - bead_id: `text not null references rig_beads(id)`, - branch: `text not null`, - pr_url: `text`, - status: `text not null default 'pending' check(status in ('pending', 'running', 'merged', 'failed'))`, - summary: `text`, - created_at: `text not null`, - processed_at: `text`, - }); -} diff --git a/cloudflare-gastown/src/db/tables/town-convoy-beads.table.ts b/cloudflare-gastown/src/db/tables/town-convoy-beads.table.ts deleted file mode 100644 index 7044eb299..000000000 --- a/cloudflare-gastown/src/db/tables/town-convoy-beads.table.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; - -export const ConvoyBeadStatus = z.enum(['open', 'closed']); - -export const TownConvoyBeadRecord = z.object({ - convoy_id: z.string(), - bead_id: z.string(), - rig_id: z.string(), - status: ConvoyBeadStatus, -}); - -export type TownConvoyBeadRecord = z.output; - -export const town_convoy_beads = getTableFromZodSchema('town_convoy_beads', TownConvoyBeadRecord); - -export function createTableTownConvoyBeads(): string { - return getCreateTableQueryFromTable(town_convoy_beads, { - convoy_id: `text not null`, - bead_id: `text not null`, - rig_id: `text not null`, - status: `text not null check(status in ('open', 'closed')) default 'open'`, - }); -} diff --git a/cloudflare-gastown/src/db/tables/town-convoys.table.ts b/cloudflare-gastown/src/db/tables/town-convoys.table.ts deleted file mode 100644 index 6594c2712..000000000 --- a/cloudflare-gastown/src/db/tables/town-convoys.table.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; - -export const ConvoyStatus = z.enum(['active', 'landed']); - -export const TownConvoyRecord = z.object({ - id: z.string(), - title: z.string(), - status: ConvoyStatus, - total_beads: z.number(), - closed_beads: z.number(), - created_by: z.string().nullable(), - created_at: z.string(), - landed_at: z.string().nullable(), -}); - -export type TownConvoyRecord = z.output; - -export const town_convoys = getTableFromZodSchema('town_convoys', TownConvoyRecord); - -export function createTableTownConvoys(): string { - return getCreateTableQueryFromTable(town_convoys, { - id: `text primary key`, - title: `text not null`, - status: `text not null check(status in ('active', 'landed')) default 'active'`, - total_beads: `integer not null default 0`, - closed_beads: `integer not null default 0`, - created_by: `text`, - created_at: `text not null`, - landed_at: `text`, - }); -} diff --git a/cloudflare-gastown/src/db/tables/town-escalations.table.ts b/cloudflare-gastown/src/db/tables/town-escalations.table.ts deleted file mode 100644 index d6ffe72b3..000000000 --- a/cloudflare-gastown/src/db/tables/town-escalations.table.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; - -export const EscalationSeverity = z.enum(['low', 'medium', 'high', 'critical']); - -export const TownEscalationRecord = z.object({ - id: z.string(), - source_rig_id: z.string(), - source_agent_id: z.string().nullable(), - severity: EscalationSeverity, - category: z.string().nullable(), - message: z.string(), - acknowledged: z.number(), - re_escalation_count: z.number(), - created_at: z.string(), - acknowledged_at: z.string().nullable(), -}); - -export type TownEscalationRecord = z.output; - -export const town_escalations = getTableFromZodSchema('town_escalations', TownEscalationRecord); - -export function createTableTownEscalations(): string { - return getCreateTableQueryFromTable(town_escalations, { - id: `text primary key`, - source_rig_id: `text not null`, - source_agent_id: `text`, - severity: `text not null check(severity in ('low', 'medium', 'high', 'critical'))`, - category: `text`, - message: `text not null`, - acknowledged: `integer not null default 0`, - re_escalation_count: `integer not null default 0`, - created_at: `text not null`, - acknowledged_at: `text`, - }); -} diff --git a/cloudflare-gastown/src/db/tables/user-rigs.table.ts b/cloudflare-gastown/src/db/tables/user-rigs.table.ts deleted file mode 100644 index 4b8225047..000000000 --- a/cloudflare-gastown/src/db/tables/user-rigs.table.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; - -export const UserRigRecord = z.object({ - id: z.string(), - town_id: z.string(), - name: z.string(), - git_url: z.string(), - default_branch: z.string(), - // nullable + optional: existing rows won't have this column at all (undefined), - // new rows will have it as null or a string. - platform_integration_id: z.string().nullable().optional().default(null), - created_at: z.string(), - updated_at: z.string(), -}); - -export type UserRigRecord = z.output; - -export const user_rigs = getTableFromZodSchema('user_rigs', UserRigRecord); - -export function createTableUserRigs(): string { - return getCreateTableQueryFromTable(user_rigs, { - id: `text primary key`, - town_id: `text not null`, - name: `text not null`, - git_url: `text not null`, - default_branch: `text not null default 'main'`, - platform_integration_id: `text`, - created_at: `text not null`, - updated_at: `text not null`, - }); -} diff --git a/cloudflare-gastown/src/db/tables/user-towns.table.ts b/cloudflare-gastown/src/db/tables/user-towns.table.ts deleted file mode 100644 index 74ada1a18..000000000 --- a/cloudflare-gastown/src/db/tables/user-towns.table.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from 'zod'; -import { getTableFromZodSchema, getCreateTableQueryFromTable } from '../../util/table'; - -export const UserTownRecord = z.object({ - id: z.string(), - name: z.string(), - owner_user_id: z.string(), - created_at: z.string(), - updated_at: z.string(), -}); - -export type UserTownRecord = z.output; - -export const user_towns = getTableFromZodSchema('user_towns', UserTownRecord); - -export function createTableUserTowns(): string { - return getCreateTableQueryFromTable(user_towns, { - id: `text primary key`, - name: `text not null`, - owner_user_id: `text not null`, - created_at: `text not null`, - updated_at: `text not null`, - }); -} diff --git a/cloudflare-gastown/src/dos/Agent.do.ts b/cloudflare-gastown/src/dos/Agent.do.ts index 607e930c6..5af411ca4 100644 --- a/cloudflare-gastown/src/dos/Agent.do.ts +++ b/cloudflare-gastown/src/dos/Agent.do.ts @@ -8,81 +8,60 @@ */ import { DurableObject } from 'cloudflare:workers'; -import { - rig_agent_events, - RigAgentEventRecord, - createTableRigAgentEvents, - getIndexesRigAgentEvents, -} from '../db/tables/rig-agent-events.table'; -import { query } from '../util/query.util'; +import { drizzle, type DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; +import { migrate } from 'drizzle-orm/durable-sqlite/migrator'; +import { gt, asc, sql } from 'drizzle-orm'; +import migrations from '../../drizzle/migrations'; +import { rig_agent_events, type RigAgentEventsSelect } from '../db/sqlite-schema'; const AGENT_DO_LOG = '[Agent.do]'; +type RigAgentEvent = Omit & { data: Record }; + +function parseRigAgentEvent(row: RigAgentEventsSelect): RigAgentEvent { + return { ...row, data: JSON.parse(row.data) as Record }; +} + export class AgentDO extends DurableObject { - private sql: SqlStorage; - private initPromise: Promise | null = null; + private db: DrizzleSqliteDODatabase; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); - this.sql = ctx.storage.sql; - + this.db = drizzle(ctx.storage, { logger: false }); void ctx.blockConcurrencyWhile(async () => { - await this.ensureInitialized(); + migrate(this.db, migrations); }); } - private async ensureInitialized(): Promise { - if (!this.initPromise) { - this.initPromise = this.initializeDatabase(); - } - await this.initPromise; - } - - private async initializeDatabase(): Promise { - query(this.sql, createTableRigAgentEvents(), []); - for (const idx of getIndexesRigAgentEvents()) { - query(this.sql, idx, []); - } - } - /** * Append an event. Returns the auto-incremented event ID. */ async appendEvent(eventType: string, data: unknown): Promise { - await this.ensureInitialized(); const dataStr = typeof data === 'string' ? data : JSON.stringify(data ?? {}); const timestamp = new Date().toISOString(); - query( - this.sql, - /* sql */ ` - INSERT INTO ${rig_agent_events} ( - ${rig_agent_events.columns.agent_id}, - ${rig_agent_events.columns.event_type}, - ${rig_agent_events.columns.data}, - ${rig_agent_events.columns.created_at} - ) VALUES (?, ?, ?, ?) - `, - [this.ctx.id.name ?? '', eventType, dataStr, timestamp] - ); - - // Return the last inserted rowid - const rows = [...this.sql.exec('SELECT last_insert_rowid() as id')]; - const insertedId = Number(rows[0]?.id ?? 0); + const row = this.db + .insert(rig_agent_events) + .values({ + agent_id: this.ctx.id.name ?? '', + event_type: eventType, + data: dataStr, + created_at: timestamp, + }) + .returning({ id: rig_agent_events.id }) + .get(); + + const insertedId = row?.id ?? 0; // Prune old events if count exceeds 10000 - query( - this.sql, - /* sql */ ` - DELETE FROM ${rig_agent_events} - WHERE ${rig_agent_events.columns.id} NOT IN ( - SELECT ${rig_agent_events.columns.id} FROM ${rig_agent_events} - ORDER BY ${rig_agent_events.columns.id} DESC - LIMIT 10000 - ) - `, - [] - ); + this.db.run(sql` + DELETE FROM ${rig_agent_events} + WHERE ${rig_agent_events.id} NOT IN ( + SELECT ${rig_agent_events.id} FROM ${rig_agent_events} + ORDER BY ${rig_agent_events.id} DESC + LIMIT 10000 + ) + `); return insertedId; } @@ -90,21 +69,16 @@ export class AgentDO extends DurableObject { /** * Query events for backfill. Returns events with id > afterId, up to limit. */ - async getEvents(afterId = 0, limit = 500): Promise { - await this.ensureInitialized(); - const rows = [ - ...query( - this.sql, - /* sql */ ` - SELECT * FROM ${rig_agent_events} - WHERE ${rig_agent_events.columns.id} > ? - ORDER BY ${rig_agent_events.columns.id} ASC - LIMIT ? - `, - [afterId, limit] - ), - ]; - return RigAgentEventRecord.array().parse(rows); + async getEvents(afterId = 0, limit = 500): Promise { + const rows = this.db + .select() + .from(rig_agent_events) + .where(gt(rig_agent_events.id, afterId)) + .orderBy(asc(rig_agent_events.id)) + .limit(limit) + .all(); + + return rows.map(parseRigAgentEvent); } /** diff --git a/cloudflare-gastown/src/dos/GastownUser.do.ts b/cloudflare-gastown/src/dos/GastownUser.do.ts index d0c7730a2..0cc82b5ec 100644 --- a/cloudflare-gastown/src/dos/GastownUser.do.ts +++ b/cloudflare-gastown/src/dos/GastownUser.do.ts @@ -1,7 +1,14 @@ import { DurableObject } from 'cloudflare:workers'; -import { createTableUserTowns, user_towns, UserTownRecord } from '../db/tables/user-towns.table'; -import { createTableUserRigs, user_rigs, UserRigRecord } from '../db/tables/user-rigs.table'; -import { query } from '../util/query.util'; +import { drizzle, type DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; +import { migrate } from 'drizzle-orm/durable-sqlite/migrator'; +import { eq, desc } from 'drizzle-orm'; +import migrations from '../../drizzle/migrations'; +import { + user_towns, + user_rigs, + type UserTownsSelect, + type UserRigsSelect, +} from '../db/sqlite-schema'; const USER_LOG = '[GastownUser.do]'; @@ -27,51 +34,33 @@ function now(): string { * Cross-rig coordination will be added in Phase 2 (#215). */ export class GastownUserDO extends DurableObject { - private sql: SqlStorage; - private initPromise: Promise | null = null; + private db: DrizzleSqliteDODatabase; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); - this.sql = ctx.storage.sql; - + this.db = drizzle(ctx.storage, { logger: false }); void ctx.blockConcurrencyWhile(async () => { - await this.ensureInitialized(); + migrate(this.db, migrations); }); } - private async ensureInitialized(): Promise { - if (!this.initPromise) { - this.initPromise = this.initializeDatabase(); - } - await this.initPromise; - } - - private async initializeDatabase(): Promise { - query(this.sql, createTableUserTowns(), []); - query(this.sql, createTableUserRigs(), []); - } - // ── Towns ───────────────────────────────────────────────────────────── - async createTown(input: { name: string; owner_user_id: string }): Promise { - await this.ensureInitialized(); + async createTown(input: { name: string; owner_user_id: string }): Promise { const id = generateId(); const timestamp = now(); console.log(`${USER_LOG} createTown: id=${id} name=${input.name} owner=${input.owner_user_id}`); - query( - this.sql, - /* sql */ ` - INSERT INTO ${user_towns} ( - ${user_towns.columns.id}, - ${user_towns.columns.name}, - ${user_towns.columns.owner_user_id}, - ${user_towns.columns.created_at}, - ${user_towns.columns.updated_at} - ) VALUES (?, ?, ?, ?, ?) - `, - [id, input.name, input.owner_user_id, timestamp, timestamp] - ); + this.db + .insert(user_towns) + .values({ + id, + name: input.name, + owner_user_id: input.owner_user_id, + created_at: timestamp, + updated_at: timestamp, + }) + .run(); const town = this.getTown(id); if (!town) throw new Error('Failed to create town'); @@ -82,33 +71,16 @@ export class GastownUserDO extends DurableObject { return town; } - async getTownAsync(townId: string): Promise { - await this.ensureInitialized(); + async getTownAsync(townId: string): Promise { return this.getTown(townId); } - private getTown(townId: string): UserTownRecord | null { - const rows = [ - ...query( - this.sql, - /* sql */ `SELECT * FROM ${user_towns} WHERE ${user_towns.columns.id} = ?`, - [townId] - ), - ]; - if (rows.length === 0) return null; - return UserTownRecord.parse(rows[0]); + private getTown(townId: string): UserTownsSelect | null { + return this.db.select().from(user_towns).where(eq(user_towns.id, townId)).get() ?? null; } - async listTowns(): Promise { - await this.ensureInitialized(); - const rows = [ - ...query( - this.sql, - /* sql */ `SELECT * FROM ${user_towns} ORDER BY ${user_towns.columns.created_at} DESC`, - [] - ), - ]; - return UserTownRecord.array().parse(rows); + async listTowns(): Promise { + return this.db.select().from(user_towns).orderBy(desc(user_towns.created_at)).all(); } // ── Rigs ────────────────────────────────────────────────────────────── @@ -119,8 +91,7 @@ export class GastownUserDO extends DurableObject { git_url: string; default_branch: string; platform_integration_id?: string; - }): Promise { - await this.ensureInitialized(); + }): Promise { console.log( `${USER_LOG} createRig: town_id=${input.town_id} name=${input.name} git_url=${input.git_url} default_branch=${input.default_branch} integration=${input.platform_integration_id ?? 'none'}` ); @@ -135,31 +106,19 @@ export class GastownUserDO extends DurableObject { const id = generateId(); const timestamp = now(); - query( - this.sql, - /* sql */ ` - INSERT INTO ${user_rigs} ( - ${user_rigs.columns.id}, - ${user_rigs.columns.town_id}, - ${user_rigs.columns.name}, - ${user_rigs.columns.git_url}, - ${user_rigs.columns.default_branch}, - ${user_rigs.columns.platform_integration_id}, - ${user_rigs.columns.created_at}, - ${user_rigs.columns.updated_at} - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - [ + this.db + .insert(user_rigs) + .values({ id, - input.town_id, - input.name, - input.git_url, - input.default_branch, - input.platform_integration_id ?? null, - timestamp, - timestamp, - ] - ); + town_id: input.town_id, + name: input.name, + git_url: input.git_url, + default_branch: input.default_branch, + platform_integration_id: input.platform_integration_id ?? null, + created_at: timestamp, + updated_at: timestamp, + }) + .run(); const rig = this.getRig(id); if (!rig) throw new Error('Failed to create rig'); @@ -167,56 +126,34 @@ export class GastownUserDO extends DurableObject { return rig; } - async getRigAsync(rigId: string): Promise { - await this.ensureInitialized(); + async getRigAsync(rigId: string): Promise { return this.getRig(rigId); } - private getRig(rigId: string): UserRigRecord | null { - const rows = [ - ...query(this.sql, /* sql */ `SELECT * FROM ${user_rigs} WHERE ${user_rigs.columns.id} = ?`, [ - rigId, - ]), - ]; - if (rows.length === 0) return null; - return UserRigRecord.parse(rows[0]); + private getRig(rigId: string): UserRigsSelect | null { + return this.db.select().from(user_rigs).where(eq(user_rigs.id, rigId)).get() ?? null; } - async listRigs(townId: string): Promise { - await this.ensureInitialized(); - const rows = [ - ...query( - this.sql, - /* sql */ ` - SELECT * FROM ${user_rigs} - WHERE ${user_rigs.columns.town_id} = ? - ORDER BY ${user_rigs.columns.created_at} DESC - `, - [townId] - ), - ]; - return UserRigRecord.array().parse(rows); + async listRigs(townId: string): Promise { + return this.db + .select() + .from(user_rigs) + .where(eq(user_rigs.town_id, townId)) + .orderBy(desc(user_rigs.created_at)) + .all(); } async deleteRig(rigId: string): Promise { - await this.ensureInitialized(); if (!this.getRig(rigId)) return false; - query(this.sql, /* sql */ `DELETE FROM ${user_rigs} WHERE ${user_rigs.columns.id} = ?`, [ - rigId, - ]); + this.db.delete(user_rigs).where(eq(user_rigs.id, rigId)).run(); return true; } async deleteTown(townId: string): Promise { - await this.ensureInitialized(); if (!this.getTown(townId)) return false; // Cascade: delete all rigs belonging to this town first - query(this.sql, /* sql */ `DELETE FROM ${user_rigs} WHERE ${user_rigs.columns.town_id} = ?`, [ - townId, - ]); - query(this.sql, /* sql */ `DELETE FROM ${user_towns} WHERE ${user_towns.columns.id} = ?`, [ - townId, - ]); + this.db.delete(user_rigs).where(eq(user_rigs.town_id, townId)).run(); + this.db.delete(user_towns).where(eq(user_towns.id, townId)).run(); return true; } diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index c1f9a3d59..970505816 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -15,6 +15,22 @@ import { DurableObject } from 'cloudflare:workers'; import { z } from 'zod'; +import { drizzle, type DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; +import { migrate } from 'drizzle-orm/durable-sqlite/migrator'; +import { + eq, + and, + or, + sql, + count, + desc, + inArray, + isNull, + isNotNull, + getTableColumns, +} from 'drizzle-orm'; +import type { SQL } from 'drizzle-orm'; +import migrations from '../../drizzle/migrations'; // Sub-modules (plain functions, not classes — per coding style) import * as beadOps from './town/beads'; @@ -25,23 +41,19 @@ import * as config from './town/config'; import * as rigs from './town/rigs'; import * as dispatch from './town/container-dispatch'; -// Table imports for beads-centric operations +// Table + type imports for beads-centric operations import { beads, - BeadRecord, - AgentBeadRecord, - EscalationBeadRecord, - ConvoyBeadRecord, -} from '../db/tables/beads.table'; -import { agent_metadata, AgentMetadataRecord } from '../db/tables/agent-metadata.table'; -import { escalation_metadata } from '../db/tables/escalation-metadata.table'; -import { convoy_metadata } from '../db/tables/convoy-metadata.table'; -import { bead_dependencies, BeadDependencyRecord } from '../db/tables/bead-dependencies.table'; -import { query } from '../util/query.util'; + agent_metadata, + escalation_metadata, + convoy_metadata, + bead_dependencies, + type BeadsSelect, +} from '../db/sqlite-schema'; import { getAgentDOStub } from './Agent.do'; import { getTownContainerStub } from './TownContainer.do'; -import { BeadPriority } from '../types'; +import { BeadPriority, BeadStatus } from '../types'; import type { TownConfig, TownConfigUpdate, @@ -95,7 +107,81 @@ type RigConfig = { platformIntegrationId?: string; }; -// ── Escalation API type (derived from EscalationBeadRecord) ───────── +// ── Drizzle join column sets ──────────────────────────────────────── + +const escalationJoinColumns = { + ...getTableColumns(beads), + severity: escalation_metadata.severity, + category: escalation_metadata.category, + acknowledged: escalation_metadata.acknowledged, + re_escalation_count: escalation_metadata.re_escalation_count, + acknowledged_at: escalation_metadata.acknowledged_at, +}; + +const convoyJoinColumns = { + ...getTableColumns(beads), + total_beads: convoy_metadata.total_beads, + closed_beads: convoy_metadata.closed_beads, + landed_at: convoy_metadata.landed_at, +}; + +// ── Parse helpers for joined rows ─────────────────────────────────── + +function parseBead(row: BeadsSelect): Bead { + return { + ...row, + labels: JSON.parse(row.labels ?? '[]'), + metadata: JSON.parse(row.metadata ?? '{}'), + }; +} + +// Escalation join row — the shape returned by selecting escalationJoinColumns +type EscalationJoinRow = { + bead_id: string; + type: string; + status: string; + title: string; + body: string | null; + rig_id: string | null; + parent_bead_id: string | null; + assignee_agent_bead_id: string | null; + priority: string | null; + labels: string | null; + metadata: string | null; + created_by: string | null; + created_at: string; + updated_at: string; + closed_at: string | null; + severity: string; + category: string | null; + acknowledged: number; + re_escalation_count: number; + acknowledged_at: string | null; +}; + +// Convoy join row — the shape returned by selecting convoyJoinColumns +type ConvoyJoinRow = { + bead_id: string; + type: string; + status: string; + title: string; + body: string | null; + rig_id: string | null; + parent_bead_id: string | null; + assignee_agent_bead_id: string | null; + priority: string | null; + labels: string | null; + metadata: string | null; + created_by: string | null; + created_at: string; + updated_at: string; + closed_at: string | null; + total_beads: number; + closed_beads: number; + landed_at: string | null; +}; + +// ── Escalation API type ───────────────────────────────────────────── type EscalationEntry = { id: string; source_rig_id: string; @@ -109,12 +195,12 @@ type EscalationEntry = { acknowledged_at: string | null; }; -function toEscalation(row: EscalationBeadRecord): EscalationEntry { +function toEscalation(row: EscalationJoinRow): EscalationEntry { return { id: row.bead_id, source_rig_id: row.rig_id ?? '', source_agent_id: row.created_by, - severity: row.severity, + severity: row.severity as EscalationEntry['severity'], category: row.category, message: row.body ?? row.title, acknowledged: row.acknowledged, @@ -124,7 +210,7 @@ function toEscalation(row: EscalationBeadRecord): EscalationEntry { }; } -// ── Convoy API type (derived from ConvoyBeadRecord) ───────────────── +// ── Convoy API type ───────────────────────────────────────────────── type ConvoyEntry = { id: string; title: string; @@ -136,7 +222,7 @@ type ConvoyEntry = { landed_at: string | null; }; -function toConvoy(row: ConvoyBeadRecord): ConvoyEntry { +function toConvoy(row: ConvoyJoinRow): ConvoyEntry { return { id: row.bead_id, title: row.title, @@ -149,62 +235,20 @@ function toConvoy(row: ConvoyBeadRecord): ConvoyEntry { }; } -const CONVOY_JOIN = /* sql */ ` - SELECT ${beads}.*, - ${convoy_metadata.total_beads}, ${convoy_metadata.closed_beads}, - ${convoy_metadata.landed_at} - FROM ${beads} - INNER JOIN ${convoy_metadata} ON ${beads.bead_id} = ${convoy_metadata.bead_id} -`; - -const ESCALATION_JOIN = /* sql */ ` - SELECT ${beads}.*, - ${escalation_metadata.severity}, ${escalation_metadata.category}, - ${escalation_metadata.acknowledged}, ${escalation_metadata.re_escalation_count}, - ${escalation_metadata.acknowledged_at} - FROM ${beads} - INNER JOIN ${escalation_metadata} ON ${beads.bead_id} = ${escalation_metadata.bead_id} -`; - export class TownDO extends DurableObject { - private sql: SqlStorage; - private initPromise: Promise | null = null; + private db: DrizzleSqliteDODatabase; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); - this.sql = ctx.storage.sql; - + this.db = drizzle(ctx.storage, { logger: false }); void ctx.blockConcurrencyWhile(async () => { - await this.ensureInitialized(); + migrate(this.db, migrations); + // Load persisted town ID if available + const storedId = await ctx.storage.get('town:id'); + if (storedId) this._townId = storedId; }); } - private async ensureInitialized(): Promise { - if (!this.initPromise) { - this.initPromise = this.initializeDatabase(); - } - await this.initPromise; - } - - private async initializeDatabase(): Promise { - // Load persisted town ID if available - const storedId = await this.ctx.storage.get('town:id'); - if (storedId) this._townId = storedId; - - // All tables are now initialized via beads.initBeadTables(): - // beads, bead_events, bead_dependencies, agent_metadata, review_metadata, - // escalation_metadata, convoy_metadata - beadOps.initBeadTables(this.sql); - - // These are no-ops now but kept for clarity - agents.initAgentTables(this.sql); - mail.initMailTables(this.sql); - reviewQueue.initReviewQueueTables(this.sql); - - // Rig registry - rigs.initRigTables(this.sql); - } - private _townId: string | null = null; private get townId(): string { @@ -243,37 +287,29 @@ export class TownDO extends DurableObject { gitUrl: string; defaultBranch: string; }): Promise { - await this.ensureInitialized(); - return rigs.addRig(this.sql, input); + return rigs.addRig(this.db, input); } async removeRig(rigId: string): Promise { - await this.ensureInitialized(); - rigs.removeRig(this.sql, rigId); + rigs.removeRig(this.db, rigId); await this.ctx.storage.delete(`rig:${rigId}:config`); // Delete all beads belonging to this rig (cascades to satellite tables via deleteBead) - const rigBeads = BeadRecord.pick({ bead_id: true }) - .array() - .parse([ - ...query( - this.sql, - /* sql */ `SELECT ${beads.bead_id} FROM ${beads} WHERE ${beads.rig_id} = ?`, - [rigId] - ), - ]); + const rigBeads = this.db + .select({ bead_id: beads.bead_id }) + .from(beads) + .where(eq(beads.rig_id, rigId)) + .all(); for (const { bead_id } of rigBeads) { - beadOps.deleteBead(this.sql, bead_id); + beadOps.deleteBead(this.db, bead_id); } } async listRigs(): Promise { - await this.ensureInitialized(); - return rigs.listRigs(this.sql); + return rigs.listRigs(this.db); } async getRigAsync(rigId: string): Promise { - await this.ensureInitialized(); - return rigs.getRig(this.sql, rigId); + return rigs.getRig(this.db, rigId); } // ── Rig Config (KV, per-rig — configuration needed for container dispatch) ── @@ -325,42 +361,34 @@ export class TownDO extends DurableObject { // ══════════════════════════════════════════════════════════════════ async createBead(input: CreateBeadInput): Promise { - await this.ensureInitialized(); - return beadOps.createBead(this.sql, input); + return beadOps.createBead(this.db, input); } async getBeadAsync(beadId: string): Promise { - await this.ensureInitialized(); - return beadOps.getBead(this.sql, beadId); + return beadOps.getBead(this.db, beadId); } async listBeads(filter: BeadFilter): Promise { - await this.ensureInitialized(); - return beadOps.listBeads(this.sql, filter); + return beadOps.listBeads(this.db, filter); } async updateBeadStatus(beadId: string, status: string, agentId: string): Promise { - await this.ensureInitialized(); - const bead = beadOps.updateBeadStatus(this.sql, beadId, status, agentId); + const validStatus = BeadStatus.parse(status); + const bead = beadOps.updateBeadStatus(this.db, beadId, validStatus, agentId); // If closed and part of a convoy (via bead_dependencies), notify if (status === 'closed') { - const convoyRows = [ - ...query( - this.sql, - /* sql */ ` - SELECT ${bead_dependencies.depends_on_bead_id} - FROM ${bead_dependencies} - WHERE ${bead_dependencies.bead_id} = ? - AND ${bead_dependencies.dependency_type} = 'tracks' - `, - [beadId] - ), - ]; - const parsed = BeadDependencyRecord.pick({ depends_on_bead_id: true }) - .array() - .parse(convoyRows); - for (const { depends_on_bead_id } of parsed) { + const convoyRows = this.db + .select({ depends_on_bead_id: bead_dependencies.depends_on_bead_id }) + .from(bead_dependencies) + .where( + and( + eq(bead_dependencies.bead_id, beadId), + eq(bead_dependencies.dependency_type, 'tracks') + ) + ) + .all(); + for (const { depends_on_bead_id } of convoyRows) { this.onBeadClosed({ convoyId: depends_on_bead_id, beadId }).catch(() => {}); } } @@ -373,8 +401,7 @@ export class TownDO extends DurableObject { } async deleteBead(beadId: string): Promise { - await this.ensureInitialized(); - beadOps.deleteBead(this.sql, beadId); + beadOps.deleteBead(this.db, beadId); } async listBeadEvents(options: { @@ -382,8 +409,7 @@ export class TownDO extends DurableObject { since?: string; limit?: number; }): Promise { - await this.ensureInitialized(); - return beadOps.listBeadEvents(this.sql, options); + return beadOps.listBeadEvents(this.db, options); } // ══════════════════════════════════════════════════════════════════ @@ -391,33 +417,27 @@ export class TownDO extends DurableObject { // ══════════════════════════════════════════════════════════════════ async registerAgent(input: RegisterAgentInput): Promise { - await this.ensureInitialized(); - return agents.registerAgent(this.sql, input); + return agents.registerAgent(this.db, input); } async getAgentAsync(agentId: string): Promise { - await this.ensureInitialized(); - return agents.getAgent(this.sql, agentId); + return agents.getAgent(this.db, agentId); } async getAgentByIdentity(identity: string): Promise { - await this.ensureInitialized(); - return agents.getAgentByIdentity(this.sql, identity); + return agents.getAgentByIdentity(this.db, identity); } async listAgents(filter?: AgentFilter): Promise { - await this.ensureInitialized(); - return agents.listAgents(this.sql, filter); + return agents.listAgents(this.db, filter); } async updateAgentStatus(agentId: string, status: string): Promise { - await this.ensureInitialized(); - agents.updateAgentStatus(this.sql, agentId, status); + agents.updateAgentStatus(this.db, agentId, status); } async deleteAgent(agentId: string): Promise { - await this.ensureInitialized(); - agents.deleteAgent(this.sql, agentId); + agents.deleteAgent(this.db, agentId); try { const agentDO = getAgentDOStub(this.env, agentId); await agentDO.destroy(); @@ -427,24 +447,20 @@ export class TownDO extends DurableObject { } async hookBead(agentId: string, beadId: string): Promise { - await this.ensureInitialized(); - agents.hookBead(this.sql, agentId, beadId); + agents.hookBead(this.db, agentId, beadId); await this.armAlarmIfNeeded(); } async unhookBead(agentId: string): Promise { - await this.ensureInitialized(); - agents.unhookBead(this.sql, agentId); + agents.unhookBead(this.db, agentId); } async getHookedBead(agentId: string): Promise { - await this.ensureInitialized(); - return agents.getHookedBead(this.sql, agentId); + return agents.getHookedBead(this.db, agentId); } async getOrCreateAgent(role: AgentRole, rigId: string): Promise { - await this.ensureInitialized(); - return agents.getOrCreateAgent(this.sql, role, rigId, this.townId); + return agents.getOrCreateAgent(this.db, role, rigId, this.townId); } // ── Agent Events (delegated to AgentDO) ─────────────────────────── @@ -462,25 +478,21 @@ export class TownDO extends DurableObject { // ── Prime & Checkpoint ──────────────────────────────────────────── async prime(agentId: string): Promise { - await this.ensureInitialized(); - return agents.prime(this.sql, agentId); + return agents.prime(this.db, agentId); } async writeCheckpoint(agentId: string, data: unknown): Promise { - await this.ensureInitialized(); - agents.writeCheckpoint(this.sql, agentId, data); + agents.writeCheckpoint(this.db, agentId, data); } async readCheckpoint(agentId: string): Promise { - await this.ensureInitialized(); - return agents.readCheckpoint(this.sql, agentId); + return agents.readCheckpoint(this.db, agentId); } // ── Heartbeat ───────────────────────────────────────────────────── async touchAgentHeartbeat(agentId: string): Promise { - await this.ensureInitialized(); - agents.touchAgent(this.sql, agentId); + agents.touchAgent(this.db, agentId); await this.armAlarmIfNeeded(); } @@ -489,13 +501,11 @@ export class TownDO extends DurableObject { // ══════════════════════════════════════════════════════════════════ async sendMail(input: SendMailInput): Promise { - await this.ensureInitialized(); - mail.sendMail(this.sql, input); + mail.sendMail(this.db, input); } async checkMail(agentId: string): Promise { - await this.ensureInitialized(); - return mail.checkMail(this.sql, agentId); + return mail.checkMail(this.db, agentId); } // ══════════════════════════════════════════════════════════════════ @@ -503,19 +513,16 @@ export class TownDO extends DurableObject { // ══════════════════════════════════════════════════════════════════ async submitToReviewQueue(input: ReviewQueueInput): Promise { - await this.ensureInitialized(); - reviewQueue.submitToReviewQueue(this.sql, input); + reviewQueue.submitToReviewQueue(this.db, input); await this.armAlarmIfNeeded(); } async popReviewQueue(): Promise { - await this.ensureInitialized(); - return reviewQueue.popReviewQueue(this.sql); + return reviewQueue.popReviewQueue(this.db); } async completeReview(entryId: string, status: 'merged' | 'failed'): Promise { - await this.ensureInitialized(); - reviewQueue.completeReview(this.sql, entryId, status); + reviewQueue.completeReview(this.db, entryId, status); } async completeReviewWithResult(input: { @@ -524,13 +531,11 @@ export class TownDO extends DurableObject { message?: string; commit_sha?: string; }): Promise { - await this.ensureInitialized(); - reviewQueue.completeReviewWithResult(this.sql, input); + reviewQueue.completeReviewWithResult(this.db, input); } async agentDone(agentId: string, input: AgentDoneInput): Promise { - await this.ensureInitialized(); - reviewQueue.agentDone(this.sql, agentId, input); + reviewQueue.agentDone(this.db, agentId, input); await this.armAlarmIfNeeded(); } @@ -538,32 +543,28 @@ export class TownDO extends DurableObject { agentId: string, input: { status: 'completed' | 'failed'; reason?: string } ): Promise { - await this.ensureInitialized(); let resolvedAgentId = agentId; if (!resolvedAgentId) { - const mayor = agents.listAgents(this.sql, { role: 'mayor' })[0]; + const mayor = agents.listAgents(this.db, { role: 'mayor' })[0]; if (mayor) resolvedAgentId = mayor.id; } if (resolvedAgentId) { - reviewQueue.agentCompleted(this.sql, resolvedAgentId, input); + reviewQueue.agentCompleted(this.db, resolvedAgentId, input); } } async createMolecule(beadId: string, formula: unknown): Promise { - await this.ensureInitialized(); - return reviewQueue.createMolecule(this.sql, beadId, formula); + return reviewQueue.createMolecule(this.db, beadId, formula); } async getMoleculeCurrentStep( agentId: string ): Promise<{ molecule: Molecule; step: unknown } | null> { - await this.ensureInitialized(); - return reviewQueue.getMoleculeCurrentStep(this.sql, agentId); + return reviewQueue.getMoleculeCurrentStep(this.db, agentId); } async advanceMoleculeStep(agentId: string, summary: string): Promise { - await this.ensureInitialized(); - return reviewQueue.advanceMoleculeStep(this.sql, agentId, summary); + return reviewQueue.advanceMoleculeStep(this.db, agentId, summary); } // ══════════════════════════════════════════════════════════════════ @@ -577,9 +578,7 @@ export class TownDO extends DurableObject { priority?: string; metadata?: Record; }): Promise<{ bead: Bead; agent: Agent }> { - await this.ensureInitialized(); - - const createdBead = beadOps.createBead(this.sql, { + const createdBead = beadOps.createBead(this.db, { type: 'issue', title: input.title, body: input.body, @@ -588,12 +587,12 @@ export class TownDO extends DurableObject { metadata: input.metadata, }); - const agent = agents.getOrCreateAgent(this.sql, 'polecat', input.rigId, this.townId); - agents.hookBead(this.sql, agent.id, createdBead.bead_id); + const agent = agents.getOrCreateAgent(this.db, 'polecat', input.rigId, this.townId); + agents.hookBead(this.db, agent.id, createdBead.bead_id); // Re-read bead and agent after hook (hookBead updates both) - const bead = beadOps.getBead(this.sql, createdBead.bead_id) ?? createdBead; - const hookedAgent = agents.getAgent(this.sql, agent.id) ?? agent; + const bead = beadOps.getBead(this.db, createdBead.bead_id) ?? createdBead; + const hookedAgent = agents.getAgent(this.db, agent.id) ?? agent; // Fire-and-forget dispatch so the sling call returns immediately. // The alarm loop retries if this fails. @@ -612,13 +611,12 @@ export class TownDO extends DurableObject { message: string, model?: string ): Promise<{ agentId: string; sessionStatus: 'idle' | 'active' | 'starting' }> { - await this.ensureInitialized(); const townId = this.townId; - let mayor = agents.listAgents(this.sql, { role: 'mayor' })[0] ?? null; + let mayor = agents.listAgents(this.db, { role: 'mayor' })[0] ?? null; if (!mayor) { const identity = `mayor-${townId.slice(0, 8)}`; - mayor = agents.registerAgent(this.sql, { + mayor = agents.registerAgent(this.db, { role: 'mayor', name: 'mayor', identity, @@ -674,7 +672,7 @@ export class TownDO extends DurableObject { }); if (started) { - agents.updateAgentStatus(this.sql, mayor.id, 'working'); + agents.updateAgentStatus(this.db, mayor.id, 'working'); sessionStatus = 'starting'; } else { sessionStatus = 'idle'; @@ -691,13 +689,12 @@ export class TownDO extends DurableObject { * without requiring the user to send a message first. */ async ensureMayor(): Promise<{ agentId: string; sessionStatus: 'idle' | 'active' | 'starting' }> { - await this.ensureInitialized(); const townId = this.townId; - let mayor = agents.listAgents(this.sql, { role: 'mayor' })[0] ?? null; + let mayor = agents.listAgents(this.db, { role: 'mayor' })[0] ?? null; if (!mayor) { const identity = `mayor-${townId.slice(0, 8)}`; - mayor = agents.registerAgent(this.sql, { + mayor = agents.registerAgent(this.db, { role: 'mayor', name: 'mayor', identity, @@ -756,7 +753,7 @@ export class TownDO extends DurableObject { }); if (started) { - agents.updateAgentStatus(this.sql, mayor.id, 'working'); + agents.updateAgentStatus(this.db, mayor.id, 'working'); return { agentId: mayor.id, sessionStatus: 'starting' }; } @@ -773,8 +770,7 @@ export class TownDO extends DurableObject { lastActivityAt: string; } | null; }> { - await this.ensureInitialized(); - const mayor = agents.listAgents(this.sql, { role: 'mayor' })[0] ?? null; + const mayor = agents.listAgents(this.db, { role: 'mayor' })[0] ?? null; const mapStatus = (agentStatus: string): 'idle' | 'active' | 'starting' => { switch (agentStatus) { @@ -802,7 +798,7 @@ export class TownDO extends DurableObject { } private async getMayorRigConfig(): Promise { - const rigList = rigs.listRigs(this.sql); + const rigList = rigs.listRigs(this.db); if (rigList.length === 0) return null; return this.getRigConfig(rigList[0].id); } @@ -811,7 +807,7 @@ export class TownDO extends DurableObject { const townConfig = await this.getTownConfig(); if (townConfig.kilocode_token) return townConfig.kilocode_token; - const rigList = rigs.listRigs(this.sql); + const rigList = rigs.listRigs(this.db); for (const rig of rigList) { const rc = await this.getRigConfig(rig.id); if (rc?.kilocodeToken) { @@ -832,7 +828,6 @@ export class TownDO extends DurableObject { beads: Array<{ bead_id: string; rig_id: string }>; created_by?: string; }): Promise { - await this.ensureInitialized(); const parsed = z .object({ title: z.string().min(1), @@ -845,62 +840,48 @@ export class TownDO extends DurableObject { const timestamp = now(); // Create the convoy bead - query( - this.sql, - /* sql */ ` - INSERT INTO ${beads} ( - ${beads.columns.bead_id}, ${beads.columns.type}, ${beads.columns.status}, - ${beads.columns.title}, ${beads.columns.body}, ${beads.columns.rig_id}, - ${beads.columns.parent_bead_id}, ${beads.columns.assignee_agent_bead_id}, - ${beads.columns.priority}, ${beads.columns.labels}, ${beads.columns.metadata}, - ${beads.columns.created_by}, ${beads.columns.created_at}, ${beads.columns.updated_at}, - ${beads.columns.closed_at} - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - convoyId, - 'convoy', - 'open', - parsed.title, - null, - null, - null, - null, - 'medium', - JSON.stringify(['gt:convoy']), - '{}', - parsed.created_by ?? null, - timestamp, - timestamp, - null, - ] - ); + this.db + .insert(beads) + .values({ + bead_id: convoyId, + type: 'convoy', + status: 'open', + title: parsed.title, + body: null, + rig_id: null, + parent_bead_id: null, + assignee_agent_bead_id: null, + priority: 'medium', + labels: JSON.stringify(['gt:convoy']), + metadata: '{}', + created_by: parsed.created_by ?? null, + created_at: timestamp, + updated_at: timestamp, + closed_at: null, + }) + .run(); // Create convoy_metadata - query( - this.sql, - /* sql */ ` - INSERT INTO ${convoy_metadata} ( - ${convoy_metadata.columns.bead_id}, ${convoy_metadata.columns.total_beads}, - ${convoy_metadata.columns.closed_beads}, ${convoy_metadata.columns.landed_at} - ) VALUES (?, ?, ?, ?) - `, - [convoyId, parsed.beads.length, 0, null] - ); + this.db + .insert(convoy_metadata) + .values({ + bead_id: convoyId, + total_beads: parsed.beads.length, + closed_beads: 0, + landed_at: null, + }) + .run(); // Track beads via bead_dependencies for (const bead of parsed.beads) { - query( - this.sql, - /* sql */ ` - INSERT INTO ${bead_dependencies} ( - ${bead_dependencies.columns.bead_id}, - ${bead_dependencies.columns.depends_on_bead_id}, - ${bead_dependencies.columns.dependency_type} - ) VALUES (?, ?, ?) - `, - [bead.bead_id, convoyId, 'tracks'] - ); + this.db + .insert(bead_dependencies) + .values({ + bead_id: bead.bead_id, + depends_on_bead_id: convoyId, + dependency_type: 'tracks', + }) + .run(); } const convoy = this.getConvoy(convoyId); @@ -909,66 +890,54 @@ export class TownDO extends DurableObject { } async onBeadClosed(input: { convoyId: string; beadId: string }): Promise { - await this.ensureInitialized(); - // Count closed tracked beads - const closedRows = [ - ...query( - this.sql, - /* sql */ ` - SELECT COUNT(1) AS count FROM ${bead_dependencies} - INNER JOIN ${beads} ON ${bead_dependencies.bead_id} = ${beads.bead_id} - WHERE ${bead_dependencies.depends_on_bead_id} = ? - AND ${bead_dependencies.dependency_type} = 'tracks' - AND ${beads.status} = 'closed' - `, - [input.convoyId] - ), - ]; - const closedCount = z.object({ count: z.number() }).parse(closedRows[0] ?? { count: 0 }).count; - - query( - this.sql, - /* sql */ ` - UPDATE ${convoy_metadata} - SET ${convoy_metadata.columns.closed_beads} = ? - WHERE ${convoy_metadata.bead_id} = ? - `, - [closedCount, input.convoyId] - ); + const closedResult = this.db + .select({ count: count() }) + .from(bead_dependencies) + .innerJoin(beads, eq(bead_dependencies.bead_id, beads.bead_id)) + .where( + and( + eq(bead_dependencies.depends_on_bead_id, input.convoyId), + eq(bead_dependencies.dependency_type, 'tracks'), + eq(beads.status, 'closed') + ) + ) + .get(); + const closedCount = closedResult?.count ?? 0; + + this.db + .update(convoy_metadata) + .set({ closed_beads: closedCount }) + .where(eq(convoy_metadata.bead_id, input.convoyId)) + .run(); const convoy = this.getConvoy(input.convoyId); if (convoy && convoy.status === 'active' && convoy.closed_beads >= convoy.total_beads) { const timestamp = now(); - query( - this.sql, - /* sql */ ` - UPDATE ${beads} - SET ${beads.columns.status} = 'closed', ${beads.columns.closed_at} = ?, ${beads.columns.updated_at} = ? - WHERE ${beads.bead_id} = ? - `, - [timestamp, timestamp, input.convoyId] - ); - query( - this.sql, - /* sql */ ` - UPDATE ${convoy_metadata} - SET ${convoy_metadata.columns.landed_at} = ? - WHERE ${convoy_metadata.bead_id} = ? - `, - [timestamp, input.convoyId] - ); + this.db + .update(beads) + .set({ status: 'closed', closed_at: timestamp, updated_at: timestamp }) + .where(eq(beads.bead_id, input.convoyId)) + .run(); + this.db + .update(convoy_metadata) + .set({ landed_at: timestamp }) + .where(eq(convoy_metadata.bead_id, input.convoyId)) + .run(); return this.getConvoy(input.convoyId); } return convoy; } private getConvoy(convoyId: string): ConvoyEntry | null { - const rows = [ - ...query(this.sql, /* sql */ `${CONVOY_JOIN} WHERE ${beads.bead_id} = ?`, [convoyId]), - ]; - if (rows.length === 0) return null; - return toConvoy(ConvoyBeadRecord.parse(rows[0])); + const row = this.db + .select(convoyJoinColumns) + .from(beads) + .innerJoin(convoy_metadata, eq(beads.bead_id, convoy_metadata.bead_id)) + .where(eq(beads.bead_id, convoyId)) + .get(); + if (!row) return null; + return toConvoy(row); } // ══════════════════════════════════════════════════════════════════ @@ -976,38 +945,33 @@ export class TownDO extends DurableObject { // ══════════════════════════════════════════════════════════════════ async acknowledgeEscalation(escalationId: string): Promise { - await this.ensureInitialized(); - query( - this.sql, - /* sql */ ` - UPDATE ${escalation_metadata} - SET ${escalation_metadata.columns.acknowledged} = 1, ${escalation_metadata.columns.acknowledged_at} = ? - WHERE ${escalation_metadata.bead_id} = ? AND ${escalation_metadata.acknowledged} = 0 - `, - [now(), escalationId] - ); + this.db + .update(escalation_metadata) + .set({ acknowledged: 1, acknowledged_at: now() }) + .where( + and(eq(escalation_metadata.bead_id, escalationId), eq(escalation_metadata.acknowledged, 0)) + ) + .run(); return this.getEscalation(escalationId); } async listEscalations(filter?: { acknowledged?: boolean }): Promise { - await this.ensureInitialized(); - const rows = - filter?.acknowledged !== undefined - ? [ - ...query( - this.sql, - /* sql */ `${ESCALATION_JOIN} WHERE ${escalation_metadata.acknowledged} = ? ORDER BY ${beads.created_at} DESC LIMIT 100`, - [filter.acknowledged ? 1 : 0] - ), - ] - : [ - ...query( - this.sql, - /* sql */ `${ESCALATION_JOIN} ORDER BY ${beads.created_at} DESC LIMIT 100`, - [] - ), - ]; - return EscalationBeadRecord.array().parse(rows).map(toEscalation); + const conditions: SQL[] = []; + if (filter?.acknowledged !== undefined) { + conditions.push(eq(escalation_metadata.acknowledged, filter.acknowledged ? 1 : 0)); + } + + const q = this.db + .select(escalationJoinColumns) + .from(beads) + .innerJoin(escalation_metadata, eq(beads.bead_id, escalation_metadata.bead_id)); + + const rows = (conditions.length > 0 ? q.where(and(...conditions)) : q) + .orderBy(desc(beads.created_at)) + .limit(100) + .all(); + + return rows.map(toEscalation); } async routeEscalation(input: { @@ -1018,54 +982,48 @@ export class TownDO extends DurableObject { category?: string; message: string; }): Promise { - await this.ensureInitialized(); const beadId = generateId(); const timestamp = now(); // Create the escalation bead - query( - this.sql, - /* sql */ ` - INSERT INTO ${beads} ( - ${beads.columns.bead_id}, ${beads.columns.type}, ${beads.columns.status}, - ${beads.columns.title}, ${beads.columns.body}, ${beads.columns.rig_id}, - ${beads.columns.parent_bead_id}, ${beads.columns.assignee_agent_bead_id}, - ${beads.columns.priority}, ${beads.columns.labels}, ${beads.columns.metadata}, - ${beads.columns.created_by}, ${beads.columns.created_at}, ${beads.columns.updated_at}, - ${beads.columns.closed_at} - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - beadId, - 'escalation', - 'open', - `Escalation: ${input.message.slice(0, 100)}`, - input.message, - input.source_rig_id, - null, - null, - input.severity === 'critical' ? 'critical' : input.severity === 'high' ? 'high' : 'medium', - JSON.stringify(['gt:escalation', `severity:${input.severity}`]), - '{}', - input.source_agent_id ?? null, - timestamp, - timestamp, - null, - ] - ); + this.db + .insert(beads) + .values({ + bead_id: beadId, + type: 'escalation', + status: 'open', + title: `Escalation: ${input.message.slice(0, 100)}`, + body: input.message, + rig_id: input.source_rig_id, + parent_bead_id: null, + assignee_agent_bead_id: null, + priority: + input.severity === 'critical' + ? 'critical' + : input.severity === 'high' + ? 'high' + : 'medium', + labels: JSON.stringify(['gt:escalation', `severity:${input.severity}`]), + metadata: '{}', + created_by: input.source_agent_id ?? null, + created_at: timestamp, + updated_at: timestamp, + closed_at: null, + }) + .run(); // Create escalation_metadata - query( - this.sql, - /* sql */ ` - INSERT INTO ${escalation_metadata} ( - ${escalation_metadata.columns.bead_id}, ${escalation_metadata.columns.severity}, - ${escalation_metadata.columns.category}, ${escalation_metadata.columns.acknowledged}, - ${escalation_metadata.columns.re_escalation_count}, ${escalation_metadata.columns.acknowledged_at} - ) VALUES (?, ?, ?, ?, ?, ?) - `, - [beadId, input.severity, input.category ?? null, 0, 0, null] - ); + this.db + .insert(escalation_metadata) + .values({ + bead_id: beadId, + severity: input.severity, + category: input.category ?? null, + acknowledged: 0, + re_escalation_count: 0, + acknowledged_at: null, + }) + .run(); const escalation = this.getEscalation(beadId); if (!escalation) throw new Error('Failed to create escalation'); @@ -1077,7 +1035,7 @@ export class TownDO extends DurableObject { ).catch(err => { console.warn(`${TOWN_LOG} routeEscalation: failed to notify mayor:`, err); try { - beadOps.logBeadEvent(this.sql, { + beadOps.logBeadEvent(this.db, { beadId, agentId: input.source_agent_id ?? null, eventType: 'notification_failed', @@ -1100,11 +1058,14 @@ export class TownDO extends DurableObject { } private getEscalation(escalationId: string): EscalationEntry | null { - const rows = [ - ...query(this.sql, /* sql */ `${ESCALATION_JOIN} WHERE ${beads.bead_id} = ?`, [escalationId]), - ]; - if (rows.length === 0) return null; - return toEscalation(EscalationBeadRecord.parse(rows[0])); + const row = this.db + .select(escalationJoinColumns) + .from(beads) + .innerJoin(escalation_metadata, eq(beads.bead_id, escalation_metadata.bead_id)) + .where(eq(beads.bead_id, escalationId)) + .get(); + if (!row) return null; + return toEscalation(row); } // ══════════════════════════════════════════════════════════════════ @@ -1112,11 +1073,10 @@ export class TownDO extends DurableObject { // ══════════════════════════════════════════════════════════════════ async alarm(): Promise { - await this.ensureInitialized(); const townId = this.townId; console.log(`${TOWN_LOG} alarm: fired for town=${townId}`); - const hasRigs = rigs.listRigs(this.sql).length > 0; + const hasRigs = rigs.listRigs(this.db).length > 0; if (hasRigs) { try { await this.ensureContainerReady(); @@ -1158,32 +1118,22 @@ export class TownDO extends DurableObject { } private hasActiveWork(): boolean { - const activeAgentRows = [ - ...query( - this.sql, - /* sql */ `SELECT COUNT(*) as cnt FROM ${agent_metadata} WHERE ${agent_metadata.status} IN ('working', 'stalled')`, - [] - ), - ]; - const pendingBeadRows = [ - ...query( - this.sql, - /* sql */ `SELECT COUNT(*) as cnt FROM ${agent_metadata} WHERE ${agent_metadata.status} = 'idle' AND ${agent_metadata.current_hook_bead_id} IS NOT NULL`, - [] - ), - ]; - const pendingReviewRows = [ - ...query( - this.sql, - /* sql */ `SELECT COUNT(*) as cnt FROM ${beads} WHERE ${beads.type} = 'merge_request' AND ${beads.status} IN ('open', 'in_progress')`, - [] - ), - ]; - return ( - Number(activeAgentRows[0]?.cnt ?? 0) > 0 || - Number(pendingBeadRows[0]?.cnt ?? 0) > 0 || - Number(pendingReviewRows[0]?.cnt ?? 0) > 0 - ); + const active = this.db + .select({ cnt: count() }) + .from(agent_metadata) + .where(inArray(agent_metadata.status, ['working', 'stalled'])) + .get(); + const pending = this.db + .select({ cnt: count() }) + .from(agent_metadata) + .where(and(eq(agent_metadata.status, 'idle'), isNotNull(agent_metadata.current_hook_bead_id))) + .get(); + const reviews = this.db + .select({ cnt: count() }) + .from(beads) + .where(and(eq(beads.type, 'merge_request'), inArray(beads.status, ['open', 'in_progress']))) + .get(); + return (active?.cnt ?? 0) > 0 || (pending?.cnt ?? 0) > 0 || (reviews?.cnt ?? 0) > 0; } /** @@ -1193,7 +1143,7 @@ export class TownDO extends DurableObject { */ private async dispatchAgent(agent: Agent, bead: Bead): Promise { try { - const rigId = agent.rig_id ?? rigs.listRigs(this.sql)[0]?.id ?? ''; + const rigId = agent.rig_id ?? rigs.listRigs(this.db)[0]?.id ?? ''; const rigConfig = rigId ? await this.getRigConfig(rigId) : null; if (!rigConfig) { console.warn(`${TOWN_LOG} dispatchAgent: no rig config for agent=${agent.id} rig=${rigId}`); @@ -1206,16 +1156,14 @@ export class TownDO extends DurableObject { // Mark dispatch in progress: set last_activity_at so schedulePendingWork // skips this agent while the container start is in flight, and bump // dispatch_attempts for the retry budget. - query( - this.sql, - /* sql */ ` - UPDATE ${agent_metadata} - SET ${agent_metadata.columns.dispatch_attempts} = ${agent_metadata.columns.dispatch_attempts} + 1, - ${agent_metadata.columns.last_activity_at} = ? - WHERE ${agent_metadata.bead_id} = ? - `, - [now(), agent.id] - ); + this.db + .update(agent_metadata) + .set({ + dispatch_attempts: sql`${agent_metadata.dispatch_attempts} + 1`, + last_activity_at: now(), + }) + .where(eq(agent_metadata.bead_id, agent.id)) + .run(); const started = await dispatch.startAgentInContainer(this.env, this.ctx.storage, { townId: this.townId, @@ -1237,17 +1185,15 @@ export class TownDO extends DurableObject { }); if (started) { - query( - this.sql, - /* sql */ ` - UPDATE ${agent_metadata} - SET ${agent_metadata.columns.status} = 'working', - ${agent_metadata.columns.dispatch_attempts} = 0, - ${agent_metadata.columns.last_activity_at} = ? - WHERE ${agent_metadata.bead_id} = ? - `, - [now(), agent.id] - ); + this.db + .update(agent_metadata) + .set({ + status: 'working', + dispatch_attempts: 0, + last_activity_at: now(), + }) + .where(eq(agent_metadata.bead_id, agent.id)) + .run(); console.log(`${TOWN_LOG} dispatchAgent: started agent=${agent.name}(${agent.id})`); } return started; @@ -1264,41 +1210,51 @@ export class TownDO extends DurableObject { */ private async schedulePendingWork(): Promise { const cooldownCutoff = new Date(Date.now() - DISPATCH_COOLDOWN_MS).toISOString(); - const rows = [ - ...query( - this.sql, - /* sql */ ` - SELECT ${beads}.*, - ${agent_metadata.role}, ${agent_metadata.identity}, - ${agent_metadata.container_process_id}, - ${agent_metadata.status} AS status, - ${agent_metadata.current_hook_bead_id}, - ${agent_metadata.dispatch_attempts}, ${agent_metadata.last_activity_at}, - ${agent_metadata.checkpoint} - FROM ${beads} - INNER JOIN ${agent_metadata} ON ${beads.bead_id} = ${agent_metadata.bead_id} - WHERE ${agent_metadata.status} = 'idle' - AND ${agent_metadata.current_hook_bead_id} IS NOT NULL - AND (${agent_metadata.last_activity_at} IS NULL OR ${agent_metadata.last_activity_at} < ?) - `, - [cooldownCutoff] - ), - ]; - const pendingAgents: Agent[] = AgentBeadRecord.array() - .parse(rows) - .map(row => ({ - id: row.bead_id, - rig_id: row.rig_id, - role: row.role, - name: row.title, - identity: row.identity, - status: row.status, - current_hook_bead_id: row.current_hook_bead_id, - dispatch_attempts: row.dispatch_attempts, - last_activity_at: row.last_activity_at, - checkpoint: row.checkpoint, - created_at: row.created_at, - })); + + // Exclude beads.status from the join columns since agent_metadata.status + // shadows it and we need the agent status, not the bead status. + const { status: _beadStatus, ...beadCols } = getTableColumns(beads); + const pendingAgentColumns = { + ...beadCols, + role: agent_metadata.role, + identity: agent_metadata.identity, + container_process_id: agent_metadata.container_process_id, + status: agent_metadata.status, + current_hook_bead_id: agent_metadata.current_hook_bead_id, + dispatch_attempts: agent_metadata.dispatch_attempts, + last_activity_at: agent_metadata.last_activity_at, + checkpoint: agent_metadata.checkpoint, + }; + + const rows = this.db + .select(pendingAgentColumns) + .from(beads) + .innerJoin(agent_metadata, eq(beads.bead_id, agent_metadata.bead_id)) + .where( + and( + eq(agent_metadata.status, 'idle'), + isNotNull(agent_metadata.current_hook_bead_id), + or( + isNull(agent_metadata.last_activity_at), + sql`${agent_metadata.last_activity_at} < ${cooldownCutoff}` + ) + ) + ) + .all(); + + const pendingAgents: Agent[] = rows.map(row => ({ + id: row.bead_id, + rig_id: row.rig_id, + role: row.role, + name: row.title, + identity: row.identity, + status: row.status, + current_hook_bead_id: row.current_hook_bead_id, + dispatch_attempts: row.dispatch_attempts, + last_activity_at: row.last_activity_at, + checkpoint: row.checkpoint, + created_at: row.created_at, + })); console.log(`${TOWN_LOG} schedulePendingWork: found ${pendingAgents.length} pending agents`); if (pendingAgents.length === 0) return; @@ -1308,12 +1264,12 @@ export class TownDO extends DurableObject { for (const agent of pendingAgents) { const beadId = agent.current_hook_bead_id; if (!beadId) continue; - const bead = beadOps.getBead(this.sql, beadId); + const bead = beadOps.getBead(this.db, beadId); if (!bead) continue; if (agent.dispatch_attempts >= MAX_DISPATCH_ATTEMPTS) { - beadOps.updateBeadStatus(this.sql, beadId, 'failed', agent.id); - agents.unhookBead(this.sql, agent.id); + beadOps.updateBeadStatus(this.db, beadId, 'failed', agent.id); + agents.unhookBead(this.db, agent.id); continue; } @@ -1334,22 +1290,15 @@ export class TownDO extends DurableObject { const townId = this.townId; const guppThreshold = new Date(Date.now() - GUPP_THRESHOLD_MS).toISOString(); - const WorkingAgentRow = AgentMetadataRecord.pick({ - bead_id: true, - current_hook_bead_id: true, - last_activity_at: true, - }); - const workingAgents = WorkingAgentRow.array().parse([ - ...query( - this.sql, - /* sql */ ` - SELECT ${agent_metadata.bead_id}, ${agent_metadata.current_hook_bead_id}, ${agent_metadata.last_activity_at} - FROM ${agent_metadata} - WHERE ${agent_metadata.status} IN ('working', 'stalled') - `, - [] - ), - ]); + const workingAgents = this.db + .select({ + bead_id: agent_metadata.bead_id, + current_hook_bead_id: agent_metadata.current_hook_bead_id, + last_activity_at: agent_metadata.last_activity_at, + }) + .from(agent_metadata) + .where(inArray(agent_metadata.status, ['working', 'stalled'])) + .all(); for (const working of workingAgents) { const agentId = working.bead_id; @@ -1360,36 +1309,35 @@ export class TownDO extends DurableObject { if (containerInfo.status === 'not_found' || containerInfo.status === 'exited') { if (containerInfo.exitReason === 'completed') { - reviewQueue.agentCompleted(this.sql, agentId, { status: 'completed' }); + reviewQueue.agentCompleted(this.db, agentId, { status: 'completed' }); continue; } - query( - this.sql, - /* sql */ `UPDATE ${agent_metadata} SET ${agent_metadata.columns.status} = 'idle', ${agent_metadata.columns.last_activity_at} = ? WHERE ${agent_metadata.bead_id} = ?`, - [now(), agentId] - ); + this.db + .update(agent_metadata) + .set({ status: 'idle', last_activity_at: now() }) + .where(eq(agent_metadata.bead_id, agentId)) + .run(); continue; } // GUPP violation check if (lastActivity && lastActivity < guppThreshold) { // Check for existing GUPP mail - const existingGupp = [ - ...query( - this.sql, - /* sql */ ` - SELECT ${beads.bead_id} FROM ${beads} - WHERE ${beads.type} = 'message' - AND ${beads.assignee_agent_bead_id} = ? - AND ${beads.title} = 'GUPP_CHECK' - AND ${beads.status} = 'open' - LIMIT 1 - `, - [agentId] - ), - ]; + const existingGupp = this.db + .select({ bead_id: beads.bead_id }) + .from(beads) + .where( + and( + eq(beads.type, 'message'), + eq(beads.assignee_agent_bead_id, agentId), + eq(beads.title, 'GUPP_CHECK'), + eq(beads.status, 'open') + ) + ) + .limit(1) + .all(); if (existingGupp.length === 0) { - mail.sendMail(this.sql, { + mail.sendMail(this.db, { from_agent_id: 'witness', to_agent_id: agentId, subject: 'GUPP_CHECK', @@ -1408,7 +1356,7 @@ export class TownDO extends DurableObject { * it isn't sent again on the next alarm tick. */ private async deliverPendingMail(): Promise { - const pendingByAgent = mail.getPendingMailForWorkingAgents(this.sql); + const pendingByAgent = mail.getPendingMailForWorkingAgents(this.db); if (pendingByAgent.size === 0) return; console.log( @@ -1423,7 +1371,7 @@ export class TownDO extends DurableObject { if (sent) { // Mark delivered only after the container accepted the message - mail.readAndDeliverMail(this.sql, agentId); + mail.readAndDeliverMail(this.db, agentId); console.log( `${TOWN_LOG} deliverPendingMail: delivered ${messages.length} message(s) to agent=${agentId}` ); @@ -1441,9 +1389,9 @@ export class TownDO extends DurableObject { * Process the review queue: pop pending entries and trigger merge. */ private async processReviewQueue(): Promise { - reviewQueue.recoverStuckReviews(this.sql); + reviewQueue.recoverStuckReviews(this.db); - const entry = reviewQueue.popReviewQueue(this.sql); + const entry = reviewQueue.popReviewQueue(this.db); if (!entry) return; // Resolve rig from the merge_request bead — not rigList[0] which would @@ -1451,12 +1399,12 @@ export class TownDO extends DurableObject { const rigId = entry.rig_id; if (!rigId) { console.error(`${TOWN_LOG} processReviewQueue: entry ${entry.id} has no rig_id, skipping`); - reviewQueue.completeReview(this.sql, entry.id, 'failed'); + reviewQueue.completeReview(this.db, entry.id, 'failed'); return; } const rigConfig = await this.getRigConfig(rigId); if (!rigConfig) { - reviewQueue.completeReview(this.sql, entry.id, 'failed'); + reviewQueue.completeReview(this.db, entry.id, 'failed'); return; } @@ -1464,7 +1412,7 @@ export class TownDO extends DurableObject { const gates = townConfig.refinery?.gates ?? []; if (gates.length > 0) { - const refineryAgent = agents.getOrCreateAgent(this.sql, 'refinery', rigId, this.townId); + const refineryAgent = agents.getOrCreateAgent(this.db, 'refinery', rigId, this.townId); const { buildRefinerySystemPrompt } = await import('../prompts/refinery-system.prompt'); const systemPrompt = buildRefinerySystemPrompt({ @@ -1480,7 +1428,7 @@ export class TownDO extends DurableObject { // Hook the refinery to the MR bead (entry.id), not the source bead // (entry.bead_id). The source bead stays closed with its original // polecat assignee preserved. - agents.hookBead(this.sql, refineryAgent.id, entry.id); + agents.hookBead(this.db, refineryAgent.id, entry.id); const started = await dispatch.startAgentInContainer(this.env, this.ctx.storage, { townId: this.townId, @@ -1503,7 +1451,7 @@ export class TownDO extends DurableObject { }); if (!started) { - agents.unhookBead(this.sql, refineryAgent.id); + agents.unhookBead(this.db, refineryAgent.id); await this.triggerDeterministicMerge(rigConfig, entry, townConfig); } } else { @@ -1529,7 +1477,7 @@ export class TownDO extends DurableObject { townConfig, }); if (!ok) { - reviewQueue.completeReview(this.sql, entry.id, 'failed'); + reviewQueue.completeReview(this.db, entry.id, 'failed'); } } @@ -1537,13 +1485,18 @@ export class TownDO extends DurableObject { * Bump severity of stale unacknowledged escalations. */ private async reEscalateStaleEscalations(): Promise { - const candidates = [ - ...query( - this.sql, - /* sql */ `${ESCALATION_JOIN} WHERE ${escalation_metadata.acknowledged} = 0 AND ${escalation_metadata.re_escalation_count} < ?`, - [MAX_RE_ESCALATIONS] - ), - ].map(r => toEscalation(EscalationBeadRecord.parse(r))); + const candidates = this.db + .select(escalationJoinColumns) + .from(beads) + .innerJoin(escalation_metadata, eq(beads.bead_id, escalation_metadata.bead_id)) + .where( + and( + eq(escalation_metadata.acknowledged, 0), + sql`${escalation_metadata.re_escalation_count} < ${MAX_RE_ESCALATIONS}` + ) + ) + .all() + .map(toEscalation); const nowMs = Date.now(); for (const esc of candidates) { @@ -1555,16 +1508,14 @@ export class TownDO extends DurableObject { if (currentIdx < 0 || currentIdx >= SEVERITY_ORDER.length - 1) continue; const newSeverity = SEVERITY_ORDER[currentIdx + 1]; - query( - this.sql, - /* sql */ ` - UPDATE ${escalation_metadata} - SET ${escalation_metadata.columns.severity} = ?, - ${escalation_metadata.columns.re_escalation_count} = ${escalation_metadata.columns.re_escalation_count} + 1 - WHERE ${escalation_metadata.bead_id} = ? - `, - [newSeverity, esc.id] - ); + this.db + .update(escalation_metadata) + .set({ + severity: newSeverity, + re_escalation_count: sql`${escalation_metadata.re_escalation_count} + 1`, + }) + .where(eq(escalation_metadata.bead_id, esc.id)) + .run(); if (newSeverity !== 'low') { this.sendMayorMessage( @@ -1572,7 +1523,7 @@ export class TownDO extends DurableObject { ).catch(err => { console.warn(`${TOWN_LOG} re-escalation: failed to notify mayor:`, err); try { - beadOps.logBeadEvent(this.sql, { + beadOps.logBeadEvent(this.db, { beadId: esc.id, agentId: null, eventType: 'notification_failed', @@ -1595,12 +1546,12 @@ export class TownDO extends DurableObject { } private async ensureContainerReady(): Promise { - const hasRigs = rigs.listRigs(this.sql).length > 0; + const hasRigs = rigs.listRigs(this.db).length > 0; if (!hasRigs) return; const hasWork = this.hasActiveWork(); if (!hasWork) { - const rigList = rigs.listRigs(this.sql); + const rigList = rigs.listRigs(this.db); const newestRigAge = rigList.reduce((min, r) => { const age = Date.now() - new Date(r.created_at).getTime(); return Math.min(min, age); @@ -1637,7 +1588,7 @@ export class TownDO extends DurableObject { console.log(`${TOWN_LOG} destroy: clearing all storage and alarms`); try { - const allAgents = agents.listAgents(this.sql); + const allAgents = agents.listAgents(this.db); await Promise.allSettled( allAgents.map(agent => getAgentDOStub(this.env, agent.id).destroy()) ); diff --git a/cloudflare-gastown/src/dos/town/agents.ts b/cloudflare-gastown/src/dos/town/agents.ts index 890ca26d0..1b3591937 100644 --- a/cloudflare-gastown/src/dos/town/agents.ts +++ b/cloudflare-gastown/src/dos/town/agents.ts @@ -5,9 +5,10 @@ * joined with agent_metadata for operational state. */ -import { beads, BeadRecord, AgentBeadRecord } from '../../db/tables/beads.table'; -import { agent_metadata } from '../../db/tables/agent-metadata.table'; -import { query } from '../../util/query.util'; +import type { DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; +import type { SQL } from 'drizzle-orm'; +import { eq, and, or, asc, desc, isNull, inArray, ne, getTableColumns } from 'drizzle-orm'; +import { beads, agent_metadata, type BeadsSelect } from '../../db/sqlite-schema'; import { logBeadEvent, getBead, deleteBead } from './beads'; import { readAndDeliverMail } from './mail'; import type { @@ -51,177 +52,173 @@ function now(): string { return new Date().toISOString(); } -/** Map a parsed AgentBeadRecord to the Agent API type. */ -function toAgent(row: AgentBeadRecord): Agent { +// Agent join: select all bead columns except status (which comes from agent_metadata) +const { status: _beadStatus, ...beadColumns } = getTableColumns(beads); +const agentJoinColumns = { + ...beadColumns, + role: agent_metadata.role, + identity: agent_metadata.identity, + container_process_id: agent_metadata.container_process_id, + status: agent_metadata.status, + current_hook_bead_id: agent_metadata.current_hook_bead_id, + dispatch_attempts: agent_metadata.dispatch_attempts, + last_activity_at: agent_metadata.last_activity_at, + checkpoint: agent_metadata.checkpoint, +}; + +type AgentJoinRow = { + bead_id: string; + type: string; + title: string; + body: string | null; + rig_id: string | null; + parent_bead_id: string | null; + assignee_agent_bead_id: string | null; + priority: string | null; + labels: string | null; + metadata: string | null; + created_by: string | null; + created_at: string; + updated_at: string; + closed_at: string | null; + role: string; + identity: string; + container_process_id: string | null; + status: string; + current_hook_bead_id: string | null; + dispatch_attempts: number; + last_activity_at: string | null; + checkpoint: string | null; +}; + +/** Map an agent join row to the Agent API type. */ +function toAgent(row: AgentJoinRow): Agent { return { id: row.bead_id, rig_id: row.rig_id, - role: row.role, + role: row.role as Agent['role'], name: row.title, identity: row.identity, - status: row.status, + status: row.status as Agent['status'], current_hook_bead_id: row.current_hook_bead_id, dispatch_attempts: row.dispatch_attempts, last_activity_at: row.last_activity_at, - checkpoint: row.checkpoint, + checkpoint: row.checkpoint ? JSON.parse(row.checkpoint) : null, created_at: row.created_at, }; } -/** - * SQL fragment for joining beads + agent_metadata. - * Uses SELECT ${beads}.* so all bead columns are available, then selects - * the agent_metadata columns explicitly (since status conflicts). - * agent_metadata.status is aliased to avoid colliding with beads.status. - */ -const AGENT_JOIN = /* sql */ ` - SELECT ${beads}.*, - ${agent_metadata.role}, ${agent_metadata.identity}, - ${agent_metadata.container_process_id}, - ${agent_metadata.status} AS status, - ${agent_metadata.current_hook_bead_id}, - ${agent_metadata.dispatch_attempts}, ${agent_metadata.last_activity_at}, - ${agent_metadata.checkpoint} - FROM ${beads} - INNER JOIN ${agent_metadata} ON ${beads.bead_id} = ${agent_metadata.bead_id} -`; - -export function initAgentTables(_sql: SqlStorage): void { +function agentJoinQuery(db: DrizzleSqliteDODatabase) { + return db + .select(agentJoinColumns) + .from(beads) + .innerJoin(agent_metadata, eq(beads.bead_id, agent_metadata.bead_id)); +} + +export function initAgentTables(_db: DrizzleSqliteDODatabase): void { // Agent tables are now initialized in beads.initBeadTables() // (beads table + agent_metadata satellite) } -export function registerAgent(sql: SqlStorage, input: RegisterAgentInput): Agent { +export function registerAgent(db: DrizzleSqliteDODatabase, input: RegisterAgentInput): Agent { const id = generateId(); const timestamp = now(); // Create the agent bead - query( - sql, - /* sql */ ` - INSERT INTO ${beads} ( - ${beads.columns.bead_id}, ${beads.columns.type}, ${beads.columns.status}, - ${beads.columns.title}, ${beads.columns.body}, ${beads.columns.rig_id}, - ${beads.columns.parent_bead_id}, ${beads.columns.assignee_agent_bead_id}, - ${beads.columns.priority}, ${beads.columns.labels}, ${beads.columns.metadata}, - ${beads.columns.created_by}, ${beads.columns.created_at}, ${beads.columns.updated_at}, - ${beads.columns.closed_at} - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - id, - 'agent', - 'open', - input.name, - null, - input.rig_id ?? null, - null, - null, - 'medium', - '[]', - '{}', - null, - timestamp, - timestamp, - null, - ] - ); + db.insert(beads) + .values({ + bead_id: id, + type: 'agent', + status: 'open', + title: input.name, + body: null, + rig_id: input.rig_id ?? null, + parent_bead_id: null, + assignee_agent_bead_id: null, + priority: 'medium', + labels: '[]', + metadata: '{}', + created_by: null, + created_at: timestamp, + updated_at: timestamp, + closed_at: null, + }) + .run(); // Create the agent_metadata satellite row - query( - sql, - /* sql */ ` - INSERT INTO ${agent_metadata} ( - ${agent_metadata.columns.bead_id}, ${agent_metadata.columns.role}, - ${agent_metadata.columns.identity}, ${agent_metadata.columns.container_process_id}, - ${agent_metadata.columns.status}, ${agent_metadata.columns.current_hook_bead_id}, - ${agent_metadata.columns.dispatch_attempts}, ${agent_metadata.columns.checkpoint}, - ${agent_metadata.columns.last_activity_at} - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [id, input.role, input.identity, null, 'idle', null, 0, null, null] - ); - - const agent = getAgent(sql, id); + db.insert(agent_metadata) + .values({ + bead_id: id, + role: input.role, + identity: input.identity, + container_process_id: null, + status: 'idle', + current_hook_bead_id: null, + dispatch_attempts: 0, + checkpoint: null, + last_activity_at: null, + }) + .run(); + + const agent = getAgent(db, id); if (!agent) throw new Error('Failed to create agent'); return agent; } -export function getAgent(sql: SqlStorage, agentId: string): Agent | null { - const rows = [...query(sql, /* sql */ `${AGENT_JOIN} WHERE ${beads.bead_id} = ?`, [agentId])]; - if (rows.length === 0) return null; - return toAgent(AgentBeadRecord.parse(rows[0])); +export function getAgent(db: DrizzleSqliteDODatabase, agentId: string): Agent | null { + const row = agentJoinQuery(db).where(eq(beads.bead_id, agentId)).get(); + if (!row) return null; + return toAgent(row as AgentJoinRow); } -export function getAgentByIdentity(sql: SqlStorage, identity: string): Agent | null { - const rows = [ - ...query(sql, /* sql */ `${AGENT_JOIN} WHERE ${agent_metadata.identity} = ?`, [identity]), - ]; - if (rows.length === 0) return null; - return toAgent(AgentBeadRecord.parse(rows[0])); +export function getAgentByIdentity(db: DrizzleSqliteDODatabase, identity: string): Agent | null { + const row = agentJoinQuery(db).where(eq(agent_metadata.identity, identity)).get(); + if (!row) return null; + return toAgent(row as AgentJoinRow); } -export function listAgents(sql: SqlStorage, filter?: AgentFilter): Agent[] { - const rows = [ - ...query( - sql, - /* sql */ ` - ${AGENT_JOIN} - WHERE (? IS NULL OR ${agent_metadata.role} = ?) - AND (? IS NULL OR ${agent_metadata.status} = ?) - AND (? IS NULL OR ${beads.rig_id} = ?) - ORDER BY ${beads.created_at} ASC - `, - [ - filter?.role ?? null, - filter?.role ?? null, - filter?.status ?? null, - filter?.status ?? null, - filter?.rig_id ?? null, - filter?.rig_id ?? null, - ] - ), - ]; - return AgentBeadRecord.array().parse(rows).map(toAgent); +export function listAgents(db: DrizzleSqliteDODatabase, filter?: AgentFilter): Agent[] { + const conditions: SQL[] = []; + if (filter?.role) conditions.push(eq(agent_metadata.role, filter.role)); + if (filter?.status) conditions.push(eq(agent_metadata.status, filter.status)); + if (filter?.rig_id) conditions.push(eq(beads.rig_id, filter.rig_id)); + + const query = agentJoinQuery(db).$dynamic(); + if (conditions.length > 0) query.where(and(...conditions)); + + const rows = query.orderBy(asc(beads.created_at)).all(); + return (rows as AgentJoinRow[]).map(toAgent); } -export function updateAgentStatus(sql: SqlStorage, agentId: string, status: string): void { - query( - sql, - /* sql */ ` - UPDATE ${agent_metadata} - SET ${agent_metadata.columns.status} = ? - WHERE ${agent_metadata.bead_id} = ? - `, - [status, agentId] - ); +export function updateAgentStatus( + db: DrizzleSqliteDODatabase, + agentId: string, + status: string +): void { + db.update(agent_metadata) + .set({ status: status as 'idle' }) + .where(eq(agent_metadata.bead_id, agentId)) + .run(); } -export function deleteAgent(sql: SqlStorage, agentId: string): void { +export function deleteAgent(db: DrizzleSqliteDODatabase, agentId: string): void { // Unassign beads that reference this agent - query( - sql, - /* sql */ ` - UPDATE ${beads} - SET ${beads.columns.assignee_agent_bead_id} = NULL, - ${beads.columns.status} = 'open', - ${beads.columns.updated_at} = ? - WHERE ${beads.assignee_agent_bead_id} = ? - `, - [now(), agentId] - ); + db.update(beads) + .set({ assignee_agent_bead_id: null, status: 'open', updated_at: now() }) + .where(eq(beads.assignee_agent_bead_id, agentId)) + .run(); // deleteBead cascades to agent_metadata, bead_events, bead_dependencies, etc. - deleteBead(sql, agentId); + deleteBead(db, agentId); } // ── Hooks (GUPP) ──────────────────────────────────────────────────── -export function hookBead(sql: SqlStorage, agentId: string, beadId: string): void { - const agent = getAgent(sql, agentId); +export function hookBead(db: DrizzleSqliteDODatabase, agentId: string, beadId: string): void { + const agent = getAgent(db, agentId); if (!agent) throw new Error(`Agent ${agentId} not found`); - const bead = getBead(sql, beadId); + const bead = getBead(db, beadId); if (!bead) throw new Error(`Bead ${beadId} not found`); // Already hooked to this bead — idempotent @@ -234,32 +231,26 @@ export function hookBead(sql: SqlStorage, agentId: string, beadId: string): void ); } - query( - sql, - /* sql */ ` - UPDATE ${agent_metadata} - SET ${agent_metadata.columns.current_hook_bead_id} = ?, - ${agent_metadata.columns.status} = 'idle', - ${agent_metadata.columns.dispatch_attempts} = 0, - ${agent_metadata.columns.last_activity_at} = ? - WHERE ${agent_metadata.bead_id} = ? - `, - [beadId, now(), agentId] - ); - - query( - sql, - /* sql */ ` - UPDATE ${beads} - SET ${beads.columns.status} = 'in_progress', - ${beads.columns.assignee_agent_bead_id} = ?, - ${beads.columns.updated_at} = ? - WHERE ${beads.bead_id} = ? - `, - [agentId, now(), beadId] - ); - - logBeadEvent(sql, { + db.update(agent_metadata) + .set({ + current_hook_bead_id: beadId, + status: 'idle', + dispatch_attempts: 0, + last_activity_at: now(), + }) + .where(eq(agent_metadata.bead_id, agentId)) + .run(); + + db.update(beads) + .set({ + status: 'in_progress', + assignee_agent_bead_id: agentId, + updated_at: now(), + }) + .where(eq(beads.bead_id, beadId)) + .run(); + + logBeadEvent(db, { beadId, agentId, eventType: 'hooked', @@ -267,24 +258,18 @@ export function hookBead(sql: SqlStorage, agentId: string, beadId: string): void }); } -export function unhookBead(sql: SqlStorage, agentId: string): void { - const agent = getAgent(sql, agentId); +export function unhookBead(db: DrizzleSqliteDODatabase, agentId: string): void { + const agent = getAgent(db, agentId); if (!agent || !agent.current_hook_bead_id) return; const beadId = agent.current_hook_bead_id; - query( - sql, - /* sql */ ` - UPDATE ${agent_metadata} - SET ${agent_metadata.columns.current_hook_bead_id} = NULL, - ${agent_metadata.columns.status} = 'idle' - WHERE ${agent_metadata.bead_id} = ? - `, - [agentId] - ); - - logBeadEvent(sql, { + db.update(agent_metadata) + .set({ current_hook_bead_id: null, status: 'idle' }) + .where(eq(agent_metadata.bead_id, agentId)) + .run(); + + logBeadEvent(db, { beadId, agentId, eventType: 'unhooked', @@ -292,10 +277,10 @@ export function unhookBead(sql: SqlStorage, agentId: string): void { }); } -export function getHookedBead(sql: SqlStorage, agentId: string): Bead | null { - const agent = getAgent(sql, agentId); +export function getHookedBead(db: DrizzleSqliteDODatabase, agentId: string): Bead | null { + const agent = getAgent(db, agentId); if (!agent?.current_hook_bead_id) return null; - return getBead(sql, agent.current_hook_bead_id); + return getBead(db, agent.current_hook_bead_id); } // ── Name Allocation ───────────────────────────────────────────────── @@ -305,23 +290,15 @@ export function getHookedBead(sql: SqlStorage, agentId: string): Bead | null { * Names are town-global (agents belong to the town, not rigs) so we * check all existing polecats across every rig. */ -export function allocatePolecatName(sql: SqlStorage): string { - const usedNames = new Set( - BeadRecord.pick({ title: true }) - .array() - .parse([ - ...query( - sql, - /* sql */ ` - SELECT ${beads.title} FROM ${beads} - INNER JOIN ${agent_metadata} ON ${beads.bead_id} = ${agent_metadata.bead_id} - WHERE ${agent_metadata.role} = 'polecat' - `, - [] - ), - ]) - .map(r => r.title) - ); +export function allocatePolecatName(db: DrizzleSqliteDODatabase): string { + const rows = db + .select({ title: beads.title }) + .from(beads) + .innerJoin(agent_metadata, eq(beads.bead_id, agent_metadata.bead_id)) + .where(eq(agent_metadata.role, 'polecat')) + .all(); + + const usedNames = new Set(rows.map(r => r.title)); for (const name of POLECAT_NAME_POOL) { if (!usedNames.has(name)) return name; @@ -337,7 +314,7 @@ export function allocatePolecatName(sql: SqlStorage): string { * For polecats, create a new one. */ export function getOrCreateAgent( - sql: SqlStorage, + db: DrizzleSqliteDODatabase, role: AgentRole, rigId: string, townId: string @@ -346,63 +323,60 @@ export function getOrCreateAgent( const townSingletonRoles = ['witness', 'mayor']; if (townSingletonRoles.includes(role)) { - const existing = listAgents(sql, { role }); + const existing = listAgents(db, { role }); if (existing.length > 0) return existing[0]; } else { // Per-rig agents (polecat, refinery): reuse an idle one in the SAME rig. // Agents are tied to a rig's worktree/repo — reusing one from a different // rig would dispatch it into the wrong repository. - const idle = [ - ...query( - sql, - /* sql */ ` - ${AGENT_JOIN} - WHERE ${agent_metadata.role} = ? - AND ${agent_metadata.status} = 'idle' - AND ${agent_metadata.current_hook_bead_id} IS NULL - AND ${beads.rig_id} = ? - LIMIT 1 - `, - [role, rigId] - ), - ]; - if (idle.length > 0) return toAgent(AgentBeadRecord.parse(idle[0])); + const row = agentJoinQuery(db) + .where( + and( + eq(agent_metadata.role, role), + eq(agent_metadata.status, 'idle'), + isNull(agent_metadata.current_hook_bead_id), + eq(beads.rig_id, rigId) + ) + ) + .limit(1) + .get(); + if (row) return toAgent(row as AgentJoinRow); } // Create a new agent - const name = role === 'polecat' ? allocatePolecatName(sql) : role; + const name = role === 'polecat' ? allocatePolecatName(db) : role; const identity = `${name}-${role}-${rigId.slice(0, 8)}@${townId.slice(0, 8)}`; - return registerAgent(sql, { role, name, identity, rig_id: rigId }); + return registerAgent(db, { role, name, identity, rig_id: rigId }); } // ── Prime Context ─────────────────────────────────────────────────── -export function prime(sql: SqlStorage, agentId: string): PrimeContext { - const agent = getAgent(sql, agentId); +export function prime(db: DrizzleSqliteDODatabase, agentId: string): PrimeContext { + const agent = getAgent(db, agentId); if (!agent) throw new Error(`Agent ${agentId} not found`); - const hookedBead = agent.current_hook_bead_id ? getBead(sql, agent.current_hook_bead_id) : null; + const hookedBead = agent.current_hook_bead_id ? getBead(db, agent.current_hook_bead_id) : null; - const undeliveredMail = readAndDeliverMail(sql, agentId); + const undeliveredMail = readAndDeliverMail(db, agentId); // Open beads (for context awareness, scoped to agent's rig) - const openBeadRows = [ - ...query( - sql, - /* sql */ ` - SELECT * FROM ${beads} - WHERE ${beads.status} IN ('open', 'in_progress') - AND ${beads.type} != 'agent' - AND ${beads.type} != 'message' - AND (${beads.rig_id} IS NULL OR ${beads.rig_id} = ?) - ORDER BY ${beads.created_at} DESC - LIMIT 20 - `, - [agent.rig_id] - ), - ]; - const openBeads = BeadRecord.array().parse(openBeadRows); + const openBeadRows = db + .select() + .from(beads) + .where( + and( + inArray(beads.status, ['open', 'in_progress']), + ne(beads.type, 'agent'), + ne(beads.type, 'message'), + or(isNull(beads.rig_id), eq(beads.rig_id, agent.rig_id ?? '')) + ) + ) + .orderBy(desc(beads.created_at)) + .limit(20) + .all(); + + const openBeads = openBeadRows.map(parseBead); return { agent, @@ -412,36 +386,34 @@ export function prime(sql: SqlStorage, agentId: string): PrimeContext { }; } +function parseBead(row: BeadsSelect): Bead { + return { + ...row, + labels: JSON.parse(row.labels ?? '[]') as string[], + metadata: JSON.parse(row.metadata ?? '{}') as Record, + }; +} + // ── Checkpoint ────────────────────────────────────────────────────── -export function writeCheckpoint(sql: SqlStorage, agentId: string, data: unknown): void { +export function writeCheckpoint(db: DrizzleSqliteDODatabase, agentId: string, data: unknown): void { const serialized = data === null || data === undefined ? null : JSON.stringify(data); - query( - sql, - /* sql */ ` - UPDATE ${agent_metadata} - SET ${agent_metadata.columns.checkpoint} = ? - WHERE ${agent_metadata.bead_id} = ? - `, - [serialized, agentId] - ); + db.update(agent_metadata) + .set({ checkpoint: serialized }) + .where(eq(agent_metadata.bead_id, agentId)) + .run(); } -export function readCheckpoint(sql: SqlStorage, agentId: string): unknown { - const agent = getAgent(sql, agentId); +export function readCheckpoint(db: DrizzleSqliteDODatabase, agentId: string): unknown { + const agent = getAgent(db, agentId); return agent?.checkpoint ?? null; } // ── Touch (heartbeat helper) ──────────────────────────────────────── -export function touchAgent(sql: SqlStorage, agentId: string): void { - query( - sql, - /* sql */ ` - UPDATE ${agent_metadata} - SET ${agent_metadata.columns.last_activity_at} = ? - WHERE ${agent_metadata.bead_id} = ? - `, - [now(), agentId] - ); +export function touchAgent(db: DrizzleSqliteDODatabase, agentId: string): void { + db.update(agent_metadata) + .set({ last_activity_at: now() }) + .where(eq(agent_metadata.bead_id, agentId)) + .run(); } diff --git a/cloudflare-gastown/src/dos/town/beads.ts b/cloudflare-gastown/src/dos/town/beads.ts index 4aa991e9c..997abe1c7 100644 --- a/cloudflare-gastown/src/dos/town/beads.ts +++ b/cloudflare-gastown/src/dos/town/beads.ts @@ -3,28 +3,35 @@ * After the beads-centric refactor (#441), all object types are beads. */ -import { beads, BeadRecord, createTableBeads, getIndexesBeads } from '../../db/tables/beads.table'; +import type { DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; +import type { SQL } from 'drizzle-orm'; +import { eq, and, or, desc, gt, sql } from 'drizzle-orm'; import { + beads, bead_events, - BeadEventRecord, - createTableBeadEvents, - getIndexesBeadEvents, -} from '../../db/tables/bead-events.table'; -import { bead_dependencies, - createTableBeadDependencies, - getIndexesBeadDependencies, -} from '../../db/tables/bead-dependencies.table'; -import { agent_metadata, createTableAgentMetadata } from '../../db/tables/agent-metadata.table'; -import { review_metadata, createTableReviewMetadata } from '../../db/tables/review-metadata.table'; -import { + agent_metadata, + review_metadata, escalation_metadata, - createTableEscalationMetadata, -} from '../../db/tables/escalation-metadata.table'; -import { convoy_metadata, createTableConvoyMetadata } from '../../db/tables/convoy-metadata.table'; -import { query } from '../../util/query.util'; -import type { CreateBeadInput, BeadFilter, Bead } from '../../types'; -import type { BeadEventType } from '../../db/tables/bead-events.table'; + convoy_metadata, +} from '../../db/sqlite-schema'; +import type { BeadsSelect, BeadEventsSelect } from '../../db/sqlite-schema'; +import type { CreateBeadInput, BeadFilter, Bead, BeadEventRecord, BeadStatus } from '../../types'; + +export type BeadEventType = + | 'created' + | 'assigned' + | 'hooked' + | 'unhooked' + | 'status_changed' + | 'closed' + | 'escalated' + | 'notification_failed' + | 'mail_sent' + | 'review_submitted' + | 'review_completed' + | 'agent_spawned' + | 'agent_exited'; function generateId(): string { return crypto.randomUUID(); @@ -34,77 +41,51 @@ function now(): string { return new Date().toISOString(); } -export function initBeadTables(sql: SqlStorage): void { - query(sql, createTableBeads(), []); - for (const idx of getIndexesBeads()) { - query(sql, idx, []); - } - query(sql, createTableBeadEvents(), []); - for (const idx of getIndexesBeadEvents()) { - query(sql, idx, []); - } - query(sql, createTableBeadDependencies(), []); - for (const idx of getIndexesBeadDependencies()) { - query(sql, idx, []); - } - // Satellite metadata tables - query(sql, createTableAgentMetadata(), []); - query(sql, createTableReviewMetadata(), []); - query(sql, createTableEscalationMetadata(), []); - query(sql, createTableConvoyMetadata(), []); +function parseBead(row: BeadsSelect): Bead { + return { + ...row, + labels: JSON.parse(row.labels ?? '[]') as string[], + metadata: JSON.parse(row.metadata ?? '{}') as Record, + }; } -export function createBead(sql: SqlStorage, input: CreateBeadInput): Bead { +function parseBeadEvent(row: BeadEventsSelect): BeadEventRecord { + return { + ...row, + metadata: JSON.parse(row.metadata ?? '{}') as Record, + }; +} + +// ── Bead CRUD ─────────────────────────────────────────────────────── + +export function createBead(db: DrizzleSqliteDODatabase, input: CreateBeadInput): Bead { const id = generateId(); const timestamp = now(); - const labels = JSON.stringify(input.labels ?? []); - const metadata = JSON.stringify(input.metadata ?? {}); - - query( - sql, - /* sql */ ` - INSERT INTO ${beads} ( - ${beads.columns.bead_id}, - ${beads.columns.type}, - ${beads.columns.status}, - ${beads.columns.title}, - ${beads.columns.body}, - ${beads.columns.rig_id}, - ${beads.columns.parent_bead_id}, - ${beads.columns.assignee_agent_bead_id}, - ${beads.columns.priority}, - ${beads.columns.labels}, - ${beads.columns.metadata}, - ${beads.columns.created_by}, - ${beads.columns.created_at}, - ${beads.columns.updated_at}, - ${beads.columns.closed_at} - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - id, - input.type, - 'open', - input.title, - input.body ?? null, - input.rig_id ?? null, - input.parent_bead_id ?? null, - input.assignee_agent_bead_id ?? null, - input.priority ?? 'medium', - labels, - metadata, - input.created_by ?? null, - timestamp, - timestamp, - null, - ] - ); - - const bead = getBead(sql, id); + db.insert(beads) + .values({ + bead_id: id, + type: input.type, + status: 'open', + title: input.title, + body: input.body ?? null, + rig_id: input.rig_id ?? null, + parent_bead_id: input.parent_bead_id ?? null, + assignee_agent_bead_id: input.assignee_agent_bead_id ?? null, + priority: input.priority ?? 'medium', + labels: JSON.stringify(input.labels ?? []), + metadata: JSON.stringify(input.metadata ?? {}), + created_by: input.created_by ?? null, + created_at: timestamp, + updated_at: timestamp, + closed_at: null, + }) + .run(); + + const bead = getBead(db, id); if (!bead) throw new Error('Failed to create bead'); - logBeadEvent(sql, { + logBeadEvent(db, { beadId: id, agentId: input.assignee_agent_bead_id ?? null, eventType: 'created', @@ -115,58 +96,43 @@ export function createBead(sql: SqlStorage, input: CreateBeadInput): Bead { return bead; } -export function getBead(sql: SqlStorage, beadId: string): Bead | null { - const rows = [ - ...query(sql, /* sql */ `SELECT * FROM ${beads} WHERE ${beads.bead_id} = ?`, [beadId]), - ]; - if (rows.length === 0) return null; - return BeadRecord.parse(rows[0]); +export function getBead(db: DrizzleSqliteDODatabase, beadId: string): Bead | null { + const row = db.select().from(beads).where(eq(beads.bead_id, beadId)).get(); + if (!row) return null; + return parseBead(row); } -export function listBeads(sql: SqlStorage, filter: BeadFilter): Bead[] { +export function listBeads(db: DrizzleSqliteDODatabase, filter: BeadFilter): Bead[] { const limit = filter.limit ?? 100; const offset = filter.offset ?? 0; - const rows = [ - ...query( - sql, - /* sql */ ` - SELECT * FROM ${beads} - WHERE (? IS NULL OR ${beads.status} = ?) - AND (? IS NULL OR ${beads.type} = ?) - AND (? IS NULL OR ${beads.assignee_agent_bead_id} = ?) - AND (? IS NULL OR ${beads.parent_bead_id} = ?) - AND (? IS NULL OR ${beads.rig_id} = ?) - ORDER BY ${beads.created_at} DESC - LIMIT ? OFFSET ? - `, - [ - filter.status ?? null, - filter.status ?? null, - filter.type ?? null, - filter.type ?? null, - filter.assignee_agent_bead_id ?? null, - filter.assignee_agent_bead_id ?? null, - filter.parent_bead_id ?? null, - filter.parent_bead_id ?? null, - filter.rig_id ?? null, - filter.rig_id ?? null, - limit, - offset, - ] - ), - ]; - - return BeadRecord.array().parse(rows); + const conditions: SQL[] = []; + if (filter.status) conditions.push(eq(beads.status, filter.status)); + if (filter.type) conditions.push(eq(beads.type, filter.type)); + if (filter.assignee_agent_bead_id) + conditions.push(eq(beads.assignee_agent_bead_id, filter.assignee_agent_bead_id)); + if (filter.parent_bead_id) conditions.push(eq(beads.parent_bead_id, filter.parent_bead_id)); + if (filter.rig_id) conditions.push(eq(beads.rig_id, filter.rig_id)); + + const rows = db + .select() + .from(beads) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(beads.created_at)) + .limit(limit) + .offset(offset) + .all(); + + return rows.map(parseBead); } export function updateBeadStatus( - sql: SqlStorage, + db: DrizzleSqliteDODatabase, beadId: string, - status: string, + status: BeadStatus, agentId: string ): Bead { - const bead = getBead(sql, beadId); + const bead = getBead(db, beadId); if (!bead) throw new Error(`Bead ${beadId} not found`); // No-op if already in the target status — avoids redundant events @@ -176,19 +142,12 @@ export function updateBeadStatus( const timestamp = now(); const closedAt = status === 'closed' ? timestamp : bead.closed_at; - query( - sql, - /* sql */ ` - UPDATE ${beads} - SET ${beads.columns.status} = ?, - ${beads.columns.updated_at} = ?, - ${beads.columns.closed_at} = ? - WHERE ${beads.bead_id} = ? - `, - [status, timestamp, closedAt, beadId] - ); - - logBeadEvent(sql, { + db.update(beads) + .set({ status, updated_at: timestamp, closed_at: closedAt }) + .where(eq(beads.bead_id, beadId)) + .run(); + + logBeadEvent(db, { beadId, agentId, eventType: 'status_changed', @@ -196,74 +155,56 @@ export function updateBeadStatus( newValue: status, }); - const updated = getBead(sql, beadId); + const updated = getBead(db, beadId); if (!updated) throw new Error(`Bead ${beadId} not found after update`); return updated; } -export function closeBead(sql: SqlStorage, beadId: string, agentId: string): Bead { - return updateBeadStatus(sql, beadId, 'closed', agentId); +export function closeBead(db: DrizzleSqliteDODatabase, beadId: string, agentId: string): Bead { + return updateBeadStatus(db, beadId, 'closed', agentId); } -export function deleteBead(sql: SqlStorage, beadId: string): void { +export function deleteBead(db: DrizzleSqliteDODatabase, beadId: string): void { // Recursively delete child beads (e.g. molecule steps) before the parent - const children = BeadRecord.pick({ bead_id: true }) - .array() - .parse([ - ...query( - sql, - /* sql */ `SELECT ${beads.bead_id} FROM ${beads} WHERE ${beads.parent_bead_id} = ?`, - [beadId] - ), - ]); + const children = db + .select({ bead_id: beads.bead_id }) + .from(beads) + .where(eq(beads.parent_bead_id, beadId)) + .all(); for (const { bead_id } of children) { - deleteBead(sql, bead_id); + deleteBead(db, bead_id); } // Unhook any agent assigned to this bead - query( - sql, - /* sql */ ` - UPDATE ${agent_metadata} - SET ${agent_metadata.columns.current_hook_bead_id} = NULL, - ${agent_metadata.columns.status} = 'idle' - WHERE ${agent_metadata.current_hook_bead_id} = ? - `, - [beadId] - ); + db.update(agent_metadata) + .set({ current_hook_bead_id: null, status: 'idle' }) + .where(eq(agent_metadata.current_hook_bead_id, beadId)) + .run(); // Delete dependencies referencing this bead - query( - sql, - /* sql */ `DELETE FROM ${bead_dependencies} WHERE ${bead_dependencies.bead_id} = ? OR ${bead_dependencies.depends_on_bead_id} = ?`, - [beadId, beadId] - ); - - query(sql, /* sql */ `DELETE FROM ${bead_events} WHERE ${bead_events.bead_id} = ?`, [beadId]); - - // Delete satellite metadata if present - query(sql, /* sql */ `DELETE FROM ${agent_metadata} WHERE ${agent_metadata.bead_id} = ?`, [ - beadId, - ]); - query(sql, /* sql */ `DELETE FROM ${review_metadata} WHERE ${review_metadata.bead_id} = ?`, [ - beadId, - ]); - query( - sql, - /* sql */ `DELETE FROM ${escalation_metadata} WHERE ${escalation_metadata.bead_id} = ?`, - [beadId] - ); - query(sql, /* sql */ `DELETE FROM ${convoy_metadata} WHERE ${convoy_metadata.bead_id} = ?`, [ - beadId, - ]); - - query(sql, /* sql */ `DELETE FROM ${beads} WHERE ${beads.bead_id} = ?`, [beadId]); + db.delete(bead_dependencies) + .where( + or(eq(bead_dependencies.bead_id, beadId), eq(bead_dependencies.depends_on_bead_id, beadId)) + ) + .run(); + + // Delete events + db.delete(bead_events).where(eq(bead_events.bead_id, beadId)).run(); + + // Delete satellite metadata + db.delete(agent_metadata).where(eq(agent_metadata.bead_id, beadId)).run(); + db.delete(review_metadata).where(eq(review_metadata.bead_id, beadId)).run(); + db.delete(escalation_metadata).where(eq(escalation_metadata.bead_id, beadId)).run(); + db.delete(convoy_metadata).where(eq(convoy_metadata.bead_id, beadId)).run(); + + // Delete the bead itself + db.delete(beads).where(eq(beads.bead_id, beadId)).run(); } // ── Bead Events ───────────────────────────────────────────────────── export function logBeadEvent( - sql: SqlStorage, + db: DrizzleSqliteDODatabase, params: { beadId: string; agentId: string | null; @@ -273,35 +214,22 @@ export function logBeadEvent( metadata?: Record; } ): void { - query( - sql, - /* sql */ ` - INSERT INTO ${bead_events} ( - ${bead_events.columns.bead_event_id}, - ${bead_events.columns.bead_id}, - ${bead_events.columns.agent_id}, - ${bead_events.columns.event_type}, - ${bead_events.columns.old_value}, - ${bead_events.columns.new_value}, - ${bead_events.columns.metadata}, - ${bead_events.columns.created_at} - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - generateId(), - params.beadId, - params.agentId, - params.eventType, - params.oldValue ?? null, - params.newValue ?? null, - JSON.stringify(params.metadata ?? {}), - now(), - ] - ); + db.insert(bead_events) + .values({ + bead_event_id: generateId(), + bead_id: params.beadId, + agent_id: params.agentId, + event_type: params.eventType, + old_value: params.oldValue ?? null, + new_value: params.newValue ?? null, + metadata: JSON.stringify(params.metadata ?? {}), + created_at: now(), + }) + .run(); } export function listBeadEvents( - sql: SqlStorage, + db: DrizzleSqliteDODatabase, options: { beadId?: string; since?: string; @@ -309,24 +237,18 @@ export function listBeadEvents( } ): BeadEventRecord[] { const limit = options.limit ?? 100; - const rows = [ - ...query( - sql, - /* sql */ ` - SELECT * FROM ${bead_events} - WHERE (? IS NULL OR ${bead_events.bead_id} = ?) - AND (? IS NULL OR ${bead_events.created_at} > ?) - ORDER BY ${bead_events.created_at} DESC - LIMIT ? - `, - [ - options.beadId ?? null, - options.beadId ?? null, - options.since ?? null, - options.since ?? null, - limit, - ] - ), - ]; - return BeadEventRecord.array().parse(rows); + + const conditions: SQL[] = []; + if (options.beadId) conditions.push(eq(bead_events.bead_id, options.beadId)); + if (options.since) conditions.push(gt(bead_events.created_at, options.since)); + + const rows = db + .select() + .from(bead_events) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(bead_events.created_at)) + .limit(limit) + .all(); + + return rows.map(parseBeadEvent); } diff --git a/cloudflare-gastown/src/dos/town/mail.ts b/cloudflare-gastown/src/dos/town/mail.ts index 041fd3c5c..56651ce56 100644 --- a/cloudflare-gastown/src/dos/town/mail.ts +++ b/cloudflare-gastown/src/dos/town/mail.ts @@ -6,9 +6,9 @@ * is stored in labels and metadata. */ -import { beads, BeadRecord } from '../../db/tables/beads.table'; -import { agent_metadata } from '../../db/tables/agent-metadata.table'; -import { query } from '../../util/query.util'; +import type { DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; +import { eq, and, asc, getTableColumns } from 'drizzle-orm'; +import { beads, agent_metadata, type BeadsSelect } from '../../db/sqlite-schema'; import { logBeadEvent } from './beads'; import { getAgent } from './agents'; import type { SendMailInput, Mail } from '../../types'; @@ -21,12 +21,20 @@ function now(): string { return new Date().toISOString(); } -export function initMailTables(_sql: SqlStorage): void { +function parseBead(row: BeadsSelect) { + return { + ...row, + labels: JSON.parse(row.labels ?? '[]') as string[], + metadata: JSON.parse(row.metadata ?? '{}') as Record, + }; +} + +export function initMailTables(_db: DrizzleSqliteDODatabase): void { // Mail tables are now part of the beads table (type='message'). // Initialization happens in beads.initBeadTables(). } -export function sendMail(sql: SqlStorage, input: SendMailInput): void { +export function sendMail(db: DrizzleSqliteDODatabase, input: SendMailInput): void { const id = generateId(); const timestamp = now(); @@ -36,41 +44,30 @@ export function sendMail(sql: SqlStorage, input: SendMailInput): void { to_agent_id: input.to_agent_id, }); - query( - sql, - /* sql */ ` - INSERT INTO ${beads} ( - ${beads.columns.bead_id}, ${beads.columns.type}, ${beads.columns.status}, - ${beads.columns.title}, ${beads.columns.body}, ${beads.columns.rig_id}, - ${beads.columns.parent_bead_id}, ${beads.columns.assignee_agent_bead_id}, - ${beads.columns.priority}, ${beads.columns.labels}, ${beads.columns.metadata}, - ${beads.columns.created_by}, ${beads.columns.created_at}, ${beads.columns.updated_at}, - ${beads.columns.closed_at} - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - id, - 'message', - 'open', - input.subject, - input.body, - null, - null, - input.to_agent_id, - 'medium', + db.insert(beads) + .values({ + bead_id: id, + type: 'message', + status: 'open', + title: input.subject, + body: input.body, + rig_id: null, + parent_bead_id: null, + assignee_agent_bead_id: input.to_agent_id, + priority: 'medium', labels, metadata, - input.from_agent_id, - timestamp, - timestamp, - null, - ] - ); + created_by: input.from_agent_id, + created_at: timestamp, + updated_at: timestamp, + closed_at: null, + }) + .run(); // Log bead event if the recipient has a hooked bead - const recipient = getAgent(sql, input.to_agent_id); + const recipient = getAgent(db, input.to_agent_id); if (recipient?.current_hook_bead_id) { - logBeadEvent(sql, { + logBeadEvent(db, { beadId: recipient.current_hook_bead_id, agentId: input.from_agent_id, eventType: 'mail_sent', @@ -83,23 +80,23 @@ export function sendMail(sql: SqlStorage, input: SendMailInput): void { * Read and deliver undelivered mail for an agent. * Returns the mail items and batch-closes the message beads in a single UPDATE. */ -export function readAndDeliverMail(sql: SqlStorage, agentId: string): Mail[] { - const rows = [ - ...query( - sql, - /* sql */ ` - SELECT * FROM ${beads} - WHERE ${beads.type} = 'message' - AND ${beads.assignee_agent_bead_id} = ? - AND ${beads.status} = 'open' - ORDER BY ${beads.created_at} ASC - `, - [agentId] - ), - ]; - - const mailBeads = BeadRecord.array().parse(rows); - if (mailBeads.length === 0) return []; +export function readAndDeliverMail(db: DrizzleSqliteDODatabase, agentId: string): Mail[] { + const rows = db + .select() + .from(beads) + .where( + and( + eq(beads.type, 'message'), + eq(beads.assignee_agent_bead_id, agentId), + eq(beads.status, 'open') + ) + ) + .orderBy(asc(beads.created_at)) + .all(); + + if (rows.length === 0) return []; + + const mailBeads = rows.map(parseBead); const messages: Mail[] = mailBeads.map(mb => ({ id: mb.bead_id, @@ -114,25 +111,22 @@ export function readAndDeliverMail(sql: SqlStorage, agentId: string): Mail[] { // Batch-close all open message beads for this agent in a single UPDATE const timestamp = now(); - query( - sql, - /* sql */ ` - UPDATE ${beads} - SET ${beads.columns.status} = 'closed', - ${beads.columns.closed_at} = ?, - ${beads.columns.updated_at} = ? - WHERE ${beads.type} = 'message' - AND ${beads.assignee_agent_bead_id} = ? - AND ${beads.status} = 'open' - `, - [timestamp, timestamp, agentId] - ); + db.update(beads) + .set({ status: 'closed', closed_at: timestamp, updated_at: timestamp }) + .where( + and( + eq(beads.type, 'message'), + eq(beads.assignee_agent_bead_id, agentId), + eq(beads.status, 'open') + ) + ) + .run(); return messages; } -export function checkMail(sql: SqlStorage, agentId: string): Mail[] { - return readAndDeliverMail(sql, agentId); +export function checkMail(db: DrizzleSqliteDODatabase, agentId: string): Mail[] { + return readAndDeliverMail(db, agentId); } /** @@ -143,28 +137,21 @@ export function checkMail(sql: SqlStorage, agentId: string): Mail[] { * Calling this does NOT mark mail as delivered — the caller should call * `readAndDeliverMail` after successfully pushing the messages. */ -export function getPendingMailForWorkingAgents(sql: SqlStorage): Map { - const rows = [ - ...query( - sql, - /* sql */ ` - SELECT ${beads}.* - FROM ${beads} - INNER JOIN ${agent_metadata} - ON ${beads.assignee_agent_bead_id} = ${agent_metadata.bead_id} - WHERE ${beads.type} = 'message' - AND ${beads.status} = 'open' - AND ${agent_metadata.status} = 'working' - ORDER BY ${beads.created_at} ASC - `, - [] - ), - ]; - - const mailBeads = BeadRecord.array().parse(rows); +export function getPendingMailForWorkingAgents(db: DrizzleSqliteDODatabase): Map { + const rows = db + .select(getTableColumns(beads)) + .from(beads) + .innerJoin(agent_metadata, eq(beads.assignee_agent_bead_id, agent_metadata.bead_id)) + .where( + and(eq(beads.type, 'message'), eq(beads.status, 'open'), eq(agent_metadata.status, 'working')) + ) + .orderBy(asc(beads.created_at)) + .all(); + const grouped = new Map(); - for (const mb of mailBeads) { + for (const row of rows) { + const mb = parseBead(row); const recipientId = mb.assignee_agent_bead_id ?? ''; if (!recipientId) continue; diff --git a/cloudflare-gastown/src/dos/town/review-queue.ts b/cloudflare-gastown/src/dos/town/review-queue.ts index 7268ab94d..72c233369 100644 --- a/cloudflare-gastown/src/dos/town/review-queue.ts +++ b/cloudflare-gastown/src/dos/town/review-queue.ts @@ -7,11 +7,15 @@ */ import { z } from 'zod'; -import { beads, BeadRecord, MergeRequestBeadRecord } from '../../db/tables/beads.table'; -import { review_metadata } from '../../db/tables/review-metadata.table'; -import { bead_dependencies } from '../../db/tables/bead-dependencies.table'; -import { agent_metadata } from '../../db/tables/agent-metadata.table'; -import { query } from '../../util/query.util'; +import type { DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; +import { eq, and, asc, lt, sql, getTableColumns } from 'drizzle-orm'; +import { + beads, + review_metadata, + bead_dependencies, + agent_metadata, + type BeadsSelect, +} from '../../db/sqlite-schema'; import { logBeadEvent, getBead, closeBead, updateBeadStatus, createBead } from './beads'; import { getAgent, unhookBead } from './agents'; import type { ReviewQueueInput, ReviewQueueEntry, AgentDoneInput, Molecule } from '../../types'; @@ -27,34 +31,67 @@ function now(): string { return new Date().toISOString(); } -export function initReviewQueueTables(_sql: SqlStorage): void { - // Review queue and molecule tables are now part of beads + satellite tables. - // Initialization happens in beads.initBeadTables(). +function parseBead(row: BeadsSelect) { + return { + ...row, + labels: JSON.parse(row.labels ?? '[]') as string[], + metadata: JSON.parse(row.metadata ?? '{}') as Record, + }; } // ── Review Queue ──────────────────────────────────────────────────── -const REVIEW_JOIN = /* sql */ ` - SELECT ${beads}.*, - ${review_metadata.branch}, ${review_metadata.target_branch}, - ${review_metadata.merge_commit}, ${review_metadata.pr_url}, - ${review_metadata.retry_count} - FROM ${beads} - INNER JOIN ${review_metadata} ON ${beads.bead_id} = ${review_metadata.bead_id} -`; - -/** Map a parsed MergeRequestBeadRecord to the ReviewQueueEntry API type. */ -function toReviewQueueEntry(row: MergeRequestBeadRecord): ReviewQueueEntry { +const reviewJoinColumns = { + ...getTableColumns(beads), + branch: review_metadata.branch, + target_branch: review_metadata.target_branch, + merge_commit: review_metadata.merge_commit, + pr_url: review_metadata.pr_url, + retry_count: review_metadata.retry_count, +}; + +type ReviewJoinRow = { + bead_id: string; + type: string; + status: string; + title: string; + body: string | null; + rig_id: string | null; + parent_bead_id: string | null; + assignee_agent_bead_id: string | null; + priority: string | null; + labels: string | null; + metadata: string | null; + created_by: string | null; + created_at: string; + updated_at: string; + closed_at: string | null; + branch: string; + target_branch: string; + merge_commit: string | null; + pr_url: string | null; + retry_count: number | null; +}; + +function reviewJoinQuery(db: DrizzleSqliteDODatabase) { + return db + .select(reviewJoinColumns) + .from(beads) + .innerJoin(review_metadata, eq(beads.bead_id, review_metadata.bead_id)); +} + +/** Map a review join row to the ReviewQueueEntry API type. */ +function toReviewQueueEntry(row: ReviewJoinRow): ReviewQueueEntry { + const metadata = JSON.parse(row.metadata ?? '{}') as Record; return { id: row.bead_id, // The polecat that submitted the review — stored in metadata (not assignee, // which is set to the refinery when it claims the MR bead via hookBead). agent_id: - typeof row.metadata?.source_agent_id === 'string' - ? row.metadata.source_agent_id + typeof metadata?.source_agent_id === 'string' + ? metadata.source_agent_id : (row.created_by ?? ''), - bead_id: - typeof row.metadata?.source_bead_id === 'string' ? row.metadata.source_bead_id : row.bead_id, + bead_id: typeof metadata?.source_bead_id === 'string' ? metadata.source_bead_id : row.bead_id, rig_id: row.rig_id ?? '', branch: row.branch, pr_url: row.pr_url, @@ -72,69 +109,58 @@ function toReviewQueueEntry(row: MergeRequestBeadRecord): ReviewQueueEntry { }; } -export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): void { +export function initReviewQueueTables(_db: DrizzleSqliteDODatabase): void { + // Review queue and molecule tables are now part of beads + satellite tables. + // Initialization happens in beads.initBeadTables(). +} + +export function submitToReviewQueue(db: DrizzleSqliteDODatabase, input: ReviewQueueInput): void { const id = generateId(); const timestamp = now(); // Create the merge_request bead - query( - sql, - /* sql */ ` - INSERT INTO ${beads} ( - ${beads.columns.bead_id}, ${beads.columns.type}, ${beads.columns.status}, - ${beads.columns.title}, ${beads.columns.body}, ${beads.columns.rig_id}, - ${beads.columns.parent_bead_id}, ${beads.columns.assignee_agent_bead_id}, - ${beads.columns.priority}, ${beads.columns.labels}, ${beads.columns.metadata}, - ${beads.columns.created_by}, ${beads.columns.created_at}, ${beads.columns.updated_at}, - ${beads.columns.closed_at} - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - id, - 'merge_request', - 'open', - `Review: ${input.branch}`, - input.summary ?? null, - input.rig_id, - null, - null, // assignee left null — refinery claims it via hookBead - 'medium', - JSON.stringify(['gt:merge-request']), - JSON.stringify({ source_bead_id: input.bead_id, source_agent_id: input.agent_id }), - input.agent_id, // created_by records who submitted - timestamp, - timestamp, - null, - ] - ); + db.insert(beads) + .values({ + bead_id: id, + type: 'merge_request', + status: 'open', + title: `Review: ${input.branch}`, + body: input.summary ?? null, + rig_id: input.rig_id, + parent_bead_id: null, + assignee_agent_bead_id: null, // assignee left null — refinery claims it via hookBead + priority: 'medium', + labels: JSON.stringify(['gt:merge-request']), + metadata: JSON.stringify({ source_bead_id: input.bead_id, source_agent_id: input.agent_id }), + created_by: input.agent_id, // created_by records who submitted + created_at: timestamp, + updated_at: timestamp, + closed_at: null, + }) + .run(); // Link MR bead → source bead via bead_dependencies so the DAG is queryable - query( - sql, - /* sql */ ` - INSERT INTO ${bead_dependencies} ( - ${bead_dependencies.columns.bead_id}, - ${bead_dependencies.columns.depends_on_bead_id}, - ${bead_dependencies.columns.dependency_type} - ) VALUES (?, ?, 'tracks') - `, - [id, input.bead_id] - ); + db.insert(bead_dependencies) + .values({ + bead_id: id, + depends_on_bead_id: input.bead_id, + dependency_type: 'tracks', + }) + .run(); // Create the review_metadata satellite - query( - sql, - /* sql */ ` - INSERT INTO ${review_metadata} ( - ${review_metadata.columns.bead_id}, ${review_metadata.columns.branch}, - ${review_metadata.columns.target_branch}, ${review_metadata.columns.merge_commit}, - ${review_metadata.columns.pr_url}, ${review_metadata.columns.retry_count} - ) VALUES (?, ?, ?, ?, ?, ?) - `, - [id, input.branch, 'main', null, input.pr_url ?? null, 0] - ); - - logBeadEvent(sql, { + db.insert(review_metadata) + .values({ + bead_id: id, + branch: input.branch, + target_branch: 'main', + merge_commit: null, + pr_url: input.pr_url ?? null, + retry_count: 0, + }) + .run(); + + logBeadEvent(db, { beadId: input.bead_id, agentId: input.agent_id, eventType: 'review_submitted', @@ -143,64 +169,47 @@ export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): v }); } -export function popReviewQueue(sql: SqlStorage): ReviewQueueEntry | null { - const rows = [ - ...query( - sql, - /* sql */ ` - ${REVIEW_JOIN} - WHERE ${beads.status} = 'open' - ORDER BY ${beads.created_at} ASC - LIMIT 1 - `, - [] - ), - ]; - - if (rows.length === 0) return null; - const parsed = MergeRequestBeadRecord.parse(rows[0]); - const entry = toReviewQueueEntry(parsed); +export function popReviewQueue(db: DrizzleSqliteDODatabase): ReviewQueueEntry | null { + const row = reviewJoinQuery(db) + .where(eq(beads.status, 'open')) + .orderBy(asc(beads.created_at)) + .limit(1) + .get(); + + if (!row) return null; + const entry = toReviewQueueEntry(row as ReviewJoinRow); // Mark as running (in_progress) - query( - sql, - /* sql */ ` - UPDATE ${beads} - SET ${beads.columns.status} = 'in_progress', - ${beads.columns.updated_at} = ? - WHERE ${beads.bead_id} = ? - `, - [now(), entry.id] - ); + db.update(beads) + .set({ status: 'in_progress', updated_at: now() }) + .where(eq(beads.bead_id, entry.id)) + .run(); return { ...entry, status: 'running', processed_at: now() }; } export function completeReview( - sql: SqlStorage, + db: DrizzleSqliteDODatabase, entryId: string, status: 'merged' | 'failed' ): void { const beadStatus = status === 'merged' ? 'closed' : 'failed'; const timestamp = now(); - query( - sql, - /* sql */ ` - UPDATE ${beads} - SET ${beads.columns.status} = ?, - ${beads.columns.updated_at} = ?, - ${beads.columns.closed_at} = ? - WHERE ${beads.bead_id} = ? - `, - [beadStatus, timestamp, beadStatus === 'closed' ? timestamp : null, entryId] - ); + db.update(beads) + .set({ + status: beadStatus, + updated_at: timestamp, + closed_at: beadStatus === 'closed' ? timestamp : null, + }) + .where(eq(beads.bead_id, entryId)) + .run(); } /** * Complete a review with full result handling (close bead on merge, escalate on conflict). */ export function completeReviewWithResult( - sql: SqlStorage, + db: DrizzleSqliteDODatabase, input: { entry_id: string; status: 'merged' | 'failed' | 'conflict'; @@ -210,17 +219,14 @@ export function completeReviewWithResult( ): void { // On conflict, mark the review entry as failed and create an escalation bead const resolvedStatus = input.status === 'conflict' ? 'failed' : input.status; - completeReview(sql, input.entry_id, resolvedStatus); + completeReview(db, input.entry_id, resolvedStatus); // Find the review entry to get agent IDs - const entryRows = [ - ...query(sql, /* sql */ `${REVIEW_JOIN} WHERE ${beads.bead_id} = ?`, [input.entry_id]), - ]; - if (entryRows.length === 0) return; - const parsed = MergeRequestBeadRecord.parse(entryRows[0]); - const entry = toReviewQueueEntry(parsed); - - logBeadEvent(sql, { + const row = reviewJoinQuery(db).where(eq(beads.bead_id, input.entry_id)).get(); + if (!row) return; + const entry = toReviewQueueEntry(row as ReviewJoinRow); + + logBeadEvent(db, { beadId: entry.bead_id, agentId: entry.agent_id, eventType: 'review_completed', @@ -232,10 +238,10 @@ export function completeReviewWithResult( }); if (input.status === 'merged') { - closeBead(sql, entry.bead_id, entry.agent_id); + closeBead(db, entry.bead_id, entry.agent_id); } else if (input.status === 'conflict') { // Create an escalation bead so the conflict is visible and actionable - createBead(sql, { + createBead(db, { type: 'escalation', title: `Merge conflict: ${input.message ?? entry.branch}`, body: input.message, @@ -250,26 +256,28 @@ export function completeReviewWithResult( } } -export function recoverStuckReviews(sql: SqlStorage): void { +export function recoverStuckReviews(db: DrizzleSqliteDODatabase): void { const timeout = new Date(Date.now() - REVIEW_RUNNING_TIMEOUT_MS).toISOString(); - query( - sql, - /* sql */ ` - UPDATE ${beads} - SET ${beads.columns.status} = 'open', - ${beads.columns.updated_at} = ? - WHERE ${beads.type} = 'merge_request' - AND ${beads.status} = 'in_progress' - AND ${beads.updated_at} < ? - `, - [now(), timeout] - ); + db.update(beads) + .set({ status: 'open', updated_at: now() }) + .where( + and( + eq(beads.type, 'merge_request'), + eq(beads.status, 'in_progress'), + lt(beads.updated_at, timeout) + ) + ) + .run(); } // ── Agent Done ────────────────────────────────────────────────────── -export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInput): void { - const agent = getAgent(sql, agentId); +export function agentDone( + db: DrizzleSqliteDODatabase, + agentId: string, + input: AgentDoneInput +): void { + const agent = getAgent(db, agentId); if (!agent) throw new Error(`Agent ${agentId} not found`); if (!agent.current_hook_bead_id) throw new Error(`Agent ${agentId} has no hooked bead`); @@ -277,8 +285,8 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu // The refinery is hooked to the MR bead. Mark it as merged and log // the review_completed event on the source bead. const mrBeadId = agent.current_hook_bead_id; - completeReviewFromMRBead(sql, mrBeadId, agentId); - unhookBead(sql, agentId); + completeReviewFromMRBead(db, mrBeadId, agentId); + unhookBead(db, agentId); return; } @@ -290,7 +298,7 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu ); } - submitToReviewQueue(sql, { + submitToReviewQueue(db, { agent_id: agentId, bead_id: sourceBead, rig_id: agent.rig_id ?? '', @@ -302,8 +310,8 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu // Close the source bead (matches upstream gt done behavior). The polecat's // work is done — the MR bead now tracks the merge lifecycle. The source // bead retains its assignee so we know which agent worked on it. - unhookBead(sql, agentId); - closeBead(sql, sourceBead, agentId); + unhookBead(db, agentId); + closeBead(db, sourceBead, agentId); } /** @@ -312,8 +320,12 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu * event on the source bead. The source bead itself is already closed by * the polecat's agentDone path. */ -function completeReviewFromMRBead(sql: SqlStorage, mrBeadId: string, agentId: string): void { - const mrBead = getBead(sql, mrBeadId); +function completeReviewFromMRBead( + db: DrizzleSqliteDODatabase, + mrBeadId: string, + agentId: string +): void { + const mrBead = getBead(db, mrBeadId); if (!mrBead) { console.error( `[review-queue] completeReviewFromMRBead: MR bead ${mrBeadId} not found — data integrity issue` @@ -322,10 +334,10 @@ function completeReviewFromMRBead(sql: SqlStorage, mrBeadId: string, agentId: st } const sourceBeadId = mrBead.metadata?.source_bead_id; - completeReview(sql, mrBeadId, 'merged'); + completeReview(db, mrBeadId, 'merged'); if (typeof sourceBeadId === 'string') { - logBeadEvent(sql, { + logBeadEvent(db, { beadId: sourceBeadId, agentId, eventType: 'review_completed', @@ -340,30 +352,24 @@ function completeReviewFromMRBead(sql: SqlStorage, mrBeadId: string, agentId: st * Closes/fails the bead and unhooks the agent. */ export function agentCompleted( - sql: SqlStorage, + db: DrizzleSqliteDODatabase, agentId: string, input: { status: 'completed' | 'failed'; reason?: string } ): void { - const agent = getAgent(sql, agentId); + const agent = getAgent(db, agentId); if (!agent) return; if (agent.current_hook_bead_id) { const beadStatus = input.status === 'completed' ? 'closed' : 'failed'; - updateBeadStatus(sql, agent.current_hook_bead_id, beadStatus, agentId); - unhookBead(sql, agentId); + updateBeadStatus(db, agent.current_hook_bead_id, beadStatus, agentId); + unhookBead(db, agentId); } // Mark agent idle - query( - sql, - /* sql */ ` - UPDATE ${agent_metadata} - SET ${agent_metadata.columns.status} = 'idle', - ${agent_metadata.columns.dispatch_attempts} = 0 - WHERE ${agent_metadata.bead_id} = ? - `, - [agentId] - ); + db.update(agent_metadata) + .set({ status: 'idle', dispatch_attempts: 0 }) + .where(eq(agent_metadata.bead_id, agentId)) + .run(); } // ── Molecules ─────────────────────────────────────────────────────── @@ -372,42 +378,35 @@ export function agentCompleted( * Create a molecule: a parent bead with type='molecule', child step beads * linked via parent_bead_id, and step ordering via bead_dependencies. */ -export function createMolecule(sql: SqlStorage, beadId: string, formula: unknown): Molecule { +export function createMolecule( + db: DrizzleSqliteDODatabase, + beadId: string, + formula: unknown +): Molecule { const id = generateId(); const timestamp = now(); const formulaArr = Array.isArray(formula) ? formula : []; // Create the molecule parent bead - query( - sql, - /* sql */ ` - INSERT INTO ${beads} ( - ${beads.columns.bead_id}, ${beads.columns.type}, ${beads.columns.status}, - ${beads.columns.title}, ${beads.columns.body}, ${beads.columns.rig_id}, - ${beads.columns.parent_bead_id}, ${beads.columns.assignee_agent_bead_id}, - ${beads.columns.priority}, ${beads.columns.labels}, ${beads.columns.metadata}, - ${beads.columns.created_by}, ${beads.columns.created_at}, ${beads.columns.updated_at}, - ${beads.columns.closed_at} - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - id, - 'molecule', - 'open', - `Molecule for bead ${beadId}`, - null, - null, - null, - null, - 'medium', - JSON.stringify(['gt:molecule']), - JSON.stringify({ source_bead_id: beadId, formula }), - null, - timestamp, - timestamp, - null, - ] - ); + db.insert(beads) + .values({ + bead_id: id, + type: 'molecule', + status: 'open', + title: `Molecule for bead ${beadId}`, + body: null, + rig_id: null, + parent_bead_id: null, + assignee_agent_bead_id: null, + priority: 'medium', + labels: JSON.stringify(['gt:molecule']), + metadata: JSON.stringify({ source_bead_id: beadId, formula }), + created_by: null, + created_at: timestamp, + updated_at: timestamp, + closed_at: null, + }) + .run(); // Create child step beads and dependency chain let prevStepId: string | null = null; @@ -415,66 +414,48 @@ export function createMolecule(sql: SqlStorage, beadId: string, formula: unknown const stepId = generateId(); const step = formulaArr[i]; - query( - sql, - /* sql */ ` - INSERT INTO ${beads} ( - ${beads.columns.bead_id}, ${beads.columns.type}, ${beads.columns.status}, - ${beads.columns.title}, ${beads.columns.body}, ${beads.columns.rig_id}, - ${beads.columns.parent_bead_id}, ${beads.columns.assignee_agent_bead_id}, - ${beads.columns.priority}, ${beads.columns.labels}, ${beads.columns.metadata}, - ${beads.columns.created_by}, ${beads.columns.created_at}, ${beads.columns.updated_at}, - ${beads.columns.closed_at} - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - stepId, - 'issue', - 'open', - z.object({ title: z.string() }).safeParse(step).data?.title ?? `Step ${i + 1}`, - typeof step === 'string' ? step : JSON.stringify(step), - null, - id, - null, - 'medium', - JSON.stringify([`gt:molecule-step`, `step:${i}`]), - JSON.stringify({ step_index: i, step_data: step }), - null, - timestamp, - timestamp, - null, - ] - ); + db.insert(beads) + .values({ + bead_id: stepId, + type: 'issue', + status: 'open', + title: z.object({ title: z.string() }).safeParse(step).data?.title ?? `Step ${i + 1}`, + body: typeof step === 'string' ? step : JSON.stringify(step), + rig_id: null, + parent_bead_id: id, + assignee_agent_bead_id: null, + priority: 'medium', + labels: JSON.stringify(['gt:molecule-step', `step:${i}`]), + metadata: JSON.stringify({ step_index: i, step_data: step }), + created_by: null, + created_at: timestamp, + updated_at: timestamp, + closed_at: null, + }) + .run(); // Chain dependencies: each step blocks on the previous if (prevStepId) { - query( - sql, - /* sql */ ` - INSERT INTO ${bead_dependencies} ( - ${bead_dependencies.columns.bead_id}, - ${bead_dependencies.columns.depends_on_bead_id}, - ${bead_dependencies.columns.dependency_type} - ) VALUES (?, ?, ?) - `, - [stepId, prevStepId, 'blocks'] - ); + db.insert(bead_dependencies) + .values({ + bead_id: stepId, + depends_on_bead_id: prevStepId, + dependency_type: 'blocks', + }) + .run(); } prevStepId = stepId; } // Link molecule to source bead in metadata - query( - sql, - /* sql */ ` - UPDATE ${beads} - SET ${beads.columns.metadata} = json_set(${beads.metadata}, '$.molecule_bead_id', ?) - WHERE ${beads.bead_id} = ? - `, - [id, beadId] - ); - - const mol = getMolecule(sql, id); + db.update(beads) + .set({ + metadata: sql`json_set(${beads.metadata}, '$.molecule_bead_id', ${id})`, + }) + .where(eq(beads.bead_id, beadId)) + .run(); + + const mol = getMolecule(db, id); if (!mol) throw new Error('Failed to create molecule'); return mol; } @@ -482,11 +463,11 @@ export function createMolecule(sql: SqlStorage, beadId: string, formula: unknown /** * Get a molecule by its bead_id. Derives current_step and status from children. */ -export function getMolecule(sql: SqlStorage, moleculeId: string): Molecule | null { - const bead = getBead(sql, moleculeId); +export function getMolecule(db: DrizzleSqliteDODatabase, moleculeId: string): Molecule | null { + const bead = getBead(db, moleculeId); if (!bead || bead.type !== 'molecule') return null; - const steps = getStepBeads(sql, moleculeId); + const steps = getStepBeads(db, moleculeId); const closedCount = steps.filter(s => s.status === 'closed').length; const failedCount = steps.filter(s => s.status === 'failed').length; @@ -511,37 +492,34 @@ export function getMolecule(sql: SqlStorage, moleculeId: string): Molecule | nul }; } -function getStepBeads(sql: SqlStorage, moleculeId: string): BeadRecord[] { - const rows = [ - ...query( - sql, - /* sql */ ` - SELECT * FROM ${beads} - WHERE ${beads.parent_bead_id} = ? - ORDER BY ${beads.created_at} ASC - `, - [moleculeId] - ), - ]; - return BeadRecord.array().parse(rows); +type ParsedBead = ReturnType; + +function getStepBeads(db: DrizzleSqliteDODatabase, moleculeId: string): ParsedBead[] { + const rows = db + .select() + .from(beads) + .where(eq(beads.parent_bead_id, moleculeId)) + .orderBy(asc(beads.created_at)) + .all(); + return rows.map(parseBead); } -export function getMoleculeForBead(sql: SqlStorage, beadId: string): Molecule | null { - const bead = getBead(sql, beadId); +export function getMoleculeForBead(db: DrizzleSqliteDODatabase, beadId: string): Molecule | null { + const bead = getBead(db, beadId); if (!bead) return null; const moleculeId = bead.metadata?.molecule_bead_id; if (typeof moleculeId !== 'string') return null; - return getMolecule(sql, moleculeId); + return getMolecule(db, moleculeId); } export function getMoleculeCurrentStep( - sql: SqlStorage, + db: DrizzleSqliteDODatabase, agentId: string ): { molecule: Molecule; step: unknown } | null { - const agent = getAgent(sql, agentId); + const agent = getAgent(db, agentId); if (!agent?.current_hook_bead_id) return null; - const mol = getMoleculeForBead(sql, agent.current_hook_bead_id); + const mol = getMoleculeForBead(db, agent.current_hook_bead_id); if (!mol || mol.status !== 'active') return null; const formula = mol.formula; @@ -552,31 +530,24 @@ export function getMoleculeCurrentStep( } export function advanceMoleculeStep( - sql: SqlStorage, + db: DrizzleSqliteDODatabase, agentId: string, _summary: string ): Molecule | null { - const current = getMoleculeCurrentStep(sql, agentId); + const current = getMoleculeCurrentStep(db, agentId); if (!current) return null; const { molecule } = current; // Close the current step bead - const steps = getStepBeads(sql, molecule.id); + const steps = getStepBeads(db, molecule.id); const currentStepBead = steps[molecule.current_step]; if (currentStepBead) { const timestamp = now(); - query( - sql, - /* sql */ ` - UPDATE ${beads} - SET ${beads.columns.status} = 'closed', - ${beads.columns.closed_at} = ?, - ${beads.columns.updated_at} = ? - WHERE ${beads.bead_id} = ? - `, - [timestamp, timestamp, currentStepBead.bead_id] - ); + db.update(beads) + .set({ status: 'closed', closed_at: timestamp, updated_at: timestamp }) + .where(eq(beads.bead_id, currentStepBead.bead_id)) + .run(); } // Check if molecule is now complete @@ -587,18 +558,11 @@ export function advanceMoleculeStep( if (isComplete) { // Close the molecule bead itself const timestamp = now(); - query( - sql, - /* sql */ ` - UPDATE ${beads} - SET ${beads.columns.status} = 'closed', - ${beads.columns.closed_at} = ?, - ${beads.columns.updated_at} = ? - WHERE ${beads.bead_id} = ? - `, - [timestamp, timestamp, molecule.id] - ); + db.update(beads) + .set({ status: 'closed', closed_at: timestamp, updated_at: timestamp }) + .where(eq(beads.bead_id, molecule.id)) + .run(); } - return getMolecule(sql, molecule.id); + return getMolecule(db, molecule.id); } diff --git a/cloudflare-gastown/src/dos/town/rigs.ts b/cloudflare-gastown/src/dos/town/rigs.ts index 1193921a6..e88cfd4cc 100644 --- a/cloudflare-gastown/src/dos/town/rigs.ts +++ b/cloudflare-gastown/src/dos/town/rigs.ts @@ -3,49 +3,24 @@ * Rigs are now SQL rows in the Town DO instead of KV entries. */ -import { z } from 'zod'; -import { query } from '../../util/query.util'; +import type { DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; +import { eq, asc, sql } from 'drizzle-orm'; +import { rigs } from '../../db/sqlite-schema'; +import type { RigsSelect } from '../../db/sqlite-schema'; -const RIG_TABLE_CREATE = /* sql */ ` - CREATE TABLE IF NOT EXISTS "rigs" ( - "id" TEXT PRIMARY KEY, - "name" TEXT NOT NULL, - "git_url" TEXT NOT NULL DEFAULT '', - "default_branch" TEXT NOT NULL DEFAULT 'main', - "config" TEXT DEFAULT '{}', - "created_at" TEXT NOT NULL - ) -`; +export type RigRecord = Omit & { + config: Record; +}; -const RIG_INDEX = /* sql */ `CREATE UNIQUE INDEX IF NOT EXISTS idx_rigs_name ON rigs(name)`; - -export const RigRecord = z.object({ - id: z.string(), - name: z.string(), - git_url: z.string(), - default_branch: z.string(), - config: z - .string() - .transform(v => { - try { - return JSON.parse(v); - } catch { - return {}; - } - }) - .pipe(z.record(z.string(), z.unknown())), - created_at: z.string(), -}); - -export type RigRecord = z.output; - -export function initRigTables(sql: SqlStorage): void { - query(sql, RIG_TABLE_CREATE, []); - query(sql, RIG_INDEX, []); +function parseRig(row: RigsSelect): RigRecord { + return { + ...row, + config: JSON.parse(row.config ?? '{}') as Record, + }; } export function addRig( - sql: SqlStorage, + db: DrizzleSqliteDODatabase, input: { rigId: string; name: string; @@ -54,35 +29,41 @@ export function addRig( } ): RigRecord { const timestamp = new Date().toISOString(); - query( - sql, - /* sql */ ` - INSERT INTO rigs (id, name, git_url, default_branch, config, created_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - name = excluded.name, - git_url = excluded.git_url, - default_branch = excluded.default_branch - `, - [input.rigId, input.name, input.gitUrl, input.defaultBranch, '{}', timestamp] - ); - const rig = getRig(sql, input.rigId); + db.insert(rigs) + .values({ + id: input.rigId, + name: input.name, + git_url: input.gitUrl, + default_branch: input.defaultBranch, + config: '{}', + created_at: timestamp, + }) + .onConflictDoUpdate({ + target: rigs.id, + set: { + name: sql`excluded.name`, + git_url: sql`excluded.git_url`, + default_branch: sql`excluded.default_branch`, + }, + }) + .run(); + + const rig = getRig(db, input.rigId); if (!rig) throw new Error('Failed to create rig'); return rig; } -export function getRig(sql: SqlStorage, rigId: string): RigRecord | null { - const rows = [...query(sql, /* sql */ `SELECT * FROM rigs WHERE id = ?`, [rigId])]; - if (rows.length === 0) return null; - return RigRecord.parse(rows[0]); +export function getRig(db: DrizzleSqliteDODatabase, rigId: string): RigRecord | null { + const row = db.select().from(rigs).where(eq(rigs.id, rigId)).get(); + if (!row) return null; + return parseRig(row); } -export function listRigs(sql: SqlStorage): RigRecord[] { - const rows = [...query(sql, /* sql */ `SELECT * FROM rigs ORDER BY created_at ASC`, [])]; - return RigRecord.array().parse(rows); +export function listRigs(db: DrizzleSqliteDODatabase): RigRecord[] { + return db.select().from(rigs).orderBy(asc(rigs.created_at)).all().map(parseRig); } -export function removeRig(sql: SqlStorage, rigId: string): void { - query(sql, /* sql */ `DELETE FROM rigs WHERE id = ?`, [rigId]); +export function removeRig(db: DrizzleSqliteDODatabase, rigId: string): void { + db.delete(rigs).where(eq(rigs.id, rigId)).run(); } diff --git a/cloudflare-gastown/src/types.ts b/cloudflare-gastown/src/types.ts index f8bc0b118..9bc808ba9 100644 --- a/cloudflare-gastown/src/types.ts +++ b/cloudflare-gastown/src/types.ts @@ -1,10 +1,13 @@ import { z } from 'zod'; -import type { BeadRecord } from './db/tables/beads.table'; -import type { AgentMetadataRecord } from './db/tables/agent-metadata.table'; -import type { ReviewMetadataRecord } from './db/tables/review-metadata.table'; -import type { EscalationMetadataRecord } from './db/tables/escalation-metadata.table'; -import type { ConvoyMetadataRecord } from './db/tables/convoy-metadata.table'; -import type { BeadEventRecord } from './db/tables/bead-events.table'; +import type { + BeadsSelect, + AgentMetadataSelect, + ReviewMetadataSelect, + EscalationMetadataSelect, + ConvoyMetadataSelect, + BeadEventsSelect, + BeadDependenciesSelect, +} from './db/sqlite-schema'; // -- Beads -- @@ -25,7 +28,10 @@ export type BeadType = z.infer; export const BeadPriority = z.enum(['low', 'medium', 'high', 'critical']); export type BeadPriority = z.infer; -export type Bead = BeadRecord; +export type Bead = Omit & { + labels: string[]; + metadata: Record; +}; export type CreateBeadInput = { type: BeadType; @@ -243,9 +249,13 @@ export const AgentConfigOverridesSchema = z.object({ export type AgentConfigOverrides = z.infer; // Re-export satellite metadata types for convenience -export type { AgentMetadataRecord } from './db/tables/agent-metadata.table'; -export type { ReviewMetadataRecord } from './db/tables/review-metadata.table'; -export type { EscalationMetadataRecord } from './db/tables/escalation-metadata.table'; -export type { ConvoyMetadataRecord } from './db/tables/convoy-metadata.table'; -export type { BeadEventRecord } from './db/tables/bead-events.table'; -export type { BeadDependencyRecord } from './db/tables/bead-dependencies.table'; +export type AgentMetadataRecord = Omit & { + checkpoint: unknown; +}; +export type ReviewMetadataRecord = ReviewMetadataSelect; +export type EscalationMetadataRecord = EscalationMetadataSelect; +export type ConvoyMetadataRecord = ConvoyMetadataSelect; +export type BeadEventRecord = Omit & { + metadata: Record; +}; +export type BeadDependencyRecord = BeadDependenciesSelect; diff --git a/cloudflare-gastown/src/util/query.util.ts b/cloudflare-gastown/src/util/query.util.ts deleted file mode 100644 index 9e2140b08..000000000 --- a/cloudflare-gastown/src/util/query.util.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * CountOccurrences type counts the number of times a SubString appears in a String_. - * Uses a recursive approach with a counter represented as an array of unknown. - */ -type CountOccurrences< - String_ extends string, - SubString extends string, - Count extends unknown[] = [], -> = String_ extends `${string}${SubString}${infer Tail}` - ? CountOccurrences - : Count['length']; - -type Tuple = Acc['length'] extends N - ? Acc - : Tuple; - -export type SqliteParams = Tuple>; - -/** - * Type-safe SQLite query helper. The params tuple length is statically - * checked against the number of `?` placeholders in the query string. - */ -export function query( - sql: SqlStorage, - query: Query, - params: SqliteParams & unknown[] -) { - return sql.exec(query, ...params); -} diff --git a/cloudflare-gastown/src/util/table.ts b/cloudflare-gastown/src/util/table.ts deleted file mode 100644 index 4c09da681..000000000 --- a/cloudflare-gastown/src/util/table.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import type { z } from 'zod'; - -export type TableInput = { - name: string; - columns: readonly string[]; -}; - -export type TableQueryInterpolator = { - _name: T['name']; - columns: { - [K in T['columns'][number]]: K; - }; - valueOf: () => T['name']; - toString: () => T['name']; -} & { - [K in T['columns'][number]]: `${T['name']}.${K}`; -}; - -export function getTable(table: T): TableQueryInterpolator { - const columns: { - [K in T['columns'][number]]: K; - } = {} as any; - - const columnsWithTable: { - [K in T['columns'][number]]: `${T['name']}.${K}`; - } = {} as any; - - for (const key of table.columns) { - (columns as any)[key] = key; - (columnsWithTable as any)[key] = [table.name, key].join('.'); - } - - const result: TableQueryInterpolator = { - _name: table.name, - valueOf() { - return table.name; - }, - toString() { - return table.name; - }, - columns, - ...columnsWithTable, - }; - - return result; -} - -export function getTableFromZodSchema>( - name: Name, - schema: Schema -): TableQueryInterpolator<{ - name: Name; - columns: Array, string>>; -}> { - return getTable({ name, columns: Object.keys(schema.shape) }) as any; -} - -export type BaseTableQueryInterpolator = TableQueryInterpolator<{ - name: string; - columns: []; -}>; - -export type TableSqliteTypeMap = { - [K in keyof T['columns']]: string; -}; - -export function getCreateTableQueryFromTable( - table: T, - columnTypeMap: TableSqliteTypeMap -): string { - return ` - create table if not exists "${table.toString()}" ( - ${objectKeys(table.columns) - .map(k => `"${String(k)}" ${String(columnTypeMap[k])}`) - .join(',\n')} - ); - `.trim(); -} - -function objectKeys(obj: T): Array { - return Object.keys(obj as any) as any; -} diff --git a/cloudflare-gastown/wrangler.jsonc b/cloudflare-gastown/wrangler.jsonc index b9e534cf7..19c53e725 100644 --- a/cloudflare-gastown/wrangler.jsonc +++ b/cloudflare-gastown/wrangler.jsonc @@ -13,6 +13,7 @@ }, ], "workers_dev": false, + "rules": [{ "type": "Text", "globs": ["**/*.sql"], "fallthrough": true }], "containers": [ { diff --git a/docs/migrate-gastown-do-to-drizzle.md b/docs/migrate-gastown-do-to-drizzle.md new file mode 100644 index 000000000..e65c557e3 --- /dev/null +++ b/docs/migrate-gastown-do-to-drizzle.md @@ -0,0 +1,320 @@ +# Migrate `cloudflare-gastown` DO SQLite to Drizzle ORM + +## Prerequisites + +This migration depends on PR #684 (`chore/migrate-do-sqlite-to-drizzle`), which establishes the pattern, adds `drizzle-orm` and `drizzle-kit` to the pnpm catalog, and ships the workflow docs (`docs/do-sqlite-drizzle.md`). + +### Worktree setup + +Work in a git worktree to avoid disrupting the main checkout: + +```bash +# From the repo root +git fetch origin +git worktree add ../cloud-gastown-drizzle origin/chore/migrate-do-sqlite-to-drizzle +cd ../cloud-gastown-drizzle +git checkout -b chore/gastown-drizzle +pnpm install +``` + +All file paths below are relative to `cloudflare-gastown/`. + +--- + +## Scope + +| DO | Active tables | Query call sites | Effort | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ------ | +| `TownDO` | 8 (`beads`, `bead_events`, `bead_dependencies`, `agent_metadata`, `review_metadata`, `escalation_metadata`, `convoy_metadata`, `rigs`) | ~68 | Large | +| `GastownUserDO` | 2 (`user_towns`, `user_rigs`) | ~11 | Small | +| `AgentDO` | 1 (`rig_agent_events`) | ~6 | Small | +| **Total** | **11** | **~110** (109 `query()` + 1 raw `sql.exec()`) | | + +Complex patterns present: `INNER JOIN` (6+), `ON CONFLICT` upsert (1), subqueries (2), `COUNT` aggregates (3), `LIMIT/OFFSET` pagination. + +--- + +## Phase 1: Schema & config + +### 1.1 Add dependencies + +In `package.json`: + +```json +{ + "dependencies": { + "drizzle-orm": "catalog:" + }, + "devDependencies": { + "drizzle-kit": "catalog:" + } +} +``` + +Run `pnpm install` from the worktree root. + +### 1.2 Create `drizzle.config.ts` + +```ts +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + out: './drizzle', + schema: './src/db/sqlite-schema.ts', + dialect: 'sqlite', + driver: 'durable-sqlite', +}); +``` + +### 1.3 Create `src/db/sqlite-schema.ts` + +Define all 11 active tables in a single file using `sqliteTable` from `drizzle-orm/sqlite-core`. Each DO imports only its own tables. + +Tables to define (follow column names, types, and constraints exactly as in the current `src/db/tables/*.table.ts` and `dos/town/rigs.ts`): + +**TownDO tables (8):** + +| Table | Source file | Notes | +| --------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `beads` | `db/tables/beads.table.ts` | 15 columns, 4 indexes, CHECK on `type`/`status`/`priority` | +| `bead_events` | `db/tables/bead-events.table.ts` | 7 columns, 3 indexes, CHECK on `event_type` | +| `bead_dependencies` | `db/tables/bead-dependencies.table.ts` | 3 columns, unique composite index + 1 index, CHECK on `dependency_type`. No explicit PK in current DDL -- use the unique index on `(bead_id, depends_on_bead_id)` as a composite PK or keep as-is with the unique index | +| `agent_metadata` | `db/tables/agent-metadata.table.ts` | 9 columns, CHECK on `role`/`status` | +| `review_metadata` | `db/tables/review-metadata.table.ts` | 6 columns | +| `escalation_metadata` | `db/tables/escalation-metadata.table.ts` | 6 columns, CHECK on `severity` | +| `convoy_metadata` | `db/tables/convoy-metadata.table.ts` | 4 columns | +| `rigs` | `dos/town/rigs.ts` (inline DDL) | 6 columns, unique index on `name` | + +**GastownUserDO tables (2):** + +| Table | Source file | +| ------------ | ------------------------------- | +| `user_towns` | `db/tables/user-towns.table.ts` | +| `user_rigs` | `db/tables/user-rigs.table.ts` | + +**AgentDO tables (1):** + +| Table | Source file | Notes | +| ------------------ | ------------------------------------- | ------------------------------------------------------ | +| `rig_agent_events` | `db/tables/rig-agent-events.table.ts` | `id` is `integer PRIMARY KEY AUTOINCREMENT`, 2 indexes | + +Export `$inferInsert` and `$inferSelect` types for each table. These replace the current Zod `*Record` types. + +Use `text({ enum: [...] })` + `check()` constraints to mirror the existing `CHECK` constraints. Use `sql` from `drizzle-orm` for default expressions. + +### 1.4 Add `.sql` import rule to `wrangler.jsonc` + +Add to the top-level config (and the `env.dev` section if it overrides `rules`): + +```jsonc +"rules": [ + { + "type": "Text", + "globs": ["**/*.sql"], + "fallthrough": true + } +], +``` + +This enables the wrangler bundler to import `.sql` files used by the drizzle migration bundle. + +### 1.5 Generate migrations + +```bash +cd cloudflare-gastown +pnpm drizzle-kit generate +``` + +This creates: + +- `drizzle/0000_*.sql` -- DDL with `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS` +- `drizzle/meta/_journal.json` +- `drizzle/meta/0000_snapshot.json` +- `drizzle/migrations.js` + `drizzle/migrations.d.ts` + +**Verify** the generated SQL matches the current DDL exactly. Compare against the `getCreateTableQueryFromTable()` output in each table file. Fix any mismatches in the schema and re-generate. + +**Important:** The generated migration must use `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` (not bare `CREATE TABLE`). If drizzle-kit generates bare statements, manually add `IF NOT EXISTS` to the generated SQL, matching what was done in PR #684. + +--- + +## Phase 2: Wire up drizzle in DO constructors + +For each DO, replace the initialization pattern. + +### 2.1 TownDO (`src/dos/Town.do.ts`) + +**Before:** + +```ts +private sql: SqlStorage; +// in constructor: +this.sql = ctx.storage.sql; +void this.ctx.blockConcurrencyWhile(async () => { + await this.initializeDatabase(); +}); +// initializeDatabase calls beadOps.initBeadTables, agents.initAgentTables, etc. +``` + +**After:** + +```ts +import { drizzle, type DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; +import { migrate } from 'drizzle-orm/durable-sqlite/migrator'; +import migrations from '../../drizzle/migrations'; + +private db: DrizzleSqliteDODatabase; + +// in constructor: +this.db = drizzle(ctx.storage, { logger: false }); +void this.ctx.blockConcurrencyWhile(async () => { + migrate(this.db, migrations); +}); +``` + +Remove `initializeDatabase()` and all `init*Tables()` calls: `beadOps.initBeadTables`, `agents.initAgentTables`, `mail.initMailTables`, `reviewQueue.initReviewQueueTables`, `rigs.initRigTables`. All DDL is now handled by the drizzle migrator. + +Sub-module files (`dos/town/beads.ts`, `dos/town/agents.ts`, `dos/town/rigs.ts`, `dos/town/mail.ts`, `dos/town/review-queue.ts`) that accept `sql: SqlStorage` must be updated to accept `db: DrizzleSqliteDODatabase` instead. + +### 2.2 GastownUserDO (`src/dos/GastownUser.do.ts`) + +Same pattern: replace `this.sql = ctx.storage.sql` + `initializeDatabase()` with `drizzle()` + `migrate()`. + +### 2.3 AgentDO (`src/dos/Agent.do.ts`) + +Same pattern. Note the single raw `sql.exec()` call for `SELECT last_insert_rowid()` -- replace with `.returning()` on the insert, e.g.: + +```ts +const row = this.db.insert(rigAgentEvents).values({ ... }).returning({ id: rigAgentEvents.id }).get(); +``` + +--- + +## Phase 3: Rewrite queries + +Convert all ~110 `query()` calls and the 1 raw `sql.exec()` call to drizzle query builder. Use: + +- `.get()` for single-row results +- `.all()` for multi-row results +- `.run()` for statements where you don't need the result +- `eq()`, `and()`, `or()`, `inArray()`, `gt()`, `lt()` etc. from `drizzle-orm` for WHERE conditions +- `.innerJoin(table, condition)` for joins +- `sql` template literal from `drizzle-orm` for any patterns drizzle doesn't natively support + +### 3.1 TownDO main file (`src/dos/Town.do.ts` -- ~27 calls) + +Operations: SELECT(11), INSERT(5), UPDATE(8), COUNT(3). + +Key patterns: + +- **INNER JOIN** -- 3 join constants (`CONVOY_JOIN`, `ESCALATION_JOIN`, inline `agent_metadata` join). Replace with `.innerJoin(table, eq(a.col, b.col))`. +- **COUNT** -- `SELECT COUNT(*)` becomes `db.select({ count: count() }).from(table).where(...).get()`. +- **Conditional WHERE** -- build conditions with `and()`, `or()`, `eq()`, `inArray()`. + +### 3.2 Beads sub-module (`src/dos/town/beads.ts` -- ~25 calls) + +Remove `initBeadTables()` entirely (was ~7 CREATE TABLE + index loops). Convert remaining ~18 queries: INSERT(2), SELECT(4), UPDATE(2), DELETE(7). + +- **LIMIT/OFFSET pagination** -- `.limit(n).offset(m)` + +### 3.3 Review queue (`src/dos/town/review-queue.ts` -- ~16 calls) + +INSERT(6), UPDATE(7), SELECT(2). Uses `REVIEW_JOIN` constant for joining `review_metadata` on `beads`. Replace with `.innerJoin()`. + +### 3.4 Agents sub-module (`src/dos/town/agents.ts` -- ~15 calls) + +INSERT(2), UPDATE(7), SELECT(4). Uses `AGENT_JOIN` constant. Has subquery for agent title lookup -- use drizzle subquery or `sql` template. + +### 3.5 GastownUserDO (`src/dos/GastownUser.do.ts` -- ~11 calls) + +Remove 2 CREATE TABLE calls. Convert: INSERT(2), SELECT(4), DELETE(3). Simple CRUD with `ORDER BY DESC`. + +### 3.6 Rigs sub-module (`src/dos/town/rigs.ts` -- ~6 calls) + +Remove 1 CREATE TABLE + 1 CREATE INDEX. Convert: INSERT with `ON CONFLICT` becomes `.onConflictDoUpdate()`, SELECT(2), DELETE(1). + +### 3.7 AgentDO (`src/dos/Agent.do.ts` -- ~6 calls) + +Remove 1 CREATE TABLE + index loop. Convert: INSERT(1), SELECT(2), DELETE(1 with `NOT IN` subquery). + +The `NOT IN (SELECT id ... ORDER BY id DESC LIMIT 10000)` prune query: use `sql` template literal for the subquery, or restructure as a drizzle subquery. + +### 3.8 Mail sub-module (`src/dos/town/mail.ts` -- ~4 calls) + +INSERT(1), SELECT(2), UPDATE(1). Has INNER JOIN for mail->agent_metadata. + +--- + +## Phase 4: Cleanup + +### 4.1 Delete files + +| File/directory | Reason | +| -------------------------------------------- | --------------------------------- | +| `src/util/table.ts` | Replaced by drizzle schema | +| `src/util/query.util.ts` | Replaced by drizzle query builder | +| `src/db/tables/beads.table.ts` | Replaced by `sqlite-schema.ts` | +| `src/db/tables/bead-events.table.ts` | " | +| `src/db/tables/bead-dependencies.table.ts` | " | +| `src/db/tables/agent-metadata.table.ts` | " | +| `src/db/tables/review-metadata.table.ts` | " | +| `src/db/tables/escalation-metadata.table.ts` | " | +| `src/db/tables/convoy-metadata.table.ts` | " | +| `src/db/tables/rig-agent-events.table.ts` | " | +| `src/db/tables/user-towns.table.ts` | " | +| `src/db/tables/user-rigs.table.ts` | " | +| `src/db/tables/rig-agents.table.ts` | Legacy, unused | +| `src/db/tables/rig-beads.table.ts` | Legacy, unused | +| `src/db/tables/rig-mail.table.ts` | Legacy, unused | +| `src/db/tables/rig-molecules.table.ts` | Legacy, unused | +| `src/db/tables/rig-review-queue.table.ts` | Legacy, unused | +| `src/db/tables/town-convoys.table.ts` | Legacy, unused | +| `src/db/tables/town-convoy-beads.table.ts` | Legacy, unused | +| `src/db/tables/town-escalations.table.ts` | Legacy, unused | + +After deletion, remove the `src/db/tables/` directory entirely (and any barrel `index.ts` in it). + +### 4.2 Update AGENTS.md + +The "SQL queries" section references `query()` helper, `/* sql */` prefixes, table interpolator objects, and Zod record parsing. Replace with: + +- Use drizzle query builder (`db.select()`, `db.insert()`, `db.update()`, `db.delete()`) instead of raw SQL +- Import table objects from `src/db/sqlite-schema.ts` +- Use `$inferSelect` / `$inferInsert` types instead of Zod schemas for DB result types +- Reference `docs/do-sqlite-drizzle.md` for the migration workflow + +### 4.3 Update `docs/do-sqlite-drizzle.md` + +Add `cloudflare-gastown` to the "Workers using this pattern" table. + +### 4.4 Check `zod` dependency + +Verify whether `zod` is still imported anywhere in the worker after removing the table files and query result parsing. It is likely still needed for HTTP request body validation in handlers, but confirm before keeping it. + +--- + +## Phase 5: Verify + +1. **Typecheck**: `pnpm typecheck` from the worktree root -- must pass with no new errors in `cloudflare-gastown`. +2. **Worker typecheck**: `cd cloudflare-gastown && pnpm tsc --noEmit`. +3. **Integration tests**: `cd cloudflare-gastown && pnpm test` -- run existing test suite. +4. **Manual review of generated migration SQL**: Spot-check `drizzle/0000_*.sql` against the old DDL: + - All CHECK constraints are present + - All indexes match (names and columns) + - Column defaults match exactly + - `AUTOINCREMENT` is present on `rig_agent_events.id` + - All `IF NOT EXISTS` markers are present + +--- + +## Backward compatibility + +The generated migration SQL uses `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS`, so existing DO instances (which already have the tables but lack `__drizzle_migrations`) will not fail when drizzle runs the initial migration. + +--- + +## Reference + +- **PR #684** -- prior art for all other workers: https://github.com/Kilo-Org/cloud/pull/684 +- **Migration workflow docs** -- `docs/do-sqlite-drizzle.md` (on the PR branch) +- **Example migrated worker** -- `cloudflare-ai-attribution/` on `chore/migrate-do-sqlite-to-drizzle` branch (3 tables, ~25 queries, INNER JOIN + RETURNING patterns) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b141abecb..89c94bb3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ catalogs: aws4fetch: specifier: ^1.0.20 version: 1.0.20 + drizzle-kit: + specifier: ^0.31.9 + version: 0.31.9 drizzle-orm: specifier: ^0.45.1 version: 0.45.1 @@ -911,6 +914,9 @@ importers: '@cloudflare/containers': specifier: ^0.1.0 version: 0.1.0 + drizzle-orm: + specifier: 'catalog:' + version: 0.45.1(@cloudflare/workers-types@4.20260130.0)(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(bun-types@1.3.9)(pg@8.18.0) hono: specifier: 'catalog:' version: 4.12.2 @@ -936,6 +942,9 @@ importers: '@typescript/native-preview': specifier: 7.0.0-dev.20251019.1 version: 7.0.0-dev.20251019.1 + drizzle-kit: + specifier: 'catalog:' + version: 0.31.9 typescript: specifier: 'catalog:' version: 5.9.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4040675b3..01c8a1abf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ catalog: '@octokit/auth-app': ^8.1.2 '@types/jsonwebtoken': ^9.0.10 aws4fetch: ^1.0.20 + drizzle-kit: ^0.31.9 drizzle-orm: ^0.45.1 eslint: ^9.39.3 hono: ^4.12.1 From f9e429a5d1099b6a19a8b78e53e70f538b6448bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 1 Mar 2026 22:54:25 +0100 Subject: [PATCH 2/6] chore: drop migration doc and pnpm-workspace changes now in base --- docs/migrate-gastown-do-to-drizzle.md | 320 -------------------------- pnpm-workspace.yaml | 2 + 2 files changed, 2 insertions(+), 320 deletions(-) delete mode 100644 docs/migrate-gastown-do-to-drizzle.md diff --git a/docs/migrate-gastown-do-to-drizzle.md b/docs/migrate-gastown-do-to-drizzle.md deleted file mode 100644 index e65c557e3..000000000 --- a/docs/migrate-gastown-do-to-drizzle.md +++ /dev/null @@ -1,320 +0,0 @@ -# Migrate `cloudflare-gastown` DO SQLite to Drizzle ORM - -## Prerequisites - -This migration depends on PR #684 (`chore/migrate-do-sqlite-to-drizzle`), which establishes the pattern, adds `drizzle-orm` and `drizzle-kit` to the pnpm catalog, and ships the workflow docs (`docs/do-sqlite-drizzle.md`). - -### Worktree setup - -Work in a git worktree to avoid disrupting the main checkout: - -```bash -# From the repo root -git fetch origin -git worktree add ../cloud-gastown-drizzle origin/chore/migrate-do-sqlite-to-drizzle -cd ../cloud-gastown-drizzle -git checkout -b chore/gastown-drizzle -pnpm install -``` - -All file paths below are relative to `cloudflare-gastown/`. - ---- - -## Scope - -| DO | Active tables | Query call sites | Effort | -| --------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ------ | -| `TownDO` | 8 (`beads`, `bead_events`, `bead_dependencies`, `agent_metadata`, `review_metadata`, `escalation_metadata`, `convoy_metadata`, `rigs`) | ~68 | Large | -| `GastownUserDO` | 2 (`user_towns`, `user_rigs`) | ~11 | Small | -| `AgentDO` | 1 (`rig_agent_events`) | ~6 | Small | -| **Total** | **11** | **~110** (109 `query()` + 1 raw `sql.exec()`) | | - -Complex patterns present: `INNER JOIN` (6+), `ON CONFLICT` upsert (1), subqueries (2), `COUNT` aggregates (3), `LIMIT/OFFSET` pagination. - ---- - -## Phase 1: Schema & config - -### 1.1 Add dependencies - -In `package.json`: - -```json -{ - "dependencies": { - "drizzle-orm": "catalog:" - }, - "devDependencies": { - "drizzle-kit": "catalog:" - } -} -``` - -Run `pnpm install` from the worktree root. - -### 1.2 Create `drizzle.config.ts` - -```ts -import { defineConfig } from 'drizzle-kit'; - -export default defineConfig({ - out: './drizzle', - schema: './src/db/sqlite-schema.ts', - dialect: 'sqlite', - driver: 'durable-sqlite', -}); -``` - -### 1.3 Create `src/db/sqlite-schema.ts` - -Define all 11 active tables in a single file using `sqliteTable` from `drizzle-orm/sqlite-core`. Each DO imports only its own tables. - -Tables to define (follow column names, types, and constraints exactly as in the current `src/db/tables/*.table.ts` and `dos/town/rigs.ts`): - -**TownDO tables (8):** - -| Table | Source file | Notes | -| --------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `beads` | `db/tables/beads.table.ts` | 15 columns, 4 indexes, CHECK on `type`/`status`/`priority` | -| `bead_events` | `db/tables/bead-events.table.ts` | 7 columns, 3 indexes, CHECK on `event_type` | -| `bead_dependencies` | `db/tables/bead-dependencies.table.ts` | 3 columns, unique composite index + 1 index, CHECK on `dependency_type`. No explicit PK in current DDL -- use the unique index on `(bead_id, depends_on_bead_id)` as a composite PK or keep as-is with the unique index | -| `agent_metadata` | `db/tables/agent-metadata.table.ts` | 9 columns, CHECK on `role`/`status` | -| `review_metadata` | `db/tables/review-metadata.table.ts` | 6 columns | -| `escalation_metadata` | `db/tables/escalation-metadata.table.ts` | 6 columns, CHECK on `severity` | -| `convoy_metadata` | `db/tables/convoy-metadata.table.ts` | 4 columns | -| `rigs` | `dos/town/rigs.ts` (inline DDL) | 6 columns, unique index on `name` | - -**GastownUserDO tables (2):** - -| Table | Source file | -| ------------ | ------------------------------- | -| `user_towns` | `db/tables/user-towns.table.ts` | -| `user_rigs` | `db/tables/user-rigs.table.ts` | - -**AgentDO tables (1):** - -| Table | Source file | Notes | -| ------------------ | ------------------------------------- | ------------------------------------------------------ | -| `rig_agent_events` | `db/tables/rig-agent-events.table.ts` | `id` is `integer PRIMARY KEY AUTOINCREMENT`, 2 indexes | - -Export `$inferInsert` and `$inferSelect` types for each table. These replace the current Zod `*Record` types. - -Use `text({ enum: [...] })` + `check()` constraints to mirror the existing `CHECK` constraints. Use `sql` from `drizzle-orm` for default expressions. - -### 1.4 Add `.sql` import rule to `wrangler.jsonc` - -Add to the top-level config (and the `env.dev` section if it overrides `rules`): - -```jsonc -"rules": [ - { - "type": "Text", - "globs": ["**/*.sql"], - "fallthrough": true - } -], -``` - -This enables the wrangler bundler to import `.sql` files used by the drizzle migration bundle. - -### 1.5 Generate migrations - -```bash -cd cloudflare-gastown -pnpm drizzle-kit generate -``` - -This creates: - -- `drizzle/0000_*.sql` -- DDL with `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS` -- `drizzle/meta/_journal.json` -- `drizzle/meta/0000_snapshot.json` -- `drizzle/migrations.js` + `drizzle/migrations.d.ts` - -**Verify** the generated SQL matches the current DDL exactly. Compare against the `getCreateTableQueryFromTable()` output in each table file. Fix any mismatches in the schema and re-generate. - -**Important:** The generated migration must use `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` (not bare `CREATE TABLE`). If drizzle-kit generates bare statements, manually add `IF NOT EXISTS` to the generated SQL, matching what was done in PR #684. - ---- - -## Phase 2: Wire up drizzle in DO constructors - -For each DO, replace the initialization pattern. - -### 2.1 TownDO (`src/dos/Town.do.ts`) - -**Before:** - -```ts -private sql: SqlStorage; -// in constructor: -this.sql = ctx.storage.sql; -void this.ctx.blockConcurrencyWhile(async () => { - await this.initializeDatabase(); -}); -// initializeDatabase calls beadOps.initBeadTables, agents.initAgentTables, etc. -``` - -**After:** - -```ts -import { drizzle, type DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; -import { migrate } from 'drizzle-orm/durable-sqlite/migrator'; -import migrations from '../../drizzle/migrations'; - -private db: DrizzleSqliteDODatabase; - -// in constructor: -this.db = drizzle(ctx.storage, { logger: false }); -void this.ctx.blockConcurrencyWhile(async () => { - migrate(this.db, migrations); -}); -``` - -Remove `initializeDatabase()` and all `init*Tables()` calls: `beadOps.initBeadTables`, `agents.initAgentTables`, `mail.initMailTables`, `reviewQueue.initReviewQueueTables`, `rigs.initRigTables`. All DDL is now handled by the drizzle migrator. - -Sub-module files (`dos/town/beads.ts`, `dos/town/agents.ts`, `dos/town/rigs.ts`, `dos/town/mail.ts`, `dos/town/review-queue.ts`) that accept `sql: SqlStorage` must be updated to accept `db: DrizzleSqliteDODatabase` instead. - -### 2.2 GastownUserDO (`src/dos/GastownUser.do.ts`) - -Same pattern: replace `this.sql = ctx.storage.sql` + `initializeDatabase()` with `drizzle()` + `migrate()`. - -### 2.3 AgentDO (`src/dos/Agent.do.ts`) - -Same pattern. Note the single raw `sql.exec()` call for `SELECT last_insert_rowid()` -- replace with `.returning()` on the insert, e.g.: - -```ts -const row = this.db.insert(rigAgentEvents).values({ ... }).returning({ id: rigAgentEvents.id }).get(); -``` - ---- - -## Phase 3: Rewrite queries - -Convert all ~110 `query()` calls and the 1 raw `sql.exec()` call to drizzle query builder. Use: - -- `.get()` for single-row results -- `.all()` for multi-row results -- `.run()` for statements where you don't need the result -- `eq()`, `and()`, `or()`, `inArray()`, `gt()`, `lt()` etc. from `drizzle-orm` for WHERE conditions -- `.innerJoin(table, condition)` for joins -- `sql` template literal from `drizzle-orm` for any patterns drizzle doesn't natively support - -### 3.1 TownDO main file (`src/dos/Town.do.ts` -- ~27 calls) - -Operations: SELECT(11), INSERT(5), UPDATE(8), COUNT(3). - -Key patterns: - -- **INNER JOIN** -- 3 join constants (`CONVOY_JOIN`, `ESCALATION_JOIN`, inline `agent_metadata` join). Replace with `.innerJoin(table, eq(a.col, b.col))`. -- **COUNT** -- `SELECT COUNT(*)` becomes `db.select({ count: count() }).from(table).where(...).get()`. -- **Conditional WHERE** -- build conditions with `and()`, `or()`, `eq()`, `inArray()`. - -### 3.2 Beads sub-module (`src/dos/town/beads.ts` -- ~25 calls) - -Remove `initBeadTables()` entirely (was ~7 CREATE TABLE + index loops). Convert remaining ~18 queries: INSERT(2), SELECT(4), UPDATE(2), DELETE(7). - -- **LIMIT/OFFSET pagination** -- `.limit(n).offset(m)` - -### 3.3 Review queue (`src/dos/town/review-queue.ts` -- ~16 calls) - -INSERT(6), UPDATE(7), SELECT(2). Uses `REVIEW_JOIN` constant for joining `review_metadata` on `beads`. Replace with `.innerJoin()`. - -### 3.4 Agents sub-module (`src/dos/town/agents.ts` -- ~15 calls) - -INSERT(2), UPDATE(7), SELECT(4). Uses `AGENT_JOIN` constant. Has subquery for agent title lookup -- use drizzle subquery or `sql` template. - -### 3.5 GastownUserDO (`src/dos/GastownUser.do.ts` -- ~11 calls) - -Remove 2 CREATE TABLE calls. Convert: INSERT(2), SELECT(4), DELETE(3). Simple CRUD with `ORDER BY DESC`. - -### 3.6 Rigs sub-module (`src/dos/town/rigs.ts` -- ~6 calls) - -Remove 1 CREATE TABLE + 1 CREATE INDEX. Convert: INSERT with `ON CONFLICT` becomes `.onConflictDoUpdate()`, SELECT(2), DELETE(1). - -### 3.7 AgentDO (`src/dos/Agent.do.ts` -- ~6 calls) - -Remove 1 CREATE TABLE + index loop. Convert: INSERT(1), SELECT(2), DELETE(1 with `NOT IN` subquery). - -The `NOT IN (SELECT id ... ORDER BY id DESC LIMIT 10000)` prune query: use `sql` template literal for the subquery, or restructure as a drizzle subquery. - -### 3.8 Mail sub-module (`src/dos/town/mail.ts` -- ~4 calls) - -INSERT(1), SELECT(2), UPDATE(1). Has INNER JOIN for mail->agent_metadata. - ---- - -## Phase 4: Cleanup - -### 4.1 Delete files - -| File/directory | Reason | -| -------------------------------------------- | --------------------------------- | -| `src/util/table.ts` | Replaced by drizzle schema | -| `src/util/query.util.ts` | Replaced by drizzle query builder | -| `src/db/tables/beads.table.ts` | Replaced by `sqlite-schema.ts` | -| `src/db/tables/bead-events.table.ts` | " | -| `src/db/tables/bead-dependencies.table.ts` | " | -| `src/db/tables/agent-metadata.table.ts` | " | -| `src/db/tables/review-metadata.table.ts` | " | -| `src/db/tables/escalation-metadata.table.ts` | " | -| `src/db/tables/convoy-metadata.table.ts` | " | -| `src/db/tables/rig-agent-events.table.ts` | " | -| `src/db/tables/user-towns.table.ts` | " | -| `src/db/tables/user-rigs.table.ts` | " | -| `src/db/tables/rig-agents.table.ts` | Legacy, unused | -| `src/db/tables/rig-beads.table.ts` | Legacy, unused | -| `src/db/tables/rig-mail.table.ts` | Legacy, unused | -| `src/db/tables/rig-molecules.table.ts` | Legacy, unused | -| `src/db/tables/rig-review-queue.table.ts` | Legacy, unused | -| `src/db/tables/town-convoys.table.ts` | Legacy, unused | -| `src/db/tables/town-convoy-beads.table.ts` | Legacy, unused | -| `src/db/tables/town-escalations.table.ts` | Legacy, unused | - -After deletion, remove the `src/db/tables/` directory entirely (and any barrel `index.ts` in it). - -### 4.2 Update AGENTS.md - -The "SQL queries" section references `query()` helper, `/* sql */` prefixes, table interpolator objects, and Zod record parsing. Replace with: - -- Use drizzle query builder (`db.select()`, `db.insert()`, `db.update()`, `db.delete()`) instead of raw SQL -- Import table objects from `src/db/sqlite-schema.ts` -- Use `$inferSelect` / `$inferInsert` types instead of Zod schemas for DB result types -- Reference `docs/do-sqlite-drizzle.md` for the migration workflow - -### 4.3 Update `docs/do-sqlite-drizzle.md` - -Add `cloudflare-gastown` to the "Workers using this pattern" table. - -### 4.4 Check `zod` dependency - -Verify whether `zod` is still imported anywhere in the worker after removing the table files and query result parsing. It is likely still needed for HTTP request body validation in handlers, but confirm before keeping it. - ---- - -## Phase 5: Verify - -1. **Typecheck**: `pnpm typecheck` from the worktree root -- must pass with no new errors in `cloudflare-gastown`. -2. **Worker typecheck**: `cd cloudflare-gastown && pnpm tsc --noEmit`. -3. **Integration tests**: `cd cloudflare-gastown && pnpm test` -- run existing test suite. -4. **Manual review of generated migration SQL**: Spot-check `drizzle/0000_*.sql` against the old DDL: - - All CHECK constraints are present - - All indexes match (names and columns) - - Column defaults match exactly - - `AUTOINCREMENT` is present on `rig_agent_events.id` - - All `IF NOT EXISTS` markers are present - ---- - -## Backward compatibility - -The generated migration SQL uses `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS`, so existing DO instances (which already have the tables but lack `__drizzle_migrations`) will not fail when drizzle runs the initial migration. - ---- - -## Reference - -- **PR #684** -- prior art for all other workers: https://github.com/Kilo-Org/cloud/pull/684 -- **Migration workflow docs** -- `docs/do-sqlite-drizzle.md` (on the PR branch) -- **Example migrated worker** -- `cloudflare-ai-attribution/` on `chore/migrate-do-sqlite-to-drizzle` branch (3 tables, ~25 queries, INNER JOIN + RETURNING patterns) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 01c8a1abf..3ce6125b5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,6 +18,8 @@ catalog: packages: - 'packages/db' + - 'packages/worker-utils' + - 'packages/encryption' - 'storybook' - 'cloudflare-deploy-infra/builder' - 'cloudflare-deploy-infra/dispatcher' From c5591ff77a740074465d8eff6c31fd6c853d2b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 1 Mar 2026 23:44:46 +0100 Subject: [PATCH 3/6] fix(gastown): address drizzle migration review comments - Derive AgentJoinRow, ReviewJoinRow, EscalationJoinRow, ConvoyJoinRow from query builder ReturnType instead of hand-writing them; eliminates all 'as' casts (AgentJoinRow x4, ReviewJoinRow x2, Agent['role'], Agent['status'], EscalationEntry['severity'], status as 'idle') - Narrow updateAgentStatus param to AgentMetadataSelect['status']; add AgentStatus.parse() at the TownDO public boundary (matching the BeadStatus.parse() pattern used by updateBeadStatus) - Export parseBead from beads.ts and import it in agents.ts, mail.ts, review-queue.ts, Town.do.ts instead of duplicating the function - Delete dead init stubs: initAgentTables, initMailTables, initReviewQueueTables (never called, stale comments) - Add comment on self-ref FK any in sqlite-schema.ts (drizzle limitation) - Clarify docs/do-sqlite-drizzle.md reference as repo-root path in AGENTS.md - Add comment on db.run(sql`...`) prune in Agent.do.ts explaining why the sql template tag is used (NOT IN subquery) --- cloudflare-gastown/AGENTS.md | 2 +- cloudflare-gastown/src/db/sqlite-schema.ts | 2 +- cloudflare-gastown/src/dos/Agent.do.ts | 4 +- cloudflare-gastown/src/dos/Town.do.ts | 74 +++++-------------- cloudflare-gastown/src/dos/town/agents.ts | 69 ++++------------- cloudflare-gastown/src/dos/town/beads.ts | 2 +- cloudflare-gastown/src/dos/town/mail.ts | 5 -- .../src/dos/town/review-queue.ts | 53 ++----------- 8 files changed, 48 insertions(+), 163 deletions(-) diff --git a/cloudflare-gastown/AGENTS.md b/cloudflare-gastown/AGENTS.md index 8b66d74c3..e436e187e 100644 --- a/cloudflare-gastown/AGENTS.md +++ b/cloudflare-gastown/AGENTS.md @@ -28,7 +28,7 @@ - Use `eq()`, `and()`, `or()`, `inArray()`, `gt()`, `lt()`, `isNull()`, `isNotNull()` from `drizzle-orm` for WHERE conditions. - Use `.innerJoin(table, condition)` for joins. - For conditional filters, build a `conditions: SQL[]` array and pass to `and(...conditions)`. -- Reference `docs/do-sqlite-drizzle.md` for the drizzle migration workflow (schema changes, generating migrations). +- Reference `docs/do-sqlite-drizzle.md` (repo root) for the drizzle migration workflow (schema changes, generating migrations). ## HTTP routes diff --git a/cloudflare-gastown/src/db/sqlite-schema.ts b/cloudflare-gastown/src/db/sqlite-schema.ts index 6644531bc..a5a8a2602 100644 --- a/cloudflare-gastown/src/db/sqlite-schema.ts +++ b/cloudflare-gastown/src/db/sqlite-schema.ts @@ -21,7 +21,7 @@ export const beads = sqliteTable( title: text('title').notNull(), body: text('body'), rig_id: text('rig_id'), - parent_bead_id: text('parent_bead_id').references((): any => beads.bead_id), + parent_bead_id: text('parent_bead_id').references((): any => beads.bead_id), // self-ref requires any — drizzle limitation assignee_agent_bead_id: text('assignee_agent_bead_id'), priority: text('priority', { enum: ['low', 'medium', 'high', 'critical'], diff --git a/cloudflare-gastown/src/dos/Agent.do.ts b/cloudflare-gastown/src/dos/Agent.do.ts index 5af411ca4..15e3a2e33 100644 --- a/cloudflare-gastown/src/dos/Agent.do.ts +++ b/cloudflare-gastown/src/dos/Agent.do.ts @@ -53,7 +53,9 @@ export class AgentDO extends DurableObject { const insertedId = row?.id ?? 0; - // Prune old events if count exceeds 10000 + // Prune old events if count exceeds 10000. + // NOT IN subquery can't be expressed via drizzle's query builder; + // sql template tag still provides column-safe escaping. this.db.run(sql` DELETE FROM ${rig_agent_events} WHERE ${rig_agent_events.id} NOT IN ( diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 970505816..d407fe6eb 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -48,7 +48,6 @@ import { escalation_metadata, convoy_metadata, bead_dependencies, - type BeadsSelect, } from '../db/sqlite-schema'; import { getAgentDOStub } from './Agent.do'; import { getTownContainerStub } from './TownContainer.do'; @@ -73,6 +72,7 @@ import type { Molecule, BeadEventRecord, } from '../types'; +import { AgentStatus } from '../types'; const TOWN_LOG = '[Town.do]'; @@ -125,61 +125,24 @@ const convoyJoinColumns = { landed_at: convoy_metadata.landed_at, }; -// ── Parse helpers for joined rows ─────────────────────────────────── - -function parseBead(row: BeadsSelect): Bead { - return { - ...row, - labels: JSON.parse(row.labels ?? '[]'), - metadata: JSON.parse(row.metadata ?? '{}'), - }; +// Helper query functions defined before their row types so types can be derived. +function escalationJoinQuery(db: DrizzleSqliteDODatabase) { + return db + .select(escalationJoinColumns) + .from(beads) + .innerJoin(escalation_metadata, eq(beads.bead_id, escalation_metadata.bead_id)); } -// Escalation join row — the shape returned by selecting escalationJoinColumns -type EscalationJoinRow = { - bead_id: string; - type: string; - status: string; - title: string; - body: string | null; - rig_id: string | null; - parent_bead_id: string | null; - assignee_agent_bead_id: string | null; - priority: string | null; - labels: string | null; - metadata: string | null; - created_by: string | null; - created_at: string; - updated_at: string; - closed_at: string | null; - severity: string; - category: string | null; - acknowledged: number; - re_escalation_count: number; - acknowledged_at: string | null; -}; +function convoyJoinQuery(db: DrizzleSqliteDODatabase) { + return db + .select(convoyJoinColumns) + .from(beads) + .innerJoin(convoy_metadata, eq(beads.bead_id, convoy_metadata.bead_id)); +} -// Convoy join row — the shape returned by selecting convoyJoinColumns -type ConvoyJoinRow = { - bead_id: string; - type: string; - status: string; - title: string; - body: string | null; - rig_id: string | null; - parent_bead_id: string | null; - assignee_agent_bead_id: string | null; - priority: string | null; - labels: string | null; - metadata: string | null; - created_by: string | null; - created_at: string; - updated_at: string; - closed_at: string | null; - total_beads: number; - closed_beads: number; - landed_at: string | null; -}; +// Derive row types from the query builders — keeps enums narrow without manual maintenance. +type EscalationJoinRow = NonNullable['get']>>; +type ConvoyJoinRow = NonNullable['get']>>; // ── Escalation API type ───────────────────────────────────────────── type EscalationEntry = { @@ -200,7 +163,7 @@ function toEscalation(row: EscalationJoinRow): EscalationEntry { id: row.bead_id, source_rig_id: row.rig_id ?? '', source_agent_id: row.created_by, - severity: row.severity as EscalationEntry['severity'], + severity: row.severity, category: row.category, message: row.body ?? row.title, acknowledged: row.acknowledged, @@ -433,7 +396,8 @@ export class TownDO extends DurableObject { } async updateAgentStatus(agentId: string, status: string): Promise { - agents.updateAgentStatus(this.db, agentId, status); + const validStatus = AgentStatus.parse(status); + agents.updateAgentStatus(this.db, agentId, validStatus); } async deleteAgent(agentId: string): Promise { diff --git a/cloudflare-gastown/src/dos/town/agents.ts b/cloudflare-gastown/src/dos/town/agents.ts index 1b3591937..472d3af7b 100644 --- a/cloudflare-gastown/src/dos/town/agents.ts +++ b/cloudflare-gastown/src/dos/town/agents.ts @@ -8,8 +8,8 @@ import type { DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; import type { SQL } from 'drizzle-orm'; import { eq, and, or, asc, desc, isNull, inArray, ne, getTableColumns } from 'drizzle-orm'; -import { beads, agent_metadata, type BeadsSelect } from '../../db/sqlite-schema'; -import { logBeadEvent, getBead, deleteBead } from './beads'; +import { beads, agent_metadata, type AgentMetadataSelect } from '../../db/sqlite-schema'; +import { logBeadEvent, getBead, deleteBead, parseBead } from './beads'; import { readAndDeliverMail } from './mail'; import type { RegisterAgentInput, @@ -66,40 +66,26 @@ const agentJoinColumns = { checkpoint: agent_metadata.checkpoint, }; -type AgentJoinRow = { - bead_id: string; - type: string; - title: string; - body: string | null; - rig_id: string | null; - parent_bead_id: string | null; - assignee_agent_bead_id: string | null; - priority: string | null; - labels: string | null; - metadata: string | null; - created_by: string | null; - created_at: string; - updated_at: string; - closed_at: string | null; - role: string; - identity: string; - container_process_id: string | null; - status: string; - current_hook_bead_id: string | null; - dispatch_attempts: number; - last_activity_at: string | null; - checkpoint: string | null; -}; +function agentJoinQuery(db: DrizzleSqliteDODatabase) { + return db + .select(agentJoinColumns) + .from(beads) + .innerJoin(agent_metadata, eq(beads.bead_id, agent_metadata.bead_id)); +} + +// Derive the row type from the query builder — keeps enums narrow (role, status) +// without manual maintenance or 'as' casts. +type AgentJoinRow = NonNullable['get']>>; /** Map an agent join row to the Agent API type. */ function toAgent(row: AgentJoinRow): Agent { return { id: row.bead_id, rig_id: row.rig_id, - role: row.role as Agent['role'], + role: row.role, name: row.title, identity: row.identity, - status: row.status as Agent['status'], + status: row.status, current_hook_bead_id: row.current_hook_bead_id, dispatch_attempts: row.dispatch_attempts, last_activity_at: row.last_activity_at, @@ -108,18 +94,6 @@ function toAgent(row: AgentJoinRow): Agent { }; } -function agentJoinQuery(db: DrizzleSqliteDODatabase) { - return db - .select(agentJoinColumns) - .from(beads) - .innerJoin(agent_metadata, eq(beads.bead_id, agent_metadata.bead_id)); -} - -export function initAgentTables(_db: DrizzleSqliteDODatabase): void { - // Agent tables are now initialized in beads.initBeadTables() - // (beads table + agent_metadata satellite) -} - export function registerAgent(db: DrizzleSqliteDODatabase, input: RegisterAgentInput): Agent { const id = generateId(); const timestamp = now(); @@ -193,12 +167,9 @@ export function listAgents(db: DrizzleSqliteDODatabase, filter?: AgentFilter): A export function updateAgentStatus( db: DrizzleSqliteDODatabase, agentId: string, - status: string + status: AgentMetadataSelect['status'] ): void { - db.update(agent_metadata) - .set({ status: status as 'idle' }) - .where(eq(agent_metadata.bead_id, agentId)) - .run(); + db.update(agent_metadata).set({ status }).where(eq(agent_metadata.bead_id, agentId)).run(); } export function deleteAgent(db: DrizzleSqliteDODatabase, agentId: string): void { @@ -386,14 +357,6 @@ export function prime(db: DrizzleSqliteDODatabase, agentId: string): PrimeContex }; } -function parseBead(row: BeadsSelect): Bead { - return { - ...row, - labels: JSON.parse(row.labels ?? '[]') as string[], - metadata: JSON.parse(row.metadata ?? '{}') as Record, - }; -} - // ── Checkpoint ────────────────────────────────────────────────────── export function writeCheckpoint(db: DrizzleSqliteDODatabase, agentId: string, data: unknown): void { diff --git a/cloudflare-gastown/src/dos/town/beads.ts b/cloudflare-gastown/src/dos/town/beads.ts index 997abe1c7..f8729e023 100644 --- a/cloudflare-gastown/src/dos/town/beads.ts +++ b/cloudflare-gastown/src/dos/town/beads.ts @@ -41,7 +41,7 @@ function now(): string { return new Date().toISOString(); } -function parseBead(row: BeadsSelect): Bead { +export function parseBead(row: BeadsSelect): Bead { return { ...row, labels: JSON.parse(row.labels ?? '[]') as string[], diff --git a/cloudflare-gastown/src/dos/town/mail.ts b/cloudflare-gastown/src/dos/town/mail.ts index 56651ce56..e5bee42b5 100644 --- a/cloudflare-gastown/src/dos/town/mail.ts +++ b/cloudflare-gastown/src/dos/town/mail.ts @@ -29,11 +29,6 @@ function parseBead(row: BeadsSelect) { }; } -export function initMailTables(_db: DrizzleSqliteDODatabase): void { - // Mail tables are now part of the beads table (type='message'). - // Initialization happens in beads.initBeadTables(). -} - export function sendMail(db: DrizzleSqliteDODatabase, input: SendMailInput): void { const id = generateId(); const timestamp = now(); diff --git a/cloudflare-gastown/src/dos/town/review-queue.ts b/cloudflare-gastown/src/dos/town/review-queue.ts index 72c233369..0fbe6a789 100644 --- a/cloudflare-gastown/src/dos/town/review-queue.ts +++ b/cloudflare-gastown/src/dos/town/review-queue.ts @@ -9,14 +9,8 @@ import { z } from 'zod'; import type { DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; import { eq, and, asc, lt, sql, getTableColumns } from 'drizzle-orm'; -import { - beads, - review_metadata, - bead_dependencies, - agent_metadata, - type BeadsSelect, -} from '../../db/sqlite-schema'; -import { logBeadEvent, getBead, closeBead, updateBeadStatus, createBead } from './beads'; +import { beads, review_metadata, bead_dependencies, agent_metadata } from '../../db/sqlite-schema'; +import { logBeadEvent, getBead, closeBead, updateBeadStatus, createBead, parseBead } from './beads'; import { getAgent, unhookBead } from './agents'; import type { ReviewQueueInput, ReviewQueueEntry, AgentDoneInput, Molecule } from '../../types'; @@ -31,14 +25,6 @@ function now(): string { return new Date().toISOString(); } -function parseBead(row: BeadsSelect) { - return { - ...row, - labels: JSON.parse(row.labels ?? '[]') as string[], - metadata: JSON.parse(row.metadata ?? '{}') as Record, - }; -} - // ── Review Queue ──────────────────────────────────────────────────── const reviewJoinColumns = { @@ -50,29 +36,6 @@ const reviewJoinColumns = { retry_count: review_metadata.retry_count, }; -type ReviewJoinRow = { - bead_id: string; - type: string; - status: string; - title: string; - body: string | null; - rig_id: string | null; - parent_bead_id: string | null; - assignee_agent_bead_id: string | null; - priority: string | null; - labels: string | null; - metadata: string | null; - created_by: string | null; - created_at: string; - updated_at: string; - closed_at: string | null; - branch: string; - target_branch: string; - merge_commit: string | null; - pr_url: string | null; - retry_count: number | null; -}; - function reviewJoinQuery(db: DrizzleSqliteDODatabase) { return db .select(reviewJoinColumns) @@ -80,6 +43,9 @@ function reviewJoinQuery(db: DrizzleSqliteDODatabase) { .innerJoin(review_metadata, eq(beads.bead_id, review_metadata.bead_id)); } +// Derive the row type from the query builder — stays in sync with schema automatically. +type ReviewJoinRow = NonNullable['get']>>; + /** Map a review join row to the ReviewQueueEntry API type. */ function toReviewQueueEntry(row: ReviewJoinRow): ReviewQueueEntry { const metadata = JSON.parse(row.metadata ?? '{}') as Record; @@ -109,11 +75,6 @@ function toReviewQueueEntry(row: ReviewJoinRow): ReviewQueueEntry { }; } -export function initReviewQueueTables(_db: DrizzleSqliteDODatabase): void { - // Review queue and molecule tables are now part of beads + satellite tables. - // Initialization happens in beads.initBeadTables(). -} - export function submitToReviewQueue(db: DrizzleSqliteDODatabase, input: ReviewQueueInput): void { const id = generateId(); const timestamp = now(); @@ -177,7 +138,7 @@ export function popReviewQueue(db: DrizzleSqliteDODatabase): ReviewQueueEntry | .get(); if (!row) return null; - const entry = toReviewQueueEntry(row as ReviewJoinRow); + const entry = toReviewQueueEntry(row); // Mark as running (in_progress) db.update(beads) @@ -224,7 +185,7 @@ export function completeReviewWithResult( // Find the review entry to get agent IDs const row = reviewJoinQuery(db).where(eq(beads.bead_id, input.entry_id)).get(); if (!row) return; - const entry = toReviewQueueEntry(row as ReviewJoinRow); + const entry = toReviewQueueEntry(row); logBeadEvent(db, { beadId: entry.bead_id, From 2b1fbc6fe93863cc5ac6f8637b1a5ef08862341c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 2 Mar 2026 00:37:16 +0100 Subject: [PATCH 4/6] fix(gastown): use any instead of unknown for opaque JSON fields in RPC types Cloudflare's Rpc.Serializable resolves DO RPC return types to never when they contain `unknown`. This was fixed on main (1da0cb38f) for the old table-based types, but the drizzle migration reintroduced `unknown` in the new Omit-based type overrides. Changed to `any` with eslint-disable comments: - Bead.metadata, CreateBeadInput.metadata, BeadEventRecord.metadata - Molecule.formula - AgentMetadataRecord.checkpoint --- cloudflare-gastown/src/types.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/cloudflare-gastown/src/types.ts b/cloudflare-gastown/src/types.ts index 6c2be57c1..8516ca883 100644 --- a/cloudflare-gastown/src/types.ts +++ b/cloudflare-gastown/src/types.ts @@ -30,7 +30,8 @@ export type BeadPriority = z.infer; export type Bead = Omit & { labels: string[]; - metadata: Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `unknown` breaks Rpc.Serializable + metadata: Record; }; export type CreateBeadInput = { @@ -39,7 +40,8 @@ export type CreateBeadInput = { body?: string; priority?: BeadPriority; labels?: string[]; - metadata?: Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `unknown` breaks Rpc.Serializable + metadata?: Record; assignee_agent_bead_id?: string; parent_bead_id?: string; rig_id?: string; @@ -154,7 +156,8 @@ export type MoleculeStatus = z.infer; export type Molecule = { id: string; bead_id: string; - formula: unknown; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `unknown` breaks Rpc.Serializable + formula: any; current_step: number; status: MoleculeStatus; created_at: string; @@ -253,12 +256,14 @@ export type AgentConfigOverrides = z.infer; // Re-export satellite metadata types for convenience export type AgentMetadataRecord = Omit & { - checkpoint: unknown; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `unknown` breaks Rpc.Serializable + checkpoint: any; }; export type ReviewMetadataRecord = ReviewMetadataSelect; export type EscalationMetadataRecord = EscalationMetadataSelect; export type ConvoyMetadataRecord = ConvoyMetadataSelect; export type BeadEventRecord = Omit & { - metadata: Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `unknown` breaks Rpc.Serializable + metadata: Record; }; export type BeadDependencyRecord = BeadDependenciesSelect; From 1048b97dd284949df58b7fc616bd0656ad668eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 2 Mar 2026 00:50:22 +0100 Subject: [PATCH 5/6] fix(gastown): use validated status instead of raw param in updateBeadStatus --- cloudflare-gastown/src/dos/Town.do.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index d407fe6eb..2c392f7fe 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -340,7 +340,7 @@ export class TownDO extends DurableObject { const bead = beadOps.updateBeadStatus(this.db, beadId, validStatus, agentId); // If closed and part of a convoy (via bead_dependencies), notify - if (status === 'closed') { + if (validStatus === 'closed') { const convoyRows = this.db .select({ depends_on_bead_id: bead_dependencies.depends_on_bead_id }) .from(bead_dependencies) From 03e4457904210c16786ff9a4bf36b34f97161ddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 2 Mar 2026 01:25:55 +0100 Subject: [PATCH 6/6] fix(gastown): parse checkpoint JSON in schedulePendingWork agent mapping toAgent() in agents.ts correctly does JSON.parse(row.checkpoint), but the inline agent construction in schedulePendingWork() was passing the raw SQLite text string through. Matches the toAgent() pattern. --- cloudflare-gastown/src/dos/Town.do.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 2c392f7fe..a13c7dab5 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -1216,7 +1216,7 @@ export class TownDO extends DurableObject { current_hook_bead_id: row.current_hook_bead_id, dispatch_attempts: row.dispatch_attempts, last_activity_at: row.last_activity_at, - checkpoint: row.checkpoint, + checkpoint: row.checkpoint ? JSON.parse(row.checkpoint) : null, created_at: row.created_at, }));