diff --git a/cloudflare-gastown/AGENTS.md b/cloudflare-gastown/AGENTS.md index 563a9a66f..e436e187e 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` (repo root) 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 91255be3b..ed0b2aac2 100644 --- a/cloudflare-gastown/package.json +++ b/cloudflare-gastown/package.json @@ -20,6 +20,7 @@ "dependencies": { "@cloudflare/containers": "^0.1.0", "@kilocode/worker-utils": "workspace:*", + "drizzle-orm": "catalog:", "hono": "catalog:", "itty-time": "^1.0.6", "jsonwebtoken": "catalog:", @@ -30,6 +31,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..a5a8a2602 --- /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), // self-ref requires any — drizzle limitation + 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 066af15ee..000000000 --- a/cloudflare-gastown/src/db/tables/agent-metadata.table.ts +++ /dev/null @@ -1,48 +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; - } - }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see Agent.checkpoint in types.ts - .pipe(z.any()), - 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 c67a2da46..000000000 --- a/cloudflare-gastown/src/db/tables/beads.table.ts +++ /dev/null @@ -1,141 +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 {}; - } - }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see Agent.checkpoint in types.ts - .pipe(z.record(z.string(), z.any())), - 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..15e3a2e33 100644 --- a/cloudflare-gastown/src/dos/Agent.do.ts +++ b/cloudflare-gastown/src/dos/Agent.do.ts @@ -8,81 +8,62 @@ */ 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); - - // 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 - ) - `, - [] - ); + 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. + // 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 ( + SELECT ${rig_agent_events.id} FROM ${rig_agent_events} + ORDER BY ${rig_agent_events.id} DESC + LIMIT 10000 + ) + `); return insertedId; } @@ -90,21 +71,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..a13c7dab5 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,18 @@ 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, +} 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, @@ -61,6 +72,7 @@ import type { Molecule, BeadEventRecord, } from '../types'; +import { AgentStatus } from '../types'; const TOWN_LOG = '[Town.do]'; @@ -95,7 +107,44 @@ 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, +}; + +// 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)); +} + +function convoyJoinQuery(db: DrizzleSqliteDODatabase) { + return db + .select(convoyJoinColumns) + .from(beads) + .innerJoin(convoy_metadata, eq(beads.bead_id, convoy_metadata.bead_id)); +} + +// 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 = { id: string; source_rig_id: string; @@ -109,7 +158,7 @@ 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 ?? '', @@ -124,7 +173,7 @@ function toEscalation(row: EscalationBeadRecord): EscalationEntry { }; } -// ── Convoy API type (derived from ConvoyBeadRecord) ───────────────── +// ── Convoy API type ───────────────────────────────────────────────── type ConvoyEntry = { id: string; title: string; @@ -136,7 +185,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 +198,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 +250,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 +324,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) { + if (validStatus === 'closed') { + 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 +364,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 +372,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 +380,28 @@ 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); + const validStatus = AgentStatus.parse(status); + agents.updateAgentStatus(this.db, agentId, validStatus); } 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 +411,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 +442,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 +465,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 +477,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 +495,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 +507,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 +542,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 +551,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 +575,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 +636,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 +653,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 +717,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 +734,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 +762,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 +771,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 +792,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 +804,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 +854,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 +909,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 +946,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 +999,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 +1022,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 +1037,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 +1082,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 +1107,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 +1120,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 +1149,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 +1174,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 ? JSON.parse(row.checkpoint) : null, + created_at: row.created_at, + })); console.log(`${TOWN_LOG} schedulePendingWork: found ${pendingAgents.length} pending agents`); if (pendingAgents.length === 0) return; @@ -1308,12 +1228,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 +1254,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 +1273,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 +1320,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 +1335,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 +1353,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 +1363,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 +1376,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 +1392,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 +1415,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 +1441,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 +1449,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 +1472,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 +1487,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 +1510,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 +1552,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..472d3af7b 100644 --- a/cloudflare-gastown/src/dos/town/agents.ts +++ b/cloudflare-gastown/src/dos/town/agents.ts @@ -5,10 +5,11 @@ * 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 { logBeadEvent, getBead, deleteBead } from './beads'; +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 AgentMetadataSelect } from '../../db/sqlite-schema'; +import { logBeadEvent, getBead, deleteBead, parseBead } from './beads'; import { readAndDeliverMail } from './mail'; import type { RegisterAgentInput, @@ -51,8 +52,33 @@ 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, +}; + +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, @@ -63,165 +89,107 @@ function toAgent(row: AgentBeadRecord): Agent { 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 { - // 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: AgentMetadataSelect['status'] +): void { + db.update(agent_metadata).set({ status }).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 +202,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 +229,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 +248,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 +261,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 +285,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 +294,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, @@ -414,34 +359,24 @@ export function prime(sql: SqlStorage, agentId: string): PrimeContext { // ── 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..f8729e023 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(), []); +export 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..e5bee42b5 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,15 @@ function now(): string { return new Date().toISOString(); } -export function initMailTables(_sql: SqlStorage): void { - // Mail tables are now part of the beads table (type='message'). - // 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, + }; } -export function sendMail(sql: SqlStorage, input: SendMailInput): void { +export function sendMail(db: DrizzleSqliteDODatabase, input: SendMailInput): void { const id = generateId(); const timestamp = now(); @@ -36,41 +39,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 +75,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 +106,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 +132,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..0fbe6a789 100644 --- a/cloudflare-gastown/src/dos/town/review-queue.ts +++ b/cloudflare-gastown/src/dos/town/review-queue.ts @@ -7,12 +7,10 @@ */ 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 { logBeadEvent, getBead, closeBead, updateBeadStatus, createBead } from './beads'; +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 } 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'; @@ -27,34 +25,39 @@ 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(). +// ── Review Queue ──────────────────────────────────────────────────── + +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, +}; + +function reviewJoinQuery(db: DrizzleSqliteDODatabase) { + return db + .select(reviewJoinColumns) + .from(beads) + .innerJoin(review_metadata, eq(beads.bead_id, review_metadata.bead_id)); } -// ── Review Queue ──────────────────────────────────────────────────── +// Derive the row type from the query builder — stays in sync with schema automatically. +type ReviewJoinRow = NonNullable['get']>>; -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 { +/** 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 +75,53 @@ function toReviewQueueEntry(row: MergeRequestBeadRecord): ReviewQueueEntry { }; } -export function submitToReviewQueue(sql: SqlStorage, input: ReviewQueueInput): void { +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 +130,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); // 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 +180,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); + + logBeadEvent(db, { beadId: entry.bead_id, agentId: entry.agent_id, eventType: 'review_completed', @@ -232,10 +199,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 +217,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 +246,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 +259,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 +271,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 +281,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 +295,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 +313,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 +339,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 +375,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 +424,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 +453,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 +491,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 +519,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 713a33455..8516ca883 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,11 @@ 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[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `unknown` breaks Rpc.Serializable + metadata: Record; +}; export type CreateBeadInput = { type: BeadType; @@ -33,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; @@ -148,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; @@ -246,9 +255,15 @@ 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 & { + // 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 & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `unknown` breaks Rpc.Serializable + 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/pnpm-lock.yaml b/pnpm-lock.yaml index 01e05098d..aa9369379 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -971,6 +971,9 @@ importers: '@kilocode/worker-utils': specifier: workspace:* version: link:../packages/worker-utils + 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 @@ -996,6 +999,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