diff --git a/docs/fleet/backlog-conventions.md b/docs/fleet/backlog-conventions.md new file mode 100644 index 0000000..0251306 --- /dev/null +++ b/docs/fleet/backlog-conventions.md @@ -0,0 +1,138 @@ +# Fleet Backlog Conventions + +The **backlog** is Mosaic's native backlog-of-record for fleet work. It is built +end-to-end on Mosaic's own storage layer (`@mosaicstack/db`, drizzle/Postgres) +and surfaced as `mosaic fleet backlog --json`. + +> **Mosaic-native, no Hermes.** This backlog REPLACES the former Hermes adapter. +> There is **no** runtime dependency on Hermes, `hermes kanban`, or `~/.hermes` +> anywhere in this feature. Anything previously delegated to Hermes is recreated +> here on Mosaic's own Postgres storage layer. + +## Storage tier — PGlite by default, Postgres by config + +The backlog uses the existing Mosaic storage layer; there is **no** new database +engine (no sqlite, no raw client). + +| Condition | Tier | Data location | +| ------------------------------ | -------------------- | -------------------------------- | +| `DATABASE_URL` set | Full server Postgres | the configured database | +| `PGLITE_DATA_DIR` set (no URL) | Embedded PGlite | that directory | +| neither (default) | Embedded PGlite | `~/.config/mosaic/fleet/backlog` | + +PGlite is real Postgres semantics in-process — including the row locks the atomic +claim relies on — so the **same code** runs on a laptop (embedded, single-host +default) and on a full Postgres deployment. Switching tiers is config-only. + +The schema (`backlog` table) is created automatically on first CLI use: +`runMigrations()` for Postgres, `runPgliteMigrations()` for embedded PGlite. + +### Update safety + +The embedded PGlite store lives under `~/.config/mosaic/fleet/backlog`, which is +listed in `PRESERVE_PATHS` in `packages/mosaic/framework/install.sh`. This means +`mosaic update` (which runs the framework sync with `rsync --delete`) will **not** +wipe the operator's backlog — same protection as the roster, per-agent env, and +heartbeat run dir. + +## Card schema + +A card is one row in the `backlog` table: + +| Column | Type | Notes | +| ------------------- | ------------------- | ------------------------------------------------------------- | +| `id` | text (PK) | Stable, caller-supplied id (e.g. `A4`, `fleet-001`). | +| `title` | text | Required. | +| `body` | text (nullable) | Free-form description. | +| `phase` | text (nullable) | Board/phase grouping (see below). | +| `priority` | int (default 0) | **Higher = sooner.** Claim picks the max-priority ready card. | +| `status` | enum | `ready` \| `claimed` \| `blocked` \| `done`. | +| `depends_on` | jsonb `string[]` | DAG edges — ids of cards this one depends on. | +| `claim_owner` | text (nullable) | Owner token of the active claim. | +| `claim_ttl_seconds` | int (nullable) | TTL of the active claim. | +| `claimed_at` | timestamptz (null) | When the claim was taken. `claimed_at + ttl` = expiry. | +| `attempts` | int (default 0) | Incremented each time the card is claimed. | +| `idempotency_key` | text (unique, null) | Dedups `create`; NULLs are distinct in Postgres. | +| `acceptance` | jsonb (nullable) | Acceptance criteria (array of strings or object). | +| `created_at` | timestamptz | | +| `updated_at` | timestamptz | | + +`depends_on` is modeled as a `jsonb` array column rather than a separate edge +table. Justification: it matches the repo's existing style (e.g. `tasks.tags`, +`agents.skills`, `routing_rules.conditions` are all jsonb arrays), keeps a card +self-contained, and the DAG is small (per-card dependency lists), so a join table +would add ceremony without benefit. + +### Board / phase convention + +`phase` is a free-form grouping string used as the board column / milestone label +(e.g. `M1`, `fleet`, `infra`). `list --phase ` filters to one board lane. +`priority` orders cards **within** the ready pool regardless of phase. + +## Status lifecycle + +``` + create + │ + ▼ + ┌──────► ready ───── claim ─────► claimed ───── complete ─────► done + │ │ │ + │ block reclaim (TTL expiry or --id) + │ ▼ │ + │ blocked └──────────────────────────┘ (back to ready) + └──────────┘ (reclaim / re-create can return a card to ready) +``` + +- **ready** — eligible to be claimed once every `depends_on` card is `done`. +- **claimed** — a worker holds it; `claim_owner` + `claimed_at` set. +- **blocked** — explicitly parked; never auto-claimed. +- **done** — completed; satisfies dependents. + +## Atomic claim (`FOR UPDATE SKIP LOCKED`) + TTL + +`claim` is atomic. Inside a single transaction it locks candidate `ready` rows +with `SELECT ... FOR UPDATE SKIP LOCKED` (via the drizzle `sql` operator), picks +the highest-priority deps-satisfied card, and flips it to `claimed`. Because a row +already locked by a concurrent claimer is **skipped**, two claimers can **never** +both win the same card — the loser falls through to the next candidate or gets +`null`. (Proven by the concurrency tests in `packages/db/src/backlog.spec.ts`.) + +- **Deps gate:** a card is only claimable when every id in `depends_on` is `done`. +- **TTL:** `claim --ttl ` (default **900s**) records `claim_ttl_seconds`. +- **reclaim:** releases claims whose `claimed_at + ttl` is in the past (expired) + back to `ready`, clearing the claim fields. `reclaim --id ` force-releases a + specific card regardless of expiry. This is how a crashed worker's card returns + to the pool. + +## CLI — `mosaic fleet backlog --json` + +All subcommands support `--json`. + +| Subcommand | Purpose | +| --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `create --id --title [--body --phase --priority --depends-on --acceptance --idempotency-key]` | Create a card; `idempotency_key` dedups (repeat returns the existing card). | +| `list [--status --phase --ready-only]` | List cards. `--ready-only` = status `ready` AND all deps `done`. | +| `claim --owner [--ttl --id ]` | Atomically claim the highest-priority ready card (or `--id`). Returns the card or `null`. | +| `reclaim [--id ]` | Release expired claims (or a specific card) back to `ready`. | +| `link --from --to` | Add a `depends_on` edge (`--from` depends on `--to`). | +| `stats` | Counts by status, oldest-ready age, expired-claim count. | +| `block --id` | Set a card to `blocked`. | +| `complete --id` | Set a card to `done` (releases any claim). | + +### Example + +```sh +# Seed two cards, the second depends on the first. +mosaic fleet backlog create --id A1 --title "schema" --priority 5 +mosaic fleet backlog create --id A2 --title "service" --depends-on A1 --priority 9 + +# A2 is gated on A1, so claim returns A1 first. +mosaic fleet backlog claim --owner worker-1 --ttl 600 --json + +# Finish A1; now A2 is ready. +mosaic fleet backlog complete --id A1 +mosaic fleet backlog list --ready-only --json + +# Recover stalled work. +mosaic fleet backlog reclaim --json +``` diff --git a/packages/db/drizzle/0011_bitter_gateway.sql b/packages/db/drizzle/0011_bitter_gateway.sql new file mode 100644 index 0000000..8fc3106 --- /dev/null +++ b/packages/db/drizzle/0011_bitter_gateway.sql @@ -0,0 +1,22 @@ +CREATE TYPE "public"."backlog_status" AS ENUM('ready', 'claimed', 'blocked', 'done');--> statement-breakpoint +CREATE TABLE "backlog" ( + "id" text PRIMARY KEY NOT NULL, + "title" text NOT NULL, + "body" text, + "phase" text, + "priority" integer DEFAULT 0 NOT NULL, + "status" "backlog_status" DEFAULT 'ready' NOT NULL, + "depends_on" jsonb DEFAULT '[]'::jsonb NOT NULL, + "claim_owner" text, + "claim_ttl_seconds" integer, + "claimed_at" timestamp with time zone, + "attempts" integer DEFAULT 0 NOT NULL, + "idempotency_key" text, + "acceptance" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX "backlog_status_priority_idx" ON "backlog" USING btree ("status","priority");--> statement-breakpoint +CREATE INDEX "backlog_status_claimed_at_idx" ON "backlog" USING btree ("status","claimed_at");--> statement-breakpoint +CREATE UNIQUE INDEX "backlog_idempotency_key_idx" ON "backlog" USING btree ("idempotency_key"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0011_snapshot.json b/packages/db/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..ae7919e --- /dev/null +++ b/packages/db/drizzle/meta/0011_snapshot.json @@ -0,0 +1,3631 @@ +{ + "id": "0aa37ae4-5a0b-464b-ba70-121c5d9bbd23", + "prevId": "e8e804d3-3556-469f-bb48-e079cdf2fd84", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_tokens": { + "name": "admin_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_tokens_user_id_idx": { + "name": "admin_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_tokens_hash_idx": { + "name": "admin_tokens_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_tokens_user_id_users_id_fk": { + "name": "admin_tokens_user_id_users_id_fk", + "tableFrom": "admin_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_logs": { + "name": "agent_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'hot'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "summarized_at": { + "name": "summarized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_logs_session_tier_idx": { + "name": "agent_logs_session_tier_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_user_id_idx": { + "name": "agent_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_tier_created_at_idx": { + "name": "agent_logs_tier_created_at_idx", + "columns": [ + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_logs_user_id_users_id_fk": { + "name": "agent_logs_user_id_users_id_fk", + "tableFrom": "agent_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_project_id_idx": { + "name": "agents_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_owner_id_idx": { + "name": "agents_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_is_system_idx": { + "name": "agents_is_system_idx", + "columns": [ + { + "expression": "is_system", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_project_id_projects_id_fk": { + "name": "agents_project_id_projects_id_fk", + "tableFrom": "agents", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_owner_id_users_id_fk": { + "name": "agents_owner_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appreciations": { + "name": "appreciations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "from_user": { + "name": "from_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_user": { + "name": "to_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backlog": { + "name": "backlog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "backlog_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "depends_on": { + "name": "depends_on", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "claim_owner": { + "name": "claim_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_ttl_seconds": { + "name": "claim_ttl_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acceptance": { + "name": "acceptance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "backlog_status_priority_idx": { + "name": "backlog_status_priority_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_status_claimed_at_idx": { + "name": "backlog_status_claimed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_idempotency_key_idx": { + "name": "backlog_idempotency_key_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_user_archived_idx": { + "name": "conversations_user_archived_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_id_idx": { + "name": "conversations_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_agent_id_idx": { + "name": "conversations_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_user_id_users_id_fk": { + "name": "conversations_user_id_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_agent_id_agents_id_fk": { + "name": "conversations_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_type_idx": { + "name": "events_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_date_idx": { + "name": "events_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_audit_log": { + "name": "federation_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verb": { + "name": "verb", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result_count": { + "name": "result_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "denied_reason": { + "name": "denied_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "query_hash": { + "name": "query_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_audit_log_peer_created_at_idx": { + "name": "federation_audit_log_peer_created_at_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_subject_created_at_idx": { + "name": "federation_audit_log_subject_created_at_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_created_at_idx": { + "name": "federation_audit_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_audit_log_peer_id_federation_peers_id_fk": { + "name": "federation_audit_log_peer_id_federation_peers_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_subject_user_id_users_id_fk": { + "name": "federation_audit_log_subject_user_id_users_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_grant_id_federation_grants_id_fk": { + "name": "federation_audit_log_grant_id_federation_grants_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_enrollment_tokens": { + "name": "federation_enrollment_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "federation_enrollment_tokens_grant_id_federation_grants_id_fk": { + "name": "federation_enrollment_tokens_grant_id_federation_grants_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_enrollment_tokens_peer_id_federation_peers_id_fk": { + "name": "federation_enrollment_tokens_peer_id_federation_peers_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_grants": { + "name": "federation_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_grants_subject_status_idx": { + "name": "federation_grants_subject_status_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_grants_peer_status_idx": { + "name": "federation_grants_peer_status_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_grants_subject_user_id_users_id_fk": { + "name": "federation_grants_subject_user_id_users_id_fk", + "tableFrom": "federation_grants", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_grants_peer_id_federation_peers_id_fk": { + "name": "federation_grants_peer_id_federation_peers_id_fk", + "tableFrom": "federation_grants", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_peers": { + "name": "federation_peers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "common_name": { + "name": "common_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_pem": { + "name": "cert_pem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_serial": { + "name": "cert_serial", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_not_after": { + "name": "cert_not_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "client_key_pem": { + "name": "client_key_pem", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "peer_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "endpoint_url": { + "name": "endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_peers_cert_serial_idx": { + "name": "federation_peers_cert_serial_idx", + "columns": [ + { + "expression": "cert_serial", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_peers_state_idx": { + "name": "federation_peers_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "federation_peers_common_name_unique": { + "name": "federation_peers_common_name_unique", + "nullsNotDistinct": false, + "columns": [ + "common_name" + ] + }, + "federation_peers_cert_serial_unique": { + "name": "federation_peers_cert_serial_unique", + "nullsNotDistinct": false, + "columns": [ + "cert_serial" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.insights": { + "name": "insights", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "relevance_score": { + "name": "relevance_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decayed_at": { + "name": "decayed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "insights_user_id_idx": { + "name": "insights_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_category_idx": { + "name": "insights_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_relevance_idx": { + "name": "insights_relevance_idx", + "columns": [ + { + "expression": "relevance_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "insights_user_id_users_id_fk": { + "name": "insights_user_id_users_id_fk", + "tableFrom": "insights", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_conversation_id_idx": { + "name": "messages_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mission_tasks": { + "name": "mission_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr": { + "name": "pr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mission_tasks_mission_id_idx": { + "name": "mission_tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_task_id_idx": { + "name": "mission_tasks_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_user_id_idx": { + "name": "mission_tasks_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_status_idx": { + "name": "mission_tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mission_tasks_mission_id_missions_id_fk": { + "name": "mission_tasks_mission_id_missions_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mission_tasks_task_id_tasks_id_fk": { + "name": "mission_tasks_task_id_tasks_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mission_tasks_user_id_users_id_fk": { + "name": "mission_tasks_user_id_users_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.missions": { + "name": "missions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "milestones": { + "name": "milestones", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "missions_project_id_idx": { + "name": "missions_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "missions_user_id_idx": { + "name": "missions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "missions_project_id_projects_id_fk": { + "name": "missions_project_id_projects_id_fk", + "tableFrom": "missions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "missions_user_id_users_id_fk": { + "name": "missions_user_id_users_id_fk", + "tableFrom": "missions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preferences": { + "name": "preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mutable": { + "name": "mutable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "preferences_user_id_idx": { + "name": "preferences_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "preferences_user_key_idx": { + "name": "preferences_user_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "preferences_user_id_users_id_fk": { + "name": "preferences_user_id_users_id_fk", + "tableFrom": "preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_credentials": { + "name": "provider_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_type": { + "name": "credential_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_credentials_user_provider_idx": { + "name": "provider_credentials_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_credentials_user_id_idx": { + "name": "provider_credentials_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_credentials_user_id_users_id_fk": { + "name": "provider_credentials_user_id_users_id_fk", + "tableFrom": "provider_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routing_rules": { + "name": "routing_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routing_rules_scope_priority_idx": { + "name": "routing_rules_scope_priority_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_user_id_idx": { + "name": "routing_rules_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_enabled_idx": { + "name": "routing_rules_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routing_rules_user_id_users_id_fk": { + "name": "routing_rules_user_id_users_id_fk", + "tableFrom": "routing_rules", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_enabled_idx": { + "name": "skills_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_installed_by_users_id_fk": { + "name": "skills_installed_by_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "installed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skills_name_unique": { + "name": "skills_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.summarization_jobs": { + "name": "summarization_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "logs_processed": { + "name": "logs_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "insights_created": { + "name": "insights_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "summarization_jobs_status_idx": { + "name": "summarization_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_project_id_idx": { + "name": "tasks_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_mission_id_idx": { + "name": "tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_mission_id_missions_id_fk": { + "name": "tasks_mission_id_missions_id_fk", + "tableFrom": "tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_user_idx": { + "name": "team_members_team_user_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_invited_by_users_id_fk": { + "name": "team_members_invited_by_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_id": { + "name": "manager_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "teams_manager_id_users_id_fk": { + "name": "teams_manager_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "manager_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_slug_unique": { + "name": "teams_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tickets": { + "name": "tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tickets_status_idx": { + "name": "tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.backlog_status": { + "name": "backlog_status", + "schema": "public", + "values": [ + "ready", + "claimed", + "blocked", + "done" + ] + }, + "public.grant_status": { + "name": "grant_status", + "schema": "public", + "values": [ + "pending", + "active", + "revoked", + "expired" + ] + }, + "public.peer_state": { + "name": "peer_state", + "schema": "public", + "values": [ + "pending", + "active", + "suspended", + "revoked" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index f7764f6..fb09f9d 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1745366400000, "tag": "0010_federation_enrollment_tokens", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1782310438919, + "tag": "0011_bitter_gateway", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/src/backlog.spec.ts b/packages/db/src/backlog.spec.ts new file mode 100644 index 0000000..d02a0df --- /dev/null +++ b/packages/db/src/backlog.spec.ts @@ -0,0 +1,263 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { sql } from 'drizzle-orm'; +import { createPgliteDb } from './client-pglite.js'; +import { runPgliteMigrations } from './migrate.js'; +import type { DbHandle } from './client.js'; +import { BacklogService } from './backlog.js'; +import { backlog } from './schema.js'; + +// Helper: backdate a claim's claimed_at by 1 hour so it is past any short TTL. +function sqlBackdate(id: string) { + return sql`UPDATE ${backlog} SET claimed_at = now() - interval '1 hour' WHERE ${backlog.id} = ${id}`; +} + +/** + * Real Postgres semantics, no external server: embedded in-memory PGlite. + * The migration path creates the `backlog` table (and every other table) so the + * service runs against the actual generated schema, including the row locks the + * atomic-claim path depends on. + */ +async function freshService(): Promise<{ handle: DbHandle; svc: BacklogService }> { + const handle = createPgliteDb('memory://'); + await runPgliteMigrations(handle); + return { handle, svc: new BacklogService(handle.db) }; +} + +describe('BacklogService', () => { + let handle: DbHandle; + let svc: BacklogService; + + beforeEach(async () => { + ({ handle, svc } = await freshService()); + }); + + afterEach(async () => { + await handle.close(); + }); + + it('create then list returns the card', async () => { + await svc.create({ id: 'c1', title: 'First card', phase: 'M1', priority: 5 }); + const all = await svc.list(); + expect(all).toHaveLength(1); + expect(all[0]).toMatchObject({ id: 'c1', title: 'First card', phase: 'M1', status: 'ready' }); + }); + + it('idempotency_key dedups create', async () => { + const a = await svc.create({ id: 'c1', title: 'one', idempotencyKey: 'k-1' }); + const b = await svc.create({ id: 'c2', title: 'two', idempotencyKey: 'k-1' }); + expect(b.id).toBe(a.id); + const all = await svc.list(); + expect(all).toHaveLength(1); + }); + + it('list filters by status and phase', async () => { + await svc.create({ id: 'c1', title: 'a', phase: 'M1' }); + await svc.create({ id: 'c2', title: 'b', phase: 'M2' }); + await svc.block('c2'); + expect(await svc.list({ phase: 'M1' })).toHaveLength(1); + expect(await svc.list({ status: 'blocked' })).toHaveLength(1); + expect((await svc.list({ status: 'blocked' }))[0]!.id).toBe('c2'); + }); + + describe('atomic claim', () => { + it('two concurrent claimers on one card => exactly one wins', async () => { + await svc.create({ id: 'only', title: 'the one', priority: 10 }); + + // Two independent claimers race for the single ready card on the same db. + // The atomic claim path (`FOR UPDATE SKIP LOCKED` inside a transaction) + // guarantees the loser's locked row is skipped, so it can never also flip + // the card to claimed — it gets the next candidate (none) and returns null. + const svcA = new BacklogService(handle.db); + const svcB = new BacklogService(handle.db); + + const [a, b] = await Promise.all([ + svcA.claim({ owner: 'worker-A' }), + svcB.claim({ owner: 'worker-B' }), + ]); + + const winners = [a, b].filter((c) => c !== null); + expect(winners).toHaveLength(1); + expect(winners[0]!.id).toBe('only'); + expect(winners[0]!.status).toBe('claimed'); + expect(['worker-A', 'worker-B']).toContain(winners[0]!.claimOwner); + + const card = await svc.get('only'); + expect(card!.status).toBe('claimed'); + expect(card!.attempts).toBe(1); + }); + + it('many concurrent claimers on N cards => no card is double-claimed', async () => { + // 5 ready cards, 8 concurrent claimers. Exactly 5 win, all distinct. + for (let i = 0; i < 5; i++) { + await svc.create({ id: `card-${i}`, title: `card ${i}`, priority: i }); + } + const claimers = Array.from({ length: 8 }, (_, i) => + new BacklogService(handle.db).claim({ owner: `w-${i}` }), + ); + const results = await Promise.all(claimers); + const won = results.filter((c): c is NonNullable => c !== null); + const wonIds = won.map((c) => c.id); + expect(won).toHaveLength(5); + expect(new Set(wonIds).size).toBe(5); // all distinct — no double-claim + }); + + it('N concurrent claimers on N ready cards => every claimer wins a distinct card (no starvation)', async () => { + // This is the direct benefit of locking exactly ONE ready row per claim + // (`FOR UPDATE SKIP LOCKED LIMIT 1`): with as many ready cards as + // claimers, NONE should starve. The old "lock the whole ready set" + // behaviour let one claimer lock every row, forcing the rest to null even + // though cards were free. + const N = 6; + for (let i = 0; i < N; i++) { + await svc.create({ id: `n-${i}`, title: `card ${i}`, priority: i }); + } + const results = await Promise.all( + Array.from({ length: N }, (_, i) => + new BacklogService(handle.db).claim({ owner: `w-${i}` }), + ), + ); + const won = results.filter((c): c is NonNullable => c !== null); + // No claimer starved: all N won. + expect(won).toHaveLength(N); + // Each won a distinct card. + expect(new Set(won.map((c) => c.id)).size).toBe(N); + // Every ready card was consumed. + expect(await svc.list({ status: 'ready' })).toHaveLength(0); + }); + + it('sequential claims drain ready cards in priority order and never null while ready remain', async () => { + // PGlite-stable fallback assertion of the same property without relying on + // true parallelism or wall-clock timing: each claim returns the next + // highest-priority distinct card and never spuriously returns null while + // ready cards remain. + const N = 4; + for (let i = 0; i < N; i++) { + await svc.create({ id: `s-${i}`, title: `card ${i}`, priority: i }); + } + const order: string[] = []; + for (let i = 0; i < N; i++) { + const claimed = await svc.claim({ owner: `w-${i}` }); + expect(claimed).not.toBeNull(); + order.push(claimed!.id); + } + // Highest priority first, all distinct. + expect(order).toEqual(['s-3', 's-2', 's-1', 's-0']); + expect(new Set(order).size).toBe(N); + // Now nothing ready remains => null. + expect(await svc.claim({ owner: 'late' })).toBeNull(); + }); + + it('claim picks the highest-priority ready card', async () => { + await svc.create({ id: 'low', title: 'low', priority: 1 }); + await svc.create({ id: 'high', title: 'high', priority: 9 }); + const claimed = await svc.claim({ owner: 'w' }); + expect(claimed!.id).toBe('high'); + }); + + it('claim of a specific --id', async () => { + await svc.create({ id: 'a', title: 'a', priority: 9 }); + await svc.create({ id: 'b', title: 'b', priority: 1 }); + const claimed = await svc.claim({ owner: 'w', id: 'b' }); + expect(claimed!.id).toBe('b'); + }); + + it('claim returns null when nothing is ready', async () => { + const claimed = await svc.claim({ owner: 'w' }); + expect(claimed).toBeNull(); + }); + }); + + describe('deps DAG gate', () => { + it('card with an unfinished dep is not claimable and not ready', async () => { + await svc.create({ id: 'dep', title: 'dependency' }); + await svc.create({ id: 'main', title: 'depends on dep', dependsOn: ['dep'] }); + + // `main` should NOT be claimable while `dep` is not done — `dep` wins. + const first = await svc.claim({ owner: 'w' }); + expect(first!.id).toBe('dep'); + + // With dep claimed (not done), main still cannot be claimed. + const second = await svc.claim({ owner: 'w' }); + expect(second).toBeNull(); + + // ready-only list excludes main while its dep is unfinished. + const ready = await svc.list({ readyOnly: true }); + expect(ready.map((c) => c.id)).not.toContain('main'); + + // Once dep is done, main becomes ready and claimable. + await svc.complete('dep'); + const readyAfter = await svc.list({ readyOnly: true }); + expect(readyAfter.map((c) => c.id)).toContain('main'); + const third = await svc.claim({ owner: 'w' }); + expect(third!.id).toBe('main'); + }); + + it('link adds a depends_on edge', async () => { + await svc.create({ id: 'a', title: 'a' }); + await svc.create({ id: 'b', title: 'b' }); + const linked = await svc.link('a', 'b'); + expect(linked.dependsOn).toEqual(['b']); + // a is now gated on b + const claimed = await svc.claim({ owner: 'w' }); + expect(claimed!.id).toBe('b'); + }); + }); + + describe('reclaim TTL', () => { + it('reclaim returns expired claims to ready', async () => { + await svc.create({ id: 'c1', title: 'c1' }); + const claimed = await svc.claim({ owner: 'w', ttlSeconds: 60 }); + expect(claimed!.status).toBe('claimed'); + + // Backdate the claim so it is well past its TTL. + await handle.db.execute(sqlBackdate('c1')); + + const result = await svc.reclaim(); + expect(result.reclaimed).toEqual(['c1']); + const card = await svc.get('c1'); + expect(card!.status).toBe('ready'); + expect(card!.claimOwner).toBeNull(); + expect(card!.claimedAt).toBeNull(); + }); + + it('reclaim does not touch a fresh (unexpired) claim', async () => { + await svc.create({ id: 'c1', title: 'c1' }); + await svc.claim({ owner: 'w', ttlSeconds: 3600 }); + const result = await svc.reclaim(); + expect(result.reclaimed).toEqual([]); + expect((await svc.get('c1'))!.status).toBe('claimed'); + }); + + it('reclaim --id releases a specific claim regardless of expiry', async () => { + await svc.create({ id: 'c1', title: 'c1' }); + await svc.claim({ owner: 'w', ttlSeconds: 3600 }); + const result = await svc.reclaim({ id: 'c1' }); + expect(result.reclaimed).toEqual(['c1']); + expect((await svc.get('c1'))!.status).toBe('ready'); + }); + }); + + describe('stats', () => { + it('computes counts, oldest-ready age, and expired-claim count', async () => { + await svc.create({ id: 'r1', title: 'r1' }); + await svc.create({ id: 'r2', title: 'r2' }); + await svc.create({ id: 'b1', title: 'b1' }); + await svc.block('b1'); + await svc.create({ id: 'd1', title: 'd1' }); + await svc.complete('d1'); + await svc.create({ id: 'cl1', title: 'cl1' }); + await svc.claim({ owner: 'w', id: 'cl1', ttlSeconds: 60 }); + await handle.db.execute(sqlBackdate('cl1')); + + const stats = await svc.stats(); + expect(stats.counts.ready).toBe(2); + expect(stats.counts.blocked).toBe(1); + expect(stats.counts.done).toBe(1); + expect(stats.counts.claimed).toBe(1); + expect(stats.total).toBe(5); + expect(stats.expiredClaimCount).toBe(1); + expect(stats.oldestReadyAgeSeconds).not.toBeNull(); + expect(stats.oldestReadyAgeSeconds!).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/packages/db/src/backlog.ts b/packages/db/src/backlog.ts new file mode 100644 index 0000000..922c259 --- /dev/null +++ b/packages/db/src/backlog.ts @@ -0,0 +1,457 @@ +/** + * Mosaic-native backlog-of-record service (card A4). + * + * This is the backlog Mosaic owns end-to-end on its OWN Postgres storage layer. + * It REPLACES the former Hermes adapter — there is NO runtime dependency on + * Hermes here or anywhere downstream. + * + * The service takes a `Db` handle, so it works identically against: + * - `createDb()` — server Postgres (DATABASE_URL / config), and + * - `createPgliteDb()` — embedded Postgres (file or in-memory). + * Same code, same semantics — PGlite gives real Postgres behaviour (including + * row locks), so the atomic-claim path is exercised by the in-memory tests. + * + * Atomic claim: `claim()` selects the highest-priority, deps-satisfied, ready + * card with `SELECT ... FOR UPDATE SKIP LOCKED` and flips it to `claimed` inside + * one transaction. Two concurrent claimers can therefore NEVER both win the same + * card — the loser's locked row is skipped and it picks the next candidate (or + * gets null). + */ + +import { and, asc, desc, eq, sql } from 'drizzle-orm'; +import type { Db } from './client.js'; +import { backlog } from './schema.js'; + +export type BacklogStatus = 'ready' | 'claimed' | 'blocked' | 'done'; + +export interface BacklogCard { + id: string; + title: string; + body: string | null; + phase: string | null; + priority: number; + status: BacklogStatus; + dependsOn: string[]; + claimOwner: string | null; + claimTtlSeconds: number | null; + claimedAt: Date | null; + attempts: number; + idempotencyKey: string | null; + acceptance: unknown; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateCardInput { + id: string; + title: string; + body?: string | null; + phase?: string | null; + priority?: number; + dependsOn?: string[]; + acceptance?: unknown; + idempotencyKey?: string | null; + status?: BacklogStatus; +} + +export interface ListFilter { + status?: BacklogStatus; + phase?: string; + /** When true, return only cards that are `ready` AND have all deps `done`. */ + readyOnly?: boolean; +} + +export interface ClaimOptions { + owner: string; + /** Claim time-to-live in seconds (default 900). */ + ttlSeconds?: number; + /** Claim a specific card by id instead of the highest-priority ready one. */ + id?: string; +} + +export interface ReclaimResult { + reclaimed: string[]; +} + +export interface BacklogStats { + counts: Record; + total: number; + oldestReadyAgeSeconds: number | null; + expiredClaimCount: number; +} + +export const DEFAULT_CLAIM_TTL_SECONDS = 900; + +type Row = typeof backlog.$inferSelect; + +/** + * Row shape as returned by the raw `SELECT * ... FOR UPDATE SKIP LOCKED` path. + * That path bypasses drizzle's column-name mapping, so JSON columns arrive as + * the snake_case `depends_on` (and may be a JSON string under some drivers). + */ +interface RawRow extends Row { + depends_on?: unknown; +} + +function toCard(row: Row): BacklogCard { + return { + id: row.id, + title: row.title, + body: row.body, + phase: row.phase, + priority: row.priority, + status: row.status, + dependsOn: row.dependsOn ?? [], + claimOwner: row.claimOwner, + claimTtlSeconds: row.claimTtlSeconds, + claimedAt: row.claimedAt, + attempts: row.attempts, + idempotencyKey: row.idempotencyKey, + acceptance: row.acceptance, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +/** + * The backlog repository/service. Construct with any `Db` handle. + */ +export class BacklogService { + constructor(private readonly db: Db) {} + + /** + * Create a card. If `idempotencyKey` is provided and a card already exists + * with that key, the existing card is returned unchanged (no duplicate). + */ + async create(input: CreateCardInput): Promise { + if (input.idempotencyKey) { + const existing = await this.db + .select() + .from(backlog) + .where(eq(backlog.idempotencyKey, input.idempotencyKey)) + .limit(1); + if (existing[0]) return toCard(existing[0]); + } + + const inserted = await this.db + .insert(backlog) + .values({ + id: input.id, + title: input.title, + body: input.body ?? null, + phase: input.phase ?? null, + priority: input.priority ?? 0, + status: input.status ?? 'ready', + dependsOn: input.dependsOn ?? [], + acceptance: input.acceptance ?? null, + idempotencyKey: input.idempotencyKey ?? null, + }) + .returning(); + + return toCard(inserted[0]!); + } + + /** Fetch a single card by id, or null. */ + async get(id: string): Promise { + const rows = await this.db.select().from(backlog).where(eq(backlog.id, id)).limit(1); + return rows[0] ? toCard(rows[0]) : null; + } + + /** + * List cards with optional filters. `readyOnly` enforces the DAG gate: + * a card is "ready" only when its own status is `ready` AND every card in + * `depends_on` exists and is `done`. + */ + async list(filter: ListFilter = {}): Promise { + const conditions = []; + if (filter.status) conditions.push(eq(backlog.status, filter.status)); + if (filter.phase) conditions.push(eq(backlog.phase, filter.phase)); + + const rows = await this.db + .select() + .from(backlog) + .where(conditions.length ? and(...conditions) : undefined) + .orderBy(desc(backlog.priority), asc(backlog.createdAt)); + + const cards = rows.map(toCard); + if (!filter.readyOnly) return cards; + + const doneIds = await this.doneIdSet(); + return cards.filter( + (c) => c.status === 'ready' && c.dependsOn.every((dep) => doneIds.has(dep)), + ); + } + + private async doneIdSet(): Promise> { + const done = await this.db + .select({ id: backlog.id }) + .from(backlog) + .where(eq(backlog.status, 'done')); + return new Set(done.map((d) => d.id)); + } + + /** + * Atomically claim a card. + * + * Strategy: inside ONE transaction we lock the candidate row with + * `FOR UPDATE SKIP LOCKED LIMIT 1`. A concurrent claimer that already holds + * the lock on a row has that row skipped for us, so two claimers can never + * both win the same card — and, crucially, each claimer locks exactly ONE + * row, so concurrent claimers fan out across distinct ready cards instead of + * one claimer locking the whole ready set and starving the rest. + * + * Candidate selection (when no explicit `id`): + * - status = 'ready' + * - all deps satisfied (every id in depends_on is currently 'done') + * - ordered by priority DESC, created_at ASC + * + * Returns the claimed card, or null if nothing is claimable. + */ + async claim(opts: ClaimOptions): Promise { + const ttl = opts.ttlSeconds ?? DEFAULT_CLAIM_TTL_SECONDS; + + return this.db.transaction(async (tx) => { + // Specific-id path: lock that one ready row (if free) and apply the + // deps-satisfied gate in JS, exactly as before. + if (opts.id) { + const doneRows = await tx + .select({ id: backlog.id }) + .from(backlog) + .where(eq(backlog.status, 'done')); + const doneIds = new Set(doneRows.map((r) => r.id)); + + const result = await tx.execute( + sql`SELECT * FROM ${backlog} + WHERE ${backlog.id} = ${opts.id} AND ${backlog.status} = 'ready' + FOR UPDATE SKIP LOCKED`, + ); + const candidate = rowsOf(result).find((row) => + normalizeDeps(row.depends_on).every((dep) => doneIds.has(dep)), + ); + if (!candidate) return null; + + const updated = await tx + .update(backlog) + .set({ + status: 'claimed', + claimOwner: opts.owner, + claimTtlSeconds: ttl, + claimedAt: new Date(), + attempts: sql`${backlog.attempts} + 1`, + updatedAt: new Date(), + }) + .where(eq(backlog.id, candidate.id)) + .returning(); + + return toCard(updated[0]!); + } + + // No-id path: claim the single highest-priority, deps-satisfied ready + // card. We lock exactly ONE row in the inner SELECT (`FOR UPDATE SKIP + // LOCKED LIMIT 1`) so concurrent claimers grab distinct cards rather than + // one claimer locking every ready row and forcing the others to null. + // + // The deps-satisfied gate is pushed into SQL so `LIMIT 1` lands on the + // next genuinely-eligible card: a card is eligible iff none of its + // depends_on ids is absent from the set of 'done' card ids. + const updated = await tx.execute( + sql`UPDATE ${backlog} + SET status = 'claimed', + claim_owner = ${opts.owner}, + claim_ttl_seconds = ${ttl}, + claimed_at = now(), + attempts = ${backlog.attempts} + 1, + updated_at = now() + WHERE ${backlog.id} = ( + SELECT b.id FROM ${backlog} AS b + WHERE b.status = 'ready' + AND NOT EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text(b.depends_on) AS dep + WHERE dep NOT IN ( + SELECT d.id FROM ${backlog} AS d WHERE d.status = 'done' + ) + ) + ORDER BY b.priority DESC, b.created_at ASC + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + RETURNING *`, + ); + + const row = rowsOf(updated)[0]; + return row ? toCard(rawToRow(row)) : null; + }); + } + + /** + * Release expired claims (claimed_at + ttl < now) back to `ready`, OR release + * a specific card by id regardless of expiry. Cleared claim fields. + * Returns the ids that were released. + */ + async reclaim(opts: { id?: string } = {}): Promise { + if (opts.id) { + const released = await this.db + .update(backlog) + .set({ + status: 'ready', + claimOwner: null, + claimTtlSeconds: null, + claimedAt: null, + updatedAt: new Date(), + }) + .where(and(eq(backlog.id, opts.id), eq(backlog.status, 'claimed'))) + .returning({ id: backlog.id }); + return { reclaimed: released.map((r) => r.id) }; + } + + // Expired = status claimed AND claimed_at + (ttl seconds) < now(). + const released = await this.db + .update(backlog) + .set({ + status: 'ready', + claimOwner: null, + claimTtlSeconds: null, + claimedAt: null, + updatedAt: new Date(), + }) + .where( + and( + eq(backlog.status, 'claimed'), + sql`${backlog.claimedAt} + make_interval(secs => ${backlog.claimTtlSeconds}) < now()`, + ), + ) + .returning({ id: backlog.id }); + return { reclaimed: released.map((r) => r.id) }; + } + + /** Add a `depends_on` edge (from → depends on → to). Idempotent. */ + async link(from: string, to: string): Promise { + const card = await this.get(from); + if (!card) throw new Error(`backlog card not found: ${from}`); + const target = await this.get(to); + if (!target) throw new Error(`backlog dependency not found: ${to}`); + if (from === to) throw new Error('a card cannot depend on itself'); + + if (card.dependsOn.includes(to)) return card; + const nextDeps = [...card.dependsOn, to]; + const updated = await this.db + .update(backlog) + .set({ dependsOn: nextDeps, updatedAt: new Date() }) + .where(eq(backlog.id, from)) + .returning(); + return toCard(updated[0]!); + } + + /** Mark a card blocked. */ + async block(id: string): Promise { + return this.setStatus(id, 'blocked'); + } + + /** Mark a card done (releasing any claim). */ + async complete(id: string): Promise { + const updated = await this.db + .update(backlog) + .set({ + status: 'done', + claimOwner: null, + claimTtlSeconds: null, + claimedAt: null, + updatedAt: new Date(), + }) + .where(eq(backlog.id, id)) + .returning(); + return updated[0] ? toCard(updated[0]) : null; + } + + private async setStatus(id: string, status: BacklogStatus): Promise { + const updated = await this.db + .update(backlog) + .set({ status, updatedAt: new Date() }) + .where(eq(backlog.id, id)) + .returning(); + return updated[0] ? toCard(updated[0]) : null; + } + + /** Counts by status, oldest-ready age (seconds), and expired-claim count. */ + async stats(): Promise { + const all = await this.db.select().from(backlog); + const counts: Record = { + ready: 0, + claimed: 0, + blocked: 0, + done: 0, + }; + let oldestReady: Date | null = null; + let expiredClaimCount = 0; + const now = Date.now(); + + for (const row of all) { + counts[row.status] += 1; + if (row.status === 'ready') { + if (oldestReady === null || row.createdAt < oldestReady) oldestReady = row.createdAt; + } + if (row.status === 'claimed' && row.claimedAt && row.claimTtlSeconds != null) { + const expiry = row.claimedAt.getTime() + row.claimTtlSeconds * 1000; + if (expiry < now) expiredClaimCount += 1; + } + } + + return { + counts, + total: all.length, + oldestReadyAgeSeconds: + oldestReady === null ? null : Math.max(0, Math.floor((now - oldestReady.getTime()) / 1000)), + expiredClaimCount, + }; + } +} + +/** Extract rows from a drizzle `.execute()` result across drivers (pg / pglite). */ +function rowsOf(result: unknown): RawRow[] { + if (Array.isArray(result)) return result as RawRow[]; + const maybe = result as { rows?: unknown }; + if (maybe && Array.isArray(maybe.rows)) return maybe.rows as RawRow[]; + return []; +} + +/** + * Map a raw `RETURNING *` row (snake_case columns, possibly string-encoded + * timestamps/JSON depending on the driver) onto the drizzle `Row` shape that + * `toCard` consumes. Mirrors the column ↔ property mapping in `schema.ts`. + */ +function rawToRow(raw: RawRow): Row { + const r = raw as unknown as Record; + const toDate = (v: unknown): Date => (v instanceof Date ? v : new Date(v as string)); + return { + id: r.id as string, + title: r.title as string, + body: (r.body ?? null) as string | null, + phase: (r.phase ?? null) as string | null, + priority: Number(r.priority), + status: r.status as BacklogStatus, + dependsOn: normalizeDeps(r.depends_on), + claimOwner: (r.claim_owner ?? null) as string | null, + claimTtlSeconds: r.claim_ttl_seconds == null ? null : Number(r.claim_ttl_seconds), + claimedAt: r.claimed_at == null ? null : toDate(r.claimed_at), + attempts: Number(r.attempts), + idempotencyKey: (r.idempotency_key ?? null) as string | null, + acceptance: r.acceptance ?? null, + createdAt: toDate(r.created_at), + updatedAt: toDate(r.updated_at), + }; +} + +/** A raw SQL row returns snake_case `depends_on`; normalize to string[]. */ +function normalizeDeps(value: unknown): string[] { + if (Array.isArray(value)) return value as string[]; + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? (parsed as string[]) : []; + } catch { + return []; + } + } + return []; +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index d0c0d09..9d823ad 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -3,6 +3,17 @@ export { createPgliteDb } from './client-pglite.js'; export { runMigrations, runPgliteMigrations } from './migrate.js'; export * from './schema.js'; export * from './federation.js'; +export { + BacklogService, + DEFAULT_CLAIM_TTL_SECONDS, + type BacklogCard, + type BacklogStatus, + type BacklogStats, + type ClaimOptions, + type CreateCardInput, + type ListFilter, + type ReclaimResult, +} from './backlog.js'; export { eq, and, diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 6ce5ec3..7f343ce 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -587,6 +587,62 @@ export const summarizationJobs = pgTable( (t) => [index('summarization_jobs_status_idx').on(t.status)], ); +// ─── Fleet Backlog ──────────────────────────────────────────────────────────── +// Mosaic-native backlog-of-record (card A4). This REPLACES the former Hermes +// adapter — there is NO runtime dependency on Hermes. Cards form a dependency +// DAG (`depends_on`), are claimed atomically by fleet workers via +// `SELECT ... FOR UPDATE SKIP LOCKED`, and auto-expire via a TTL so a crashed +// claimer's card returns to the pool. + +/** + * Lifecycle status of a backlog card. + * - ready: eligible to be claimed (once its deps are all `done`). + * - claimed: a worker holds it (claim_owner + claimed_at set); may expire via TTL. + * - blocked: explicitly parked; never auto-claimed. + * - done: completed; satisfies dependents. + */ +export const backlogStatusEnum = pgEnum('backlog_status', ['ready', 'claimed', 'blocked', 'done']); + +export const backlog = pgTable( + 'backlog', + { + /** Stable, caller-supplied card id (e.g. "A4", "fleet-001"). PK. */ + id: text('id').primaryKey(), + title: text('title').notNull(), + body: text('body'), + /** Board/phase grouping (e.g. "M1", "fleet"). Free-form. */ + phase: text('phase'), + /** Higher number = higher priority; claim picks the max-priority ready card. */ + priority: integer('priority').notNull().default(0), + status: backlogStatusEnum('status').notNull().default('ready'), + /** DAG edges: ids of cards this one depends on. "ready" requires all done. */ + dependsOn: jsonb('depends_on').notNull().$type().default([]), + /** Owner token of the current claim (worker/agent id). NULL when unclaimed. */ + claimOwner: text('claim_owner'), + /** TTL of the active claim in seconds. NULL when unclaimed. */ + claimTtlSeconds: integer('claim_ttl_seconds'), + /** When the active claim was taken. NULL when unclaimed. claimed_at + ttl = expiry. */ + claimedAt: timestamp('claimed_at', { withTimezone: true }), + /** Count of times this card has been claimed (incremented on each claim). */ + attempts: integer('attempts').notNull().default(0), + /** Optional dedup key for `create`; a repeat key returns the existing card. */ + idempotencyKey: text('idempotency_key'), + /** Acceptance criteria — free-form JSON (array of strings or object). */ + acceptance: jsonb('acceptance'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + // Hot path: claim scans ready cards ordered by priority then age. + index('backlog_status_priority_idx').on(t.status, t.priority), + // reclaim sweeps claimed cards by claimed_at to find expired ones. + index('backlog_status_claimed_at_idx').on(t.status, t.claimedAt), + // Idempotent create dedups on this key (NULLs are distinct in Postgres, so + // many unkeyed cards coexist; a repeated non-null key collides). + uniqueIndex('backlog_idempotency_key_idx').on(t.idempotencyKey), + ], +); + // ─── Federation ────────────────────────────────────────────────────────────── // Enums declared before tables that reference them. // All federation definitions live in this file (avoids CJS/ESM cross-import diff --git a/packages/mosaic/framework/install.sh b/packages/mosaic/framework/install.sh index 5a4c89e..870e17f 100755 --- a/packages/mosaic/framework/install.sh +++ b/packages/mosaic/framework/install.sh @@ -30,10 +30,12 @@ INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}" # own fleet files MUST # survive `mosaic update` (which runs this sync automatically): the active # roster (`fleet/roster.yaml` + any other `fleet/*.yaml`), per-agent env -# (`fleet/agents/`), and heartbeat run dir (`fleet/run/`). Without these, an -# update wipes the operator's fleet. Glob entries are honored by both the rsync -# path (`--exclude`) and the glob-aware cp fallback below. -PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/*.yaml" "fleet/agents" "fleet/run") +# (`fleet/agents/`), heartbeat run dir (`fleet/run/`), and the Mosaic-native +# backlog-of-record store (`fleet/backlog/` — embedded PGlite data dir; see +# packages/mosaic/src/commands/fleet-backlog.ts). Without these, an update +# wipes the operator's fleet AND their backlog. Glob entries are honored by +# both the rsync path (`--exclude`) and the glob-aware cp fallback below. +PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/*.yaml" "fleet/agents" "fleet/run" "fleet/backlog") # Framework-owned contract files: re-copied from defaults/ on every upgrade (the # user must not edit them; a divergent copy is backed up once before overwrite). diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index b095e25..f97427a 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -29,6 +29,7 @@ "dependencies": { "@mosaicstack/brain": "workspace:*", "@mosaicstack/config": "workspace:*", + "@mosaicstack/db": "workspace:*", "@mosaicstack/forge": "workspace:*", "@mosaicstack/log": "workspace:*", "@mosaicstack/macp": "workspace:*", diff --git a/packages/mosaic/src/commands/fleet-backlog.ts b/packages/mosaic/src/commands/fleet-backlog.ts new file mode 100644 index 0000000..1fb4a48 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-backlog.ts @@ -0,0 +1,285 @@ +/** + * `mosaic fleet backlog --json` — Mosaic-native backlog of record. + * + * Mosaic OWNS this backlog end-to-end on its existing Postgres storage layer + * (`@mosaicstack/db`). It REPLACES the former Hermes adapter — there is NO + * runtime dependency on Hermes. + * + * Storage tier (the existing storage-layer convention, no new engine): + * - default: embedded PGlite at /fleet/backlog (real Postgres + * semantics, persisted on disk so the operator's backlog survives reboots + * and `mosaic update` — see install.sh PRESERVE_PATHS). + * - DATABASE_URL set: full server Postgres — same code, no change. + * + * Migrations run on first use so the `backlog` table always exists. + */ + +import { mkdir } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { Command } from 'commander'; +import { + BacklogService, + DEFAULT_CLAIM_TTL_SECONDS, + type BacklogCard, + type DbHandle, +} from '@mosaicstack/db'; + +function defaultMosaicHome(): string { + return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); +} + +/** Resolve where the embedded PGlite backlog store lives (default tier). */ +export function defaultBacklogDataDir(mosaicHome = defaultMosaicHome()): string { + return join(mosaicHome, 'fleet', 'backlog'); +} + +/** + * Open a db handle for the backlog and ensure the schema exists. + * + * Tier detection mirrors the storage layer: DATABASE_URL => server Postgres + * (migrations applied via runMigrations); otherwise embedded PGlite at the + * fleet/backlog data dir (migrations applied via runPgliteMigrations). + */ +async function openBacklogDb(mosaicHome: string): Promise { + const { createDb, createPgliteDb, runMigrations, runPgliteMigrations } = + await import('@mosaicstack/db'); + const url = process.env['DATABASE_URL']; + if (url) { + await runMigrations(url); + return createDb(url); + } + const dataDir = process.env['PGLITE_DATA_DIR'] ?? defaultBacklogDataDir(mosaicHome); + // PGlite writes a file-backed store to dataDir but does not create missing + // parent directories (e.g. /fleet). Create them first. Skip for + // the in-memory pseudo-paths so a memory:// store never touches the fs. + if (!dataDir.startsWith('memory://') && dataDir !== ':memory:') { + await mkdir(dataDir, { recursive: true }); + } + const handle = createPgliteDb(dataDir); + await runPgliteMigrations(handle); + return handle; +} + +function parseDependsOn(value?: string): string[] { + if (!value) return []; + return value + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +function parseAcceptance(value?: string): unknown { + if (!value) return null; + try { + return JSON.parse(value); + } catch { + // Fall back to a list of newline/semicolon-separated criteria. + return value + .split(/[\n;]/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + } +} + +function printCard(card: BacklogCard | null, json?: boolean): void { + if (json) { + console.log(JSON.stringify(card)); + return; + } + if (!card) { + console.log('(none)'); + return; + } + const deps = card.dependsOn.length ? card.dependsOn.join(',') : '-'; + console.log( + `${card.id}\t[${card.status}]\tp=${card.priority}\tphase=${card.phase ?? '-'}\tdeps=${deps}\t${card.title}`, + ); +} + +function printCards(cards: BacklogCard[], json?: boolean): void { + if (json) { + console.log(JSON.stringify(cards)); + return; + } + if (cards.length === 0) { + console.log('(no cards)'); + return; + } + for (const card of cards) printCard(card, false); +} + +/** + * Register `backlog` under an existing `fleet` command. + * `mosaicHomeFor` resolves the active --mosaic-home (parent flag) at call time. + */ +export function registerFleetBacklogCommand( + fleetCmd: Command, + mosaicHomeFor: () => string, +): Command { + const backlogCmd = fleetCmd + .command('backlog') + .description('Mosaic-native backlog of record (atomic claim + TTL, deps DAG)'); + + const withSvc = async (fn: (svc: BacklogService) => Promise): Promise => { + const handle = await openBacklogDb(mosaicHomeFor()); + try { + return await fn(new BacklogService(handle.db)); + } finally { + await handle.close(); + } + }; + + backlogCmd + .command('create') + .description('Create a backlog card (idempotency_key dedups)') + .requiredOption('--id ', 'Stable card id') + .requiredOption('--title ', 'Card title') + .option('--body <body>', 'Card body / description') + .option('--phase <phase>', 'Board/phase grouping') + .option('--priority <n>', 'Priority (higher = sooner)', (v) => parseInt(v, 10), 0) + .option('--depends-on <ids>', 'Comma-separated dependency card ids') + .option('--acceptance <json>', 'Acceptance criteria (JSON or ;/newline list)') + .option('--idempotency-key <key>', 'Dedup key; repeat returns the existing card') + .option('--json', 'Print JSON') + .action( + async (opts: { + id: string; + title: string; + body?: string; + phase?: string; + priority: number; + dependsOn?: string; + acceptance?: string; + idempotencyKey?: string; + json?: boolean; + }) => { + const card = await withSvc((svc) => + svc.create({ + id: opts.id, + title: opts.title, + body: opts.body ?? null, + phase: opts.phase ?? null, + priority: opts.priority, + dependsOn: parseDependsOn(opts.dependsOn), + acceptance: parseAcceptance(opts.acceptance), + idempotencyKey: opts.idempotencyKey ?? null, + }), + ); + printCard(card, opts.json); + }, + ); + + backlogCmd + .command('list') + .description('List cards (filters: --status, --phase, --ready-only)') + .option('--status <status>', 'Filter by status: ready|claimed|blocked|done') + .option('--phase <phase>', 'Filter by phase') + .option('--ready-only', 'Only cards that are ready AND have all deps done') + .option('--json', 'Print JSON') + .action( + async (opts: { + status?: BacklogCard['status']; + phase?: string; + readyOnly?: boolean; + json?: boolean; + }) => { + const cards = await withSvc((svc) => + svc.list({ + ...(opts.status ? { status: opts.status } : {}), + ...(opts.phase ? { phase: opts.phase } : {}), + ...(opts.readyOnly ? { readyOnly: true } : {}), + }), + ); + printCards(cards, opts.json); + }, + ); + + backlogCmd + .command('claim') + .description('Atomically claim the highest-priority ready card (FOR UPDATE SKIP LOCKED)') + .requiredOption('--owner <owner>', 'Claim owner (worker/agent id)') + .option( + '--ttl <sec>', + 'Claim TTL in seconds', + (v) => parseInt(v, 10), + DEFAULT_CLAIM_TTL_SECONDS, + ) + .option('--id <id>', 'Claim a specific card by id') + .option('--json', 'Print JSON') + .action(async (opts: { owner: string; ttl: number; id?: string; json?: boolean }) => { + const card = await withSvc((svc) => + svc.claim({ owner: opts.owner, ttlSeconds: opts.ttl, ...(opts.id ? { id: opts.id } : {}) }), + ); + printCard(card, opts.json); + if (!card && !opts.json) process.exitCode = 0; + }); + + backlogCmd + .command('reclaim') + .description('Release expired claims back to ready (or a specific --id)') + .option('--id <id>', 'Release a specific card regardless of expiry') + .option('--json', 'Print JSON') + .action(async (opts: { id?: string; json?: boolean }) => { + const result = await withSvc((svc) => svc.reclaim(opts.id ? { id: opts.id } : {})); + if (opts.json) { + console.log(JSON.stringify(result)); + } else if (result.reclaimed.length === 0) { + console.log('(nothing to reclaim)'); + } else { + console.log(`reclaimed: ${result.reclaimed.join(', ')}`); + } + }); + + backlogCmd + .command('link') + .description('Add a depends_on edge (--from depends on --to)') + .requiredOption('--from <id>', 'Card that gains the dependency') + .requiredOption('--to <id>', 'Card it now depends on') + .option('--json', 'Print JSON') + .action(async (opts: { from: string; to: string; json?: boolean }) => { + const card = await withSvc((svc) => svc.link(opts.from, opts.to)); + printCard(card, opts.json); + }); + + backlogCmd + .command('stats') + .description('Counts by status, oldest-ready age, expired-claim count') + .option('--json', 'Print JSON') + .action(async (opts: { json?: boolean }) => { + const stats = await withSvc((svc) => svc.stats()); + if (opts.json) { + console.log(JSON.stringify(stats)); + return; + } + console.log(`total: ${stats.total}`); + console.log( + `ready=${stats.counts.ready} claimed=${stats.counts.claimed} ` + + `blocked=${stats.counts.blocked} done=${stats.counts.done}`, + ); + console.log(`oldest-ready-age: ${stats.oldestReadyAgeSeconds ?? '-'}s`); + console.log(`expired-claims: ${stats.expiredClaimCount}`); + }); + + backlogCmd + .command('block') + .description('Mark a card blocked') + .requiredOption('--id <id>', 'Card id') + .option('--json', 'Print JSON') + .action(async (opts: { id: string; json?: boolean }) => { + const card = await withSvc((svc) => svc.block(opts.id)); + printCard(card, opts.json); + }); + + backlogCmd + .command('complete') + .description('Mark a card done') + .requiredOption('--id <id>', 'Card id') + .option('--json', 'Print JSON') + .action(async (opts: { id: string; json?: boolean }) => { + const card = await withSvc((svc) => svc.complete(opts.id)); + printCard(card, opts.json); + }); + + return backlogCmd; +} diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index be3b085..ebd3781 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -78,6 +78,7 @@ describe('registerFleetCommand', () => { expect(fleet).toBeDefined(); expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([ 'add', + 'backlog', 'init', 'install', 'install-systemd', @@ -91,6 +92,24 @@ describe('registerFleetCommand', () => { ]); }); + it('registers the backlog subcommand with its operations', () => { + const program = buildProgram(); + const fleet = program.commands.find((command) => command.name() === 'fleet'); + const backlog = fleet!.commands.find((command) => command.name() === 'backlog'); + + expect(backlog).toBeDefined(); + expect(backlog!.commands.map((command) => command.name()).sort()).toEqual([ + 'block', + 'claim', + 'complete', + 'create', + 'link', + 'list', + 'reclaim', + 'stats', + ]); + }); + it('adds fleet-backed agent subcommands without removing existing options', () => { const program = buildProgram(); const agent = program.commands.find((command) => command.name() === 'agent'); diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index 8df4b90..58701d8 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -8,6 +8,7 @@ import * as readline from 'node:readline'; import type { Command } from 'commander'; import YAML from 'yaml'; import { resolveCommsBlock } from '../fleet/comms-onboarding.js'; +import { registerFleetBacklogCommand } from './fleet-backlog.js'; /** * A function that spawns a command with inherited stdio (TTY passthrough). @@ -1700,6 +1701,11 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = console.log(`Removed ${name} from the fleet.`); }); + // Mosaic-native backlog of record (card A4). Resolves the active --mosaic-home + // (parent flag) at call time so the embedded PGlite store lands under the same + // fleet/ directory as the roster and heartbeats. + registerFleetBacklogCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome); + return cmd; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6132a4d..bf89e42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -540,6 +540,9 @@ importers: '@mosaicstack/config': specifier: workspace:* version: link:../config + '@mosaicstack/db': + specifier: workspace:* + version: link:../db '@mosaicstack/forge': specifier: workspace:* version: link:../forge