From e25638067195860fe2c9664c3fe432c47e52cb11 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 7 Jul 2026 13:55:03 -0500 Subject: [PATCH 1/7] plan(automation): add mission-control foundation backlog and model-routing ADR Documents the automation foundation plan (nine epics, Epic 1 taskified) and the accepted model-provider registry contract. Updates .env.example for backlog import workspace id and local/cloud AI provider env guidance. Co-authored-by: Cursor --- .env.example | 24 +- docs/adr-model-routing.md | 362 ++++++++++++++++++ .../Epic-apps-orchestrator.md | 59 +++ .../Epic-apps-worker-v1.md | 49 +++ .../Epic-connected-repos.md | 48 +++ .../Epic-convoy-lifecycle-mcp.md | 52 +++ .../Epic-fleet-dashboard.md | 42 ++ .../Epic-forge-webhooks.md | 39 ++ .../Epic-mission-control-gates-ui.md | 49 +++ .../Epic-model-provider-registry.md | 88 +++++ .../Task-agent-runs-model-logging.md | 77 ++++ .../Task-db-model-providers-migration.md | 60 +++ .../Task-settings-ai-providers-ui.md | 63 +++ .../Task-settings-ai-role-routing-ui.md | 61 +++ .../Task-shared-schemas-resolve-model.md | 65 ++++ .../Task-trpc-models-router.md | 66 ++++ .../Epic-pipeline-convoy-scaffolding.md | 46 +++ .../Plan-automation-foundation.md | 228 +++++++++++ 18 files changed, 1476 insertions(+), 2 deletions(-) create mode 100644 docs/adr-model-routing.md create mode 100644 plans/Plan-automation-foundation/Epic-apps-orchestrator/Epic-apps-orchestrator.md create mode 100644 plans/Plan-automation-foundation/Epic-apps-worker-v1/Epic-apps-worker-v1.md create mode 100644 plans/Plan-automation-foundation/Epic-connected-repos/Epic-connected-repos.md create mode 100644 plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md create mode 100644 plans/Plan-automation-foundation/Epic-fleet-dashboard/Epic-fleet-dashboard.md create mode 100644 plans/Plan-automation-foundation/Epic-forge-webhooks/Epic-forge-webhooks.md create mode 100644 plans/Plan-automation-foundation/Epic-mission-control-gates-ui/Epic-mission-control-gates-ui.md create mode 100644 plans/Plan-automation-foundation/Epic-model-provider-registry/Epic-model-provider-registry.md create mode 100644 plans/Plan-automation-foundation/Epic-model-provider-registry/Task-agent-runs-model-logging.md create mode 100644 plans/Plan-automation-foundation/Epic-model-provider-registry/Task-db-model-providers-migration.md create mode 100644 plans/Plan-automation-foundation/Epic-model-provider-registry/Task-settings-ai-providers-ui.md create mode 100644 plans/Plan-automation-foundation/Epic-model-provider-registry/Task-settings-ai-role-routing-ui.md create mode 100644 plans/Plan-automation-foundation/Epic-model-provider-registry/Task-shared-schemas-resolve-model.md create mode 100644 plans/Plan-automation-foundation/Epic-model-provider-registry/Task-trpc-models-router.md create mode 100644 plans/Plan-automation-foundation/Epic-pipeline-convoy-scaffolding/Epic-pipeline-convoy-scaffolding.md create mode 100644 plans/Plan-automation-foundation/Plan-automation-foundation.md diff --git a/.env.example b/.env.example index 10a70c3..bfce677 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,12 @@ # Production (Coolify, CT 102): dedicated `echodo` user/db on shared Postgres DATABASE_URL="postgresql://echodo:CHANGE_ME@192.168.68.102:5432/echodo" +# UUID of the workspace that owns imported `plans/` rows (slug or UUID from Settings). +# For dogfooding this repo, use the `convoys-tasks` workspace id. +# Required by `pnpm --filter @tasks/database import:markdown-backlog` and +# `watch:markdown-backlog` (not loaded automatically — set in `.env`). +MARKDOWN_BACKLOG_WORKSPACE_ID="00000000-0000-0000-0000-000000000000" + # ----- Redis (CT 102 shared Redis) ----- # Use a dedicated DB index per AGENT-DEPLOY.md (ECHODO uses /11) # Local dev: redis://localhost:6379 @@ -70,11 +76,25 @@ NEXT_PUBLIC_DIRECTUS_URL="https://directus.stillwell.cloud" DIRECTUS_FEEDBACK_TOKEN="" # ----- AI Providers ----- -# Use OPENAI_BASE_URL=http://192.168.68.108:11434/v1 to route through -# local Ollama (CT 108) instead of the OpenAI API. +# Registry aliases (Epic 1) read these env var NAMES from the DB — values +# live here on the server/worker host, never in Postgres. +# +# Local Ollama on Mac: +# OPENAI_BASE_URL=http://:11434/v1 +# OPENAI_API_KEY=ollama +# OPENAI_MODEL=qwen2.5-coder:32b +# +# Local MLX (mlx-openai-server on Mac — preferred for parallel implementers): +# MLX_SERVER_URL=http://:8000/v1 +# MLX_API_KEY=local +# (Register as openai_compatible alias qwen-coder-mlx in Settings when Epic 1 ships) +# +# Cloud / free-tier (see docs/adr-model-routing.md): OPENAI_API_KEY="" OPENAI_BASE_URL="" ANTHROPIC_API_KEY="" +GOOGLE_GENERATIVE_AI_API_KEY="" +NVIDIA_API_KEY="" # ----- MCP Server ----- MCP_SERVER_PORT=3001 diff --git a/docs/adr-model-routing.md b/docs/adr-model-routing.md new file mode 100644 index 0000000..2b24663 --- /dev/null +++ b/docs/adr-model-routing.md @@ -0,0 +1,362 @@ +# ADR: Model routing and provider registry + +**Status:** Accepted (draft implementation) +**Date:** 2026-07-03 +**Context:** Echodo automation foundation — orchestrator, worker, and mission-control UI without Cursor as the runtime substrate. + +## Decision + +Echodo will own a **workspace-scoped model provider registry** and an **extensible role → model routing table**. Every automated pipeline run resolves a concrete provider configuration before inference. Resolution is deterministic, logged on every run, and configurable from Settings without redeploying orchestrator or worker code. + +## Problem + +The agent-pipeline defines per-role models via: + +- `model:` frontmatter in `.cursor/agents/role-*.md` (Cursor-only enforcement) +- `model_policy.roles` in convoy frontmatter (recommendation only) +- `recommended_model` on implementer briefs (optional override) + +That works for manual Cursor sessions. It does **not** work when: + +- An orchestrator dispatches `role-implementer` unattended +- Planning uses Claude API and implementation uses local Ollama on a Mac +- Operators add providers (OpenAI, Gemini, NVIDIA NIM, LiteLLM gateway) without code changes +- Custom roles (e.g. `gate-copilot`, `role-security-auditor`) need routing + +We need one contract that web chat, orchestrator, worker, and MCP logging all share. + +## Goals + +1. **Per-role model selection** — conductor/architect on cloud; implementer/auditors on local coder models. +2. **Extensible providers** — Anthropic, OpenAI, Google (Gemini), OpenAI-compatible (Ollama, LiteLLM, NVIDIA NIM, Azure OpenAI). +3. **Extensible roles** — seed pipeline’s nine L2 roles; allow arbitrary `roleId` rows. +4. **Layered overrides** — workspace defaults → convoy `model_policy` → brief `recommended_model`. +5. **No secrets in DB** — store `apiKeyEnvVar` names; values live in deploy env (Coolify/Dokploy/Mac). +6. **Observable** — every `agent_runs` / `convoy_events` row records alias, provider type, model id, tier. + +## Non-goals (this ADR) + +- Implementing the orchestrator or worker (separate epics). +- LiteLLM as a required dependency (optional alias pointing at a gateway URL). +- Storing API keys in Postgres or committed YAML. +- Replacing `docs/model-routing-policy.md` tier guidance — this ADR **implements** it in Echodo. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Settings → AI → Providers (ModelProviderAlias CRUD) │ +│ Settings → AI → Role routing (RoleModelRoute CRUD) │ +└────────────────────────────┬────────────────────────────────┘ + │ + resolveModel(workspaceId, roleId, ctx?) + │ + ┌───────────────────┼───────────────────┐ + ▼ ▼ ▼ + apps/web apps/orchestrator apps/worker + (chat, gate (dispatch job (inference + co-pilot) with ModelRef) only) +``` + +### Resolution order (first match wins) + +1. **Brief override** — `recommended_model` alias on `.convoys//brief-N-*.md` +2. **Convoy override** — `model_policy.roles[roleId]` in `.convoys/.md` +3. **Workspace route** — `role_model_routes` row for `(workspace_id, role_id)` +4. **Pipeline template** — shipped defaults (nine L2 roles + `gate-copilot`, `chat-default`) + +If the alias is missing or disabled, fail closed with a structured error — do not silently fall back to a hardcoded model. + +### Escalation + +When `model_policy.escalate_to` is set (convoy) or workspace policy defines escalation: + +- After N failed implementer attempts (default: 2), orchestrator re-dispatches the same brief with the escalation alias. +- Log `model_tier` change on the new run. + +## Data model + +### `model_providers` (per workspace) + +| Column | Type | Notes | +|--------|------|-------| +| `id` | uuid PK | | +| `workspace_id` | uuid FK | Required; all queries filter by this | +| `alias` | varchar | Unique per workspace, e.g. `claude-planning` | +| `display_name` | varchar | UI label | +| `provider_type` | enum | See below | +| `model_id` | varchar | Provider-native model string | +| `base_url` | text nullable | Required for `openai_compatible` | +| `api_key_env_var` | varchar nullable | e.g. `ANTHROPIC_API_KEY` | +| `extra_headers` | jsonb nullable | Optional HTTP headers | +| `default_params` | jsonb nullable | `temperature`, `maxTokens`, … | +| `metadata` | jsonb nullable | `tier`, cost hints for dashboard | +| `enabled` | boolean | Default true | +| `created_at` / `updated_at` | timestamptz | | + +**`provider_type` enum:** + +| Value | Client | Examples | +|-------|--------|----------| +| `anthropic` | `@ai-sdk/anthropic` | Claude API | +| `openai` | `@ai-sdk/openai` | OpenAI API | +| `google` | `@ai-sdk/google` | Gemini | +| `openai_compatible` | `@ai-sdk/openai` + `baseURL` | Ollama, LiteLLM, **NVIDIA NIM**, vLLM | +| `azure_openai` | `@ai-sdk/openai` + Azure config | Azure deployments | + +Future: `custom` with a registered adapter — not in v1. + +### `role_model_routes` (per workspace) + +| Column | Type | Notes | +|--------|------|-------| +| `id` | uuid PK | | +| `workspace_id` | uuid FK | | +| `role_id` | varchar | e.g. `role-implementer`, `gate-copilot` | +| `provider_alias_id` | uuid FK → `model_providers.id` | | +| `enabled` | boolean | | +| `sort_order` | int | UI ordering | +| `created_at` / `updated_at` | timestamptz | | + +Unique constraint: `(workspace_id, role_id)`. + +`role_id` is a **string**, not a DB enum — new pipeline roles do not require migrations. + +### Resolved config (runtime, not persisted) + +```typescript +type ResolvedModel = { + alias: string; + providerType: ProviderType; + modelId: string; + baseUrl?: string; + apiKey: string; // read from process.env[apiKeyEnvVar] at call time + extraHeaders?: Record; + defaultParams?: { temperature?: number; maxTokens?: number }; + tier?: "premium" | "fast" | "local" | "auto"; +}; +``` + +Pure function: `resolveModel(input, db)` lives in `packages/shared` (types + zod) with implementation in `packages/ai` or `packages/shared` (no React, no DB imports in shared — DB fetch in tRPC/orchestrator, pass rows into resolver). + +## Provider examples + +### Local Ollama (Mac) + +```yaml +alias: qwen-coder +provider_type: openai_compatible +model_id: qwen2.5-coder:32b +base_url: http://192.168.1.42:11434/v1 +api_key_env_var: OLLAMA_API_KEY # any non-empty string Ollama accepts +metadata: { tier: local } +``` + +### Local MLX on Apple Silicon (Mac) + +[MLX](https://github.com/ml-explore/mlx) is Apple's on-device ML framework. Echodo does **not** talk to MLX directly — use an **OpenAI-compatible HTTP server** in front of MLX, same `provider_type: openai_compatible` as Ollama. + +| Server | Package | Default port | When to use | +|--------|---------|--------------|-------------| +| **mlx_lm.server** | `pip install mlx-lm` | 8080 | Solo dev, single implementer, simplest path | +| **mlx-openai-server** | [cubist38/mlx-openai-server](https://github.com/cubist38/mlx-openai-server) | 8000 | **Parallel implementer fan-out** — continuous batching handles concurrent requests better than the reference server | +| mlx-lm-server (community) | [0xDaizz/mlx-lm-server](https://github.com/0xDaizz/mlx-lm-server) | 8000 | Alternative if mlx-openai-server doesn't fit | + +**Parallel implementers note:** When the orchestrator dispatches multiple `role-implementer` jobs at once (disjoint briefs per agent-pipeline `slice_dependencies`), a single local inference process can become the bottleneck. Prefer **mlx-openai-server** (or mlx-lm-server with batching) over the minimal `mlx_lm.server` when running 2+ implementers against the same Mac. Ollama can also serve parallel requests but MLX-native models (`mlx-community/*` on Hugging Face) often perform better on Apple Silicon unified memory. You may run **both** Ollama (`:11434`) and an MLX server (`:8000` / `:8080`) as separate registry aliases and route roles accordingly. + +```yaml +alias: qwen-coder-mlx +provider_type: openai_compatible +model_id: mlx-community/Qwen2.5-Coder-32B-Instruct-4bit +base_url: http://192.168.1.42:8000/v1 # mlx-openai-server; use :8080/v1 for mlx_lm.server +api_key_env_var: MLX_API_KEY # placeholder; local servers ignore it +metadata: { tier: local, runtime: mlx } +``` + +**Operator setup (mlx-openai-server example):** + +```bash +# Machine-wide install (all repos): ~/mlx-inference/mlx-serve.sh +# See ~/mlx-inference/README.md +~/mlx-inference/mlx-serve.sh + +# Or manual: +pip install mlx-lm mlx-openai-server +mlx-openai-server launch \ + --model-path mlx-community/Qwen2.5-Coder-32B-Instruct-4bit \ + --model-type lm \ + --host 0.0.0.0 \ + --port 8000 +``` + +**Networking:** Bind `0.0.0.0` when Echodo (Coolify) or `apps/worker` on another host must reach the Mac over LAN. Restrict firewall to homelab subnet. Do not expose raw local inference to the public internet. + +**v1 registry:** No `provider_type: mlx` — HTTP gateway only. Native MLX SDK in `apps/worker` is out of scope. + + +```yaml +alias: claude-planning +provider_type: anthropic +model_id: claude-sonnet-4-20250514 +api_key_env_var: ANTHROPIC_API_KEY +metadata: { tier: premium } +``` + +### NVIDIA NIM + +```yaml +alias: nim-llama-70b +provider_type: openai_compatible +model_id: meta/llama-3.1-70b-instruct +base_url: https://integrate.api.nvidia.com/v1 +api_key_env_var: NVIDIA_API_KEY +``` + +### Gemini + +```yaml +alias: gemini-flash +provider_type: google +model_id: gemini-2.0-flash +api_key_env_var: GOOGLE_GENERATIVE_AI_API_KEY +``` + +### LiteLLM gateway (optional) + +```yaml +alias: litellm-gateway +provider_type: openai_compatible +model_id: qwen2.5-coder:32b # or per-role model_id in route table +base_url: http://host:4000/v1 +api_key_env_var: LITELLM_MASTER_KEY +``` + +## Default role routing (seed template) + +Shipped when a workspace enables pipeline integration: + +| role_id | Suggested alias | tier | +|---------|-----------------|------| +| `role-conductor` | `claude-planning` | premium | +| `role-architect` | `claude-planning` | premium | +| `role-ia-architect` | `claude-planning` | premium | +| `role-ux-reviewer` | `qwen-coder` or `qwen-coder-mlx` | fast/local | +| `role-implementer` | `qwen-coder` or `qwen-coder-mlx` | fast/local | +| `role-reviewer` | `qwen-coder` or `qwen-coder-mlx` | fast/local | +| `role-design-system-auditor` | `qwen-coder` or `qwen-coder-mlx` | fast/local | +| `role-a11y-auditor` | `qwen-coder` or `qwen-coder-mlx` | fast/local | +| `role-doc-writer` | `qwen-coder` or `qwen-coder-mlx` | fast/local | +| `gate-copilot` | `claude-planning` | premium | +| `chat-default` | workspace choice | — | + +Operators remap aliases in Settings; seed is not immutable. + +## Convoy / brief integration + +Convoy frontmatter uses **registry aliases**, not Cursor model slugs: + +```yaml +model_policy: + roles: + role-conductor: claude-planning + role-architect: claude-planning + role-implementer: qwen-coder + escalate_to: claude-planning +``` + +Brief frontmatter: + +```yaml +recommended_model: qwen-coder +model_tier: fast +``` + +Importer/parser for convoy files is out of scope for the first epic; orchestrator reads frontmatter when dispatching. + +## API surface (tRPC) + +Router: `ai.models` (or `pipeline.models`) + +| Procedure | Purpose | +|-----------|---------| +| `listProviders` | Paginated; workspace-scoped | +| `createProvider` / `updateProvider` / `deleteProvider` | Admin-only | +| `testProvider` | One-token completion; returns latency + error detail | +| `listRoleRoutes` | All routes for workspace | +| `upsertRoleRoute` | Set role → alias; supports custom `roleId` | +| `deleteRoleRoute` | Remove custom role | +| `resolveForRole` | Debug: given `roleId` + optional convoy/brief context | + +## UI + +- `/{workspaceSlug}/settings/ai/providers` — provider table + add/edit dialog + test button +- `/{workspaceSlug}/settings/ai/role-routing` — role → alias table + add custom role +- Link from convoy/gate screens: “Model for this step: `qwen-coder` (change)” + +## Logging contract + +Extend `agent_runs` (and future `convoy_events`) with: + +- `model_alias` varchar nullable +- `provider_type` varchar nullable +- `model_id` varchar nullable +- `model_tier` varchar nullable + +`complete_task` MCP tool accepts optional `modelAlias` / `modelTier` for close-out logging. + +## Security + +- Provider CRUD: workspace admin only (same bar as `updateWorkflowPrompt`). +- `testProvider` runs server-side; rate-limit per workspace. +- Never return resolved API keys to the browser — only alias and env var **name**. +- Worker/orchestrator processes must have env vars for aliases they use. + +## Relationship to other ADRs / plans + +| Artifact | Relationship | +|----------|----------------| +| `docs/model-routing-policy.md` | Tier guidance; Cursor-era slugs migrate to aliases | +| `plans/Plan-agent-coordination` | `claim_task` / `complete_task` log model fields | +| `plans/Plan-automation-foundation` | Implements this ADR (model registry epic) | +| Future orchestrator epic | Calls `resolveModel` on every dispatch | +| Future worker epic | Consumes `ResolvedModel` only | + +## Migration from Cursor slugs + +Legacy convoys may reference `claude-4.6-opus-high-thinking` or `composer-2.5-fast`. Optional alias map in workspace metadata: + +```json +{ + "legacy_model_aliases": { + "claude-4.6-opus-high-thinking": "claude-planning", + "composer-2.5-fast": "qwen-coder" + } +} +``` + +## Consequences + +**Positive** + +- Single place to change Claude vs Qwen vs NIM without code deploys. +- Orchestrator and worker stay provider-agnostic. +- Extensible roles support pipeline evolution. + +**Negative** + +- Operators must configure providers before automation runs (mitigated: test button + clear errors). +- Two sources of truth for convoy overrides (markdown + DB) until convoy lifecycle MCP lands — resolution order is explicit. + +## Open questions + +1. **Repo-level `echodo.models.yml`** — mirror workspace config in git for bootstrap? Deferred; DB is v1 source. +2. **Per-user overrides** — deferred; workspace-level only for v1. +3. **Budget caps per alias** — defer to LiteLLM or Phase 4 workers. + +## References + +- Agent-pipeline: `docs/model-routing-policy.md`, `docs/role-reference.md` +- Agent-pipeline v0.4 plan: convoy lifecycle + Echodo integration +- Vercel AI SDK provider packages in `packages/ai` +- MLX: [ml-explore/mlx](https://github.com/ml-explore/mlx), [mlx-lm SERVER.md](https://github.com/ml-explore/mlx-lm/blob/main/mlx_lm/SERVER.md), [mlx-openai-server](https://github.com/cubist38/mlx-openai-server) +- Free-tier discovery: [cheahjs/free-llm-api-resources](https://github.com/cheahjs/free-llm-api-resources), [mnfst/awesome-free-llm-apis](https://github.com/mnfst/awesome-free-llm-apis) diff --git a/plans/Plan-automation-foundation/Epic-apps-orchestrator/Epic-apps-orchestrator.md b/plans/Plan-automation-foundation/Epic-apps-orchestrator/Epic-apps-orchestrator.md new file mode 100644 index 0000000..8f1b64e --- /dev/null +++ b/plans/Plan-automation-foundation/Epic-apps-orchestrator/Epic-apps-orchestrator.md @@ -0,0 +1,59 @@ +--- +kind: epic +slug: apps-orchestrator +title: apps/orchestrator — role dispatch and gate pauses +plan_slug: automation-foundation +status: draft +priority: P1 +tenant_id: global +cursor_epic_id: null +updated_at: "2026-07-03" +--- + +# Epic objective + +Long-running **`apps/orchestrator`** service: Redis job queue, convoy state machine driver, role dispatcher. Inspired by Symphony **concepts** (stall detection, token logging) — **not** Symphony codebase. + +On **Go**: enqueue planning roles serially (ia → ux → architect), stop at `convoy_review`; after Gate 1, dispatch implementer jobs (parallel when `slice_dependencies` allow); fan-out audit jobs; stop at `pr_review` and post-merge gates. + +**Parallel implementer fan-out:** When multiple implementer jobs target the same Mac inference host, orchestrator should pass the same `qwen-coder-mlx` alias to each job; operator must run a batching-capable MLX server (mlx-openai-server) — see `docs/adr-model-routing.md` § Local MLX. Cap concurrent implementers per host in orchestrator config if OOM occurs (agent-pipeline multitask playbook). + +Each job calls `resolveModel(workspace, roleId, convoy, brief)` and passes `ResolvedModel` to worker. + +## In scope / out of scope + +**In scope** + +- `apps/orchestrator` package in monorepo. +- Redis queue (existing infra). +- Deploy on Coolify beside `web`. +- Stall detection → `agent_runs` / convoy `blocked`. + +**Out of scope** + +- Worker implementation (Epic 7). +- Symphony / Linear integration. + +## Dependencies + +- Epic 1 (`resolveModel`). +- Epic 3 (repo link for clone path). +- Epic 4 (transitions). +- Epic 5 (gates — for dogfood; orchestrator can run in dry-run before UI). + +## Blocks + +- Epic 7 (worker consumes jobs). + +## Acceptance criteria + +- [ ] Dry-run: orchestrator logs next role for a convoy without worker. +- [ ] Live: one implementer brief dispatched and paused correctly at gate. + +## Tasks + +_To be decomposed when epic graduates to `ready`._ + +## Note + +Supersedes [Epic-optional-orchestrator](../../Plan-agent-coordination/Epic-optional-orchestrator/Epic-optional-orchestrator.md) deferred stub with a concrete Echodo-native design. diff --git a/plans/Plan-automation-foundation/Epic-apps-worker-v1/Epic-apps-worker-v1.md b/plans/Plan-automation-foundation/Epic-apps-worker-v1/Epic-apps-worker-v1.md new file mode 100644 index 0000000..02346a8 --- /dev/null +++ b/plans/Plan-automation-foundation/Epic-apps-worker-v1/Epic-apps-worker-v1.md @@ -0,0 +1,49 @@ +--- +kind: epic +slug: apps-worker-v1 +title: apps/worker v1 — minimal Echodo coding agent +plan_slug: automation-foundation +status: draft +priority: P1 +tenant_id: global +cursor_epic_id: null +updated_at: "2026-07-03" +--- + +# Epic objective + +First-party **`apps/worker`**: minimal agent runtime in the Echodo monorepo (not OpenHands). Docker sandbox per job, tool loop (`read_file`, `write_file`, `grep`, `run_command` with allowlist), git worktree per brief, MCP `claim_task` / `complete_task`, inference via `ResolvedModel` from orchestrator. + +Role prompt = `.cursor/agents/role-*.md` + brief path. Model endpoint points at operator Mac (**Ollama or MLX server** — see ADR § Local MLX) or cloud APIs per registry. + +**Parallel implementers:** When orchestrator dispatches N implementers to the same Mac inference host, prefer a batching-capable MLX server (**mlx-openai-server**) over `mlx_lm.server` alone. Document chosen server + port in workspace provider alias (`qwen-coder-mlx`). + +## In scope / out of scope + +**In scope** + +- `WorkerSession` adapter interface (for future optional OpenHands plug-in). +- `pnpm lint && pnpm test` in allowlisted commands. +- Branch + PR draft output (forge adapter from Epic 3). + +**Out of scope** + +- Browser automation, multi-agent delegation. +- Feature parity with OpenHands. + +## Dependencies + +- Epic 1, 3, 6. + +## Blocks + +- Epic 8 (meaningful PR webhook dogfood). + +## Acceptance criteria + +- [ ] One brief from `.convoys/` completes unattended on connected repo. +- [ ] `complete_task` includes model alias + token totals. + +## Tasks + +_To be decomposed when epic graduates to `ready`._ diff --git a/plans/Plan-automation-foundation/Epic-connected-repos/Epic-connected-repos.md b/plans/Plan-automation-foundation/Epic-connected-repos/Epic-connected-repos.md new file mode 100644 index 0000000..9e81be2 --- /dev/null +++ b/plans/Plan-automation-foundation/Epic-connected-repos/Epic-connected-repos.md @@ -0,0 +1,48 @@ +--- +kind: epic +slug: connected-repos +title: Connected repos — paste remote URL, forge adapters +plan_slug: automation-foundation +status: draft +priority: P1 +tenant_id: global +cursor_epic_id: null +updated_at: "2026-07-03" +--- + +# Epic objective + +Workspace admins connect git remotes (Forgejo, GitHub, GitLab, Bitbucket, future Origin) via **paste URL** UX. Store `repo_links` with `forge_type`, API base, and credentials reference. Provide a `ForgeAdapter` interface for clone metadata, open PR, and (later) webhooks. + +See automation foundation discussion: Vercel/Coolify-style “show me your repo.” + +## In scope / out of scope + +**In scope** + +- `connected_repos` (or `repo_links`) Drizzle table, workspace-scoped. +- tRPC CRUD + “test clone” (shallow fetch). +- Settings UI: connect / disconnect / list. +- Forge adapter trait + implementations: `forgejo`, `github` (minimum two). + +**Out of scope** + +- Webhook receivers (Epic 8). +- Orchestrator clone logic (Epic 6 consumes this table). + +## Dependencies + +- Workspace admin auth patterns (existing). + +## Blocks + +- Epic 6 (orchestrator), Epic 7 (worker). + +## Acceptance criteria + +- [ ] Admin pastes Forgejo URL; Echodo stores link and detects `forge_type`. +- [ ] Worker/orchestrator can read `git_remote_url` + default branch from API. + +## Tasks + +_To be decomposed when epic graduates to `ready`._ diff --git a/plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md b/plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md new file mode 100644 index 0000000..23c2440 --- /dev/null +++ b/plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md @@ -0,0 +1,52 @@ +--- +kind: epic +slug: convoy-lifecycle-mcp +title: Convoy lifecycle MCP — state machine and convoy_events +plan_slug: automation-foundation +status: draft +priority: P1 +tenant_id: global +cursor_epic_id: null +updated_at: "2026-07-03" +--- + +# Epic objective + +Implement agent-pipeline **Phase 2a** convoy lifecycle in Echodo MCP: `create_convoy`, `create_brief`, `transition_convoy_status`, `log_convoy_event`, `query_manifest_status`, `reconcile_from_files`. + +Server-enforced state machine (draft → convoy_review → ready → in_progress → pr_review → merged → released). Human gate actors (`human-gate-1/2/3`) only for their transitions. + +Reference: agent-pipeline v0.4 plan §7; local-first `.convoys/` with `.pending-mcp-sync.jsonl` outbox. + +## In scope / out of scope + +**In scope** + +- MCP tools in `apps/mcp-server`. +- `convoy_events` table (or extend `agent_runs` pattern). +- Map convoy → Echodo `project` object; brief → `task` child (per v0.4 object mapping). +- Extended status enum documented; UI filters updated in Epic 5. + +**Out of scope** + +- Orchestrator auto-dispatch (Epic 6). +- Deliverable templates (agent-pipeline Phase 2b). + +## Dependencies + +- Epic 2 (convoy file conventions). +- Epic 3 (workspace `convoys-` convention). + +## Blocks + +- Epic 5, 6, 9. + +## Acceptance criteria + +- [ ] Invalid transition `draft → released` rejected with structured error. +- [ ] `role-architect` cannot call `human-gate-1` transition. +- [ ] Offline convoy edit reconciles via `reconcile_from_files`. + +## Tasks + +_To be decomposed when epic graduates to `ready`._ diff --git a/plans/Plan-automation-foundation/Epic-fleet-dashboard/Epic-fleet-dashboard.md b/plans/Plan-automation-foundation/Epic-fleet-dashboard/Epic-fleet-dashboard.md new file mode 100644 index 0000000..e2027e7 --- /dev/null +++ b/plans/Plan-automation-foundation/Epic-fleet-dashboard/Epic-fleet-dashboard.md @@ -0,0 +1,42 @@ +--- +kind: epic +slug: fleet-dashboard +title: Fleet dashboard — all connected repos and convoys +plan_slug: automation-foundation +status: draft +priority: P2 +tenant_id: global +cursor_epic_id: null +updated_at: "2026-07-03" +--- + +# Epic objective + +Top-level **fleet** view: all `convoys-` workspaces (or connected repos) in one screen. Roll up convoys by status, gate inbox count, active `agent_runs`, recent spend by model alias. + +Answers: “What needs me across all projects?” + +## In scope / out of scope + +**In scope** + +- Route `/fleet` or home dashboard for operator account. +- Cross-workspace tRPC (membership-scoped). +- Links into per-repo mission control (Epic 5). + +**Out of scope** + +- Multi-tenant fleet across unrelated customers (operator's own repos only for v1). + +## Dependencies + +- Epic 4 (queryable convoy status). +- Epic 5 (gate semantics). + +## Acceptance criteria + +- [ ] Operator with two connected repos sees combined “3 awaiting approval” badge. + +## Tasks + +_To be decomposed when epic graduates to `ready`._ diff --git a/plans/Plan-automation-foundation/Epic-forge-webhooks/Epic-forge-webhooks.md b/plans/Plan-automation-foundation/Epic-forge-webhooks/Epic-forge-webhooks.md new file mode 100644 index 0000000..86b0ede --- /dev/null +++ b/plans/Plan-automation-foundation/Epic-forge-webhooks/Epic-forge-webhooks.md @@ -0,0 +1,39 @@ +--- +kind: epic +slug: forge-webhooks +title: Forge webhooks — PR and CI events drive convoy transitions +plan_slug: automation-foundation +status: draft +priority: P2 +tenant_id: global +cursor_epic_id: null +updated_at: "2026-07-03" +--- + +# Epic objective + +Webhook receivers on Echodo for connected forges: PR opened → `pr_review`; PR merged → trigger doc-writer job + `merged`; CI failed → optional `blocked` or notify. Supports Forgejo and GitHub adapters minimum. + +## In scope / out of scope + +**In scope** + +- `POST /api/webhooks/forge/:repoLinkId` with signature verification. +- Map events to `transition_convoy_status(actor: automation)`. +- Link PR branch to convoy/brief via branch naming convention or PR body marker. + +**Out of scope** + +- Replacing Coolify deploy webhooks for Gate 3 prod promote (manual or separate integration). + +## Dependencies + +- Epic 3, 4, 7. + +## Acceptance criteria + +- [ ] Merge on Forgejo moves convoy from `pr_review` to `merged` when Gate 2 approved. + +## Tasks + +_To be decomposed when epic graduates to `ready`._ diff --git a/plans/Plan-automation-foundation/Epic-mission-control-gates-ui/Epic-mission-control-gates-ui.md b/plans/Plan-automation-foundation/Epic-mission-control-gates-ui/Epic-mission-control-gates-ui.md new file mode 100644 index 0000000..34644e9 --- /dev/null +++ b/plans/Plan-automation-foundation/Epic-mission-control-gates-ui/Epic-mission-control-gates-ui.md @@ -0,0 +1,49 @@ +--- +kind: epic +slug: mission-control-gates-ui +title: Mission control — convoy kanban, gate inbox, co-pilot +plan_slug: automation-foundation +status: draft +priority: P1 +tenant_id: global +cursor_epic_id: null +updated_at: "2026-07-03" +--- + +# Epic objective + +Echodo UI for human gates and convoy visibility: + +- **Convoy kanban** — columns map to state machine statuses (Planning, Awaiting plan approval, Building, …). +- **Gate inbox** — “needs you” queue for `convoy_review`, `pr_review`, pre-`released`. +- **Approve / deny / request changes** — calls `transition_convoy_status` with correct `human-gate-*` actor. +- **Gate co-pilot** — AI panel using `gate-copilot` role route (`resolveModel`) to summarize plan/diff and suggest action. + +## In scope / out of scope + +**In scope** + +- Routes under `/{workspaceSlug}/convoys/` or `/mission-control/`. +- Conductor-style chat to seed convoys (can call `create_convoy` MCP or web tRPC wrapper). + +**Out of scope** + +- Fleet cross-repo view (Epic 9). +- Unattended orchestration (Epic 6). + +## Dependencies + +- Epic 4 (lifecycle transitions and queryable convoy state). + +## Blocks + +- Epic 6 (orchestrator pauses until gate UI exists for dogfood). + +## Acceptance criteria + +- [ ] Operator sees pending Gate 1 convoy and approves → status `ready`. +- [ ] Co-pilot uses `claude-planning` alias when configured. + +## Tasks + +_To be decomposed when epic graduates to `ready`._ diff --git a/plans/Plan-automation-foundation/Epic-model-provider-registry/Epic-model-provider-registry.md b/plans/Plan-automation-foundation/Epic-model-provider-registry/Epic-model-provider-registry.md new file mode 100644 index 0000000..3085c46 --- /dev/null +++ b/plans/Plan-automation-foundation/Epic-model-provider-registry/Epic-model-provider-registry.md @@ -0,0 +1,88 @@ +--- +kind: epic +slug: model-provider-registry +title: Model provider registry and per-role routing +plan_slug: automation-foundation +status: ready +priority: P1 +tenant_id: global +cursor_epic_id: null +updated_at: "2026-07-03" +agent_prompt: | + Epic: model provider registry (docs/adr-model-routing.md). Server-only change — + no UI component library inventing; reuse components/ui/. Admin-only mutations. + Filter every DB query by workspace_id. One task per session; claim_task first. +--- + +# Epic objective + +Deliver the workspace-scoped **model provider registry** and **role → alias routing** described in [docs/adr-model-routing.md](../../../docs/adr-model-routing.md): Drizzle schema, zod types in `packages/shared`, `resolveModel()`, tRPC CRUD + `testProvider`, Settings UI, and `agent_runs` model metadata on completion. + +This epic is the first shipped piece of the automation-foundation plan and unblocks future orchestrator/worker work. + +## In scope / out of scope + +**In scope** + +- Tables `model_providers`, `role_model_routes`. +- Pure resolver + unit tests. +- tRPC router (workspace admin). +- Settings pages: Providers, Role routing. +- Seed/default routes for nine L2 roles + `gate-copilot` + `chat-default`. +- `complete_task` optional model logging fields. +- `.env.example` env var documentation. + +**Out of scope** + +- Orchestrator dispatch (consumes resolver later). +- Worker inference loop. +- Parsing `model_policy` from `.convoys/*.md` files (orchestrator reads later). +- Billing/budget enforcement. + +## Related tasks + +| Task | Link | +|------|------| +| Shared schemas and resolveModel | `./Task-shared-schemas-resolve-model.md` | +| DB schema and migration | `./Task-db-model-providers-migration.md` | +| tRPC models router | `./Task-trpc-models-router.md` | +| Settings — providers UI | `./Task-settings-ai-providers-ui.md` | +| Settings — role routing UI | `./Task-settings-role-routing-ui.md` | +| agent_runs model fields + MCP | `./Task-agent-runs-model-logging.md` | + +## Dependencies + +- Depends on: existing auth + workspace admin patterns in `apps/web`. +- Blocks: orchestrator epic (needs `resolveModel`), worker epic (needs `ResolvedModel`). + +## Acceptance criteria + +- [ ] Admin can create `claude-planning` (anthropic) and `qwen-coder` (openai_compatible + base URL) in a workspace. +- [ ] Admin can map `role-implementer` → `qwen-coder`, `role-architect` → `claude-planning`. +- [ ] `testProvider` returns success/failure without exposing API keys. +- [ ] `resolveModel()` unit tests cover override order and missing-alias error. +- [ ] At least one task in this epic completed via `claim_task` → `complete_task` with model metadata. + +## Proposed timeline + +| Phase | Window | Notes | +|-------|--------|-------| +| Schema + resolver | Session 1–2 | Tasks: shared schemas, migration | +| API | Session 3 | tRPC router | +| UI | Session 4–5 | Both settings pages | +| Logging | Session 6 | agent_runs + MCP | + +## Pipeline / convoy mapping + +When running via agent-pipeline, architect briefs should align to tasks: + +| Brief # | Task slug | +|---------|-----------| +| 1 | `shared-schemas-resolve-model` | +| 2 | `db-model-providers-migration` | +| 3 | `trpc-models-router` | +| 4 | `settings-ai-providers-ui` | +| 5 | `settings-role-routing-ui` | +| 6 | `agent-runs-model-logging` | + +Briefs 1–2 serial (schema depends on types); 4–5 can fan out after brief 3 if `files:` are disjoint. diff --git a/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-agent-runs-model-logging.md b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-agent-runs-model-logging.md new file mode 100644 index 0000000..e682db8 --- /dev/null +++ b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-agent-runs-model-logging.md @@ -0,0 +1,77 @@ +--- +kind: task +slug: agent-runs-model-logging +title: agent_runs model metadata and complete_task logging fields +plan_slug: automation-foundation +epic_slug: model-provider-registry +status: ready +priority: P1 +tenant_id: global +owner: unassigned +cursor_todo_id: null +updated_at: "2026-07-03" +agent_prompt: | + Read docs/adr-model-routing.md. Extend agent_runs schema + complete_task MCP + + runs UI columns. Migration via db:generate. claim_task before work; + complete_task when done — include modelAlias on your own completion as + dogfood proof. +--- + +# Task summary + +Persist model routing metadata on `agent_runs` and accept optional model fields on `complete_task` so dogfooded pipeline sessions record which alias was used. + +## Description + +### DB (`agent_runs`) + +Add nullable columns: + +- `model_alias` varchar +- `provider_type` varchar +- `model_id` varchar +- `model_tier` varchar + +Migration via `pnpm db:generate`. + +### MCP `complete_task` + +Extend zod input (optional): + +- `modelAlias?: string` +- `modelTier?: string` +- `providerType?: string` +- `modelId?: string` + +Write to `agent_runs` on close. Document in tool description. + +### Runs UI + +`apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx` — show model alias column in table and expanded row detail. + +### `.env.example` + +Add commented entries: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_GENERATIVE_AI_API_KEY`, `NVIDIA_API_KEY`, `OLLAMA_API_KEY`, `OPENAI_BASE_URL` with note pointing to ADR. + +## Subtasks + +- [ ] Schema + migration +- [ ] complete_task schema + handler +- [ ] Runs UI column +- [ ] .env.example update + +## Estimation + +S + +## Acceptance criteria + +- [ ] Completing a run with `modelAlias: "qwen-coder"` persists and displays in runs UI +- [ ] Existing complete_task callers unchanged (fields optional) +- [ ] ADR logging contract satisfied + +## Links + +- Epic: `./Epic-model-provider-registry.md` +- Plan: `../Plan-automation-foundation.md` +- Related: `plans/Plan-agent-coordination/` diff --git a/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-db-model-providers-migration.md b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-db-model-providers-migration.md new file mode 100644 index 0000000..49d761d --- /dev/null +++ b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-db-model-providers-migration.md @@ -0,0 +1,60 @@ +--- +kind: task +slug: db-model-providers-migration +title: Drizzle schema and migration for model_providers and role_model_routes +plan_slug: automation-foundation +epic_slug: model-provider-registry +status: ready +priority: P1 +tenant_id: global +owner: unassigned +cursor_todo_id: null +updated_at: "2026-07-03" +agent_prompt: | + Read docs/adr-model-routing.md and packages/shared model-routing types from + Task-shared-schemas-resolve-model. packages/database only. Generate migration + with pnpm db:generate — do not hand-edit SQL. claim_task before work. +--- + +# Task summary + +Add `model_providers` and `role_model_routes` tables with workspace scoping and indexes per the ADR. + +## Description + +### Schema (`packages/database/src/schema/`) + +- `model_providers.ts` — columns from ADR; unique `(workspace_id, alias)` +- `role_model_routes.ts` — FK to `model_providers`; unique `(workspace_id, role_id)` +- Re-export from `schema/index.ts`; add relations if needed + +### Migration + +- `pnpm db:generate` from repo root after schema edit +- Commit generated SQL under `packages/database/migrations/` + +### Optional seed helper + +- `seedDefaultRoleRoutes(workspaceId)` in database package or web server — inserts pipeline default role_ids pointing at aliases **only if** matching aliases exist (no auto-create of providers) + +## Subtasks + +- [ ] Define Drizzle tables +- [ ] Generate and commit migration +- [ ] Verify `workspace_id` index on both tables + +## Estimation + +S + +## Acceptance criteria + +- [ ] Migration applies cleanly with `pnpm db:migrate` +- [ ] Every query example in epic doc filters by `workspace_id` +- [ ] Types align with `packages/shared` zod schemas + +## Links + +- Epic: `./Epic-model-provider-registry.md` +- ADR: `../../../docs/adr-model-routing.md` +- Depends on: `Task-shared-schemas-resolve-model.md` diff --git a/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-settings-ai-providers-ui.md b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-settings-ai-providers-ui.md new file mode 100644 index 0000000..c19741d --- /dev/null +++ b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-settings-ai-providers-ui.md @@ -0,0 +1,63 @@ +--- +kind: task +slug: settings-ai-providers-ui +title: Settings UI — AI model providers +plan_slug: automation-foundation +epic_slug: model-provider-registry +status: ready +priority: P1 +tenant_id: global +owner: unassigned +cursor_todo_id: null +updated_at: "2026-07-03" +agent_prompt: | + Read docs/adr-model-routing.md. apps/web UI only — reuse components/ui. + Route: /[workspaceSlug]/settings/ai/providers. Do not store secrets in + forms — only api_key_env_var name field. claim_task before work. +--- + +# Task summary + +Build the workspace Settings page for CRUD on model provider aliases, including a “Test connection” action. + +## Description + +### Route + +`apps/web/app/(app)/[workspaceSlug]/settings/ai/providers/page.tsx` + +Add nav link under Settings (near runs/audit). + +### UI + +- Table: alias, display name, provider type, model id, base URL (truncated), enabled, actions +- Add/Edit dialog: all fields from ADR; provider type select drives whether base URL is required +- Test button → calls `ai.models.testProvider` → toast with latency or error +- Delete with confirm + +### UX notes + +- Show helper text: “API key is read from server env var `ANTHROPIC_API_KEY`” — not a password field +- Example presets (optional buttons): “Ollama local”, “MLX local (mlx-openai-server)”, “Anthropic”, “NVIDIA NIM”, “Gemini” — prefill form per `docs/adr-model-routing.md` § Provider examples + +## Subtasks + +- [ ] Page + list query +- [ ] Create/edit dialog +- [ ] Test connection + delete +- [ ] Settings nav entry + +## Estimation + +M + +## Acceptance criteria + +- [ ] Admin can add `qwen-coder` with Mac Ollama base URL and test it +- [ ] Non-admin sees read-only or hidden mutations +- [ ] Matches existing settings page visual patterns (e.g. runs page) + +## Links + +- Epic: `./Epic-model-provider-registry.md` +- Depends on: `Task-trpc-models-router.md` diff --git a/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-settings-ai-role-routing-ui.md b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-settings-ai-role-routing-ui.md new file mode 100644 index 0000000..2915c9b --- /dev/null +++ b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-settings-ai-role-routing-ui.md @@ -0,0 +1,61 @@ +--- +kind: task +slug: settings-ai-role-routing-ui +title: Settings UI — per-role model routing +plan_slug: automation-foundation +epic_slug: model-provider-registry +status: ready +priority: P1 +tenant_id: global +owner: unassigned +cursor_todo_id: null +updated_at: "2026-07-03" +agent_prompt: | + Read docs/adr-model-routing.md. apps/web UI. Route: + /[workspaceSlug]/settings/ai/role-routing. Allow adding custom role_id + strings. claim_task before work. +--- + +# Task summary + +Build the Settings page that maps `role_id` → provider alias, with pipeline defaults seeded and support for custom roles. + +## Description + +### Route + +`apps/web/app/(app)/[workspaceSlug]/settings/ai/role-routing/page.tsx` + +### UI + +- Table: role id, display label (optional friendly name), provider alias (dropdown from `listProviders`), tier badge from provider metadata, enabled toggle +- “Add role” — free-text `role_id` + alias dropdown (for `gate-copilot`, custom auditors, etc.) +- “Reset to pipeline defaults” — calls upsert for nine L2 roles + `gate-copilot` + `chat-default` **if** matching aliases exist; confirm dialog +- Link to ADR / note about convoy `model_policy` overrides + +### Default labels + +Map known `role_id` to human labels (Conductor, Architect, …) in a const — custom roles show raw id. + +## Subtasks + +- [ ] Page + list routes query +- [ ] Upsert/disable per row +- [ ] Add custom role flow +- [ ] Reset defaults action +- [ ] Settings nav entry + +## Estimation + +M + +## Acceptance criteria + +- [ ] User can set `role-architect` → `claude-planning`, `role-implementer` → `qwen-coder` +- [ ] User can add `gate-copilot` role row +- [ ] Dropdown only lists enabled providers for the workspace + +## Links + +- Epic: `./Epic-model-provider-registry.md` +- Depends on: `Task-trpc-models-router.md`, `Task-settings-ai-providers-ui.md` (providers should exist first for dogfood; soft dependency) diff --git a/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-shared-schemas-resolve-model.md b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-shared-schemas-resolve-model.md new file mode 100644 index 0000000..65b1cfd --- /dev/null +++ b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-shared-schemas-resolve-model.md @@ -0,0 +1,65 @@ +--- +kind: task +slug: shared-schemas-resolve-model +title: Shared zod schemas and resolveModel() pure function +plan_slug: automation-foundation +epic_slug: model-provider-registry +status: ready +priority: P1 +tenant_id: global +owner: unassigned +cursor_todo_id: null +updated_at: "2026-07-03" +agent_prompt: | + Read docs/adr-model-routing.md first. Implement only packages/shared (and + packages/ai if needed for zero DB imports in shared). No UI, no migration. + claim_task this backlog item before coding; complete_task when done. +--- + +# Task summary + +Add zod schemas and types for model providers, role routes, and a pure `resolveModel()` function per the ADR. + +## Description + +### Deliverables + +1. **`packages/shared`** — export: + - `providerTypeSchema` enum: `anthropic` | `openai` | `google` | `openai_compatible` | `azure_openai` + - `modelProviderSchema`, `roleModelRouteSchema`, `resolvedModelSchema` + - `resolveModel(input)` — pure function; input includes optional brief/convoy overrides, workspace routes, provider catalog + - `PIPELINE_DEFAULT_ROLE_ROUTES` constant (role_id → suggested alias names, not UUIDs) + - `LEGACY_CURSOR_MODEL_ALIASES` map for slug migration + +2. **Tests** — `packages/shared/src/model-routing/*.test.ts`: + - Resolution order: brief > convoy > workspace > default + - Missing/disabled alias → structured error + - Legacy slug remapping + +### Constraints + +- `packages/shared` must not import DB or Node-only APIs. +- Resolver accepts plain objects (tRPC/orchestrator loads rows and passes in). + +## Subtasks + +- [ ] Add schemas and types +- [ ] Implement `resolveModel` +- [ ] Add vitest cases (≥6 scenarios) +- [ ] Export from `packages/shared` index + +## Estimation + +S (one agent session) + +## Acceptance criteria + +- [ ] `pnpm --filter @tasks/shared test` passes with new tests +- [ ] `pnpm type-check` clean +- [ ] Resolver matches resolution order in ADR § Architecture + +## Links + +- Epic: `./Epic-model-provider-registry.md` +- Plan: `../Plan-automation-foundation.md` +- ADR: `../../../docs/adr-model-routing.md` diff --git a/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-trpc-models-router.md b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-trpc-models-router.md new file mode 100644 index 0000000..0a779b2 --- /dev/null +++ b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-trpc-models-router.md @@ -0,0 +1,66 @@ +--- +kind: task +slug: trpc-models-router +title: tRPC ai.models router — CRUD, role routes, testProvider +plan_slug: automation-foundation +epic_slug: model-provider-registry +status: ready +priority: P1 +tenant_id: global +owner: unassigned +cursor_todo_id: null +updated_at: "2026-07-03" +agent_prompt: | + Read docs/adr-model-routing.md. apps/web/server only. Admin-only mutations + (match updateWorkflowPrompt bar). testProvider runs a minimal completion + server-side — never return API keys to client. claim_task before work. +--- + +# Task summary + +Add `ai.models` tRPC router: provider CRUD, role route upsert/delete, `testProvider`, and `resolveForRole` debug procedure. + +## Description + +### Procedures + +| Procedure | Auth | Notes | +|-----------|------|-------| +| `listProviders` | member | workspace-scoped | +| `createProvider` / `updateProvider` / `deleteProvider` | admin | zod validate; unique alias | +| `testProvider` | admin | one short completion; return `{ ok, latencyMs, error? }` | +| `listRoleRoutes` | member | join provider alias for display | +| `upsertRoleRoute` / `deleteRoleRoute` | admin | custom `roleId` allowed | +| `resolveForRole` | member | calls shared `resolveModel` with DB rows | + +### Model client factory + +- Add `packages/ai/src/providers/resolve-client.ts` (or similar): given `ResolvedModel`, return Vercel AI SDK model instance for `generateText` in `testProvider` +- Support at least: `anthropic`, `openai`, `google`, `openai_compatible` + +### Wire router + +- Register in `apps/web/server/root.ts` +- Export input schemas from shared where possible + +## Subtasks + +- [ ] Implement provider CRUD +- [ ] Implement role route CRUD +- [ ] Implement testProvider with provider factory +- [ ] Implement resolveForRole + +## Estimation + +M + +## Acceptance criteria + +- [ ] Admin can create provider via tRPC; non-admin gets FORBIDDEN +- [ ] testProvider works against env-configured Anthropic or Ollama when aliases exist +- [ ] `pnpm lint && pnpm type-check` clean + +## Links + +- Epic: `./Epic-model-provider-registry.md` +- Depends on: `Task-db-model-providers-migration.md`, `Task-shared-schemas-resolve-model.md` diff --git a/plans/Plan-automation-foundation/Epic-pipeline-convoy-scaffolding/Epic-pipeline-convoy-scaffolding.md b/plans/Plan-automation-foundation/Epic-pipeline-convoy-scaffolding/Epic-pipeline-convoy-scaffolding.md new file mode 100644 index 0000000..0e2d460 --- /dev/null +++ b/plans/Plan-automation-foundation/Epic-pipeline-convoy-scaffolding/Epic-pipeline-convoy-scaffolding.md @@ -0,0 +1,46 @@ +--- +kind: epic +slug: pipeline-convoy-scaffolding +title: Pipeline convoy scaffolding — .convoys/ and metrics in tasks repo +plan_slug: automation-foundation +status: draft +priority: P1 +tenant_id: global +cursor_epic_id: null +updated_at: "2026-07-03" +--- + +# Epic objective + +Install L3 convoy infrastructure in the `tasks` repo so agent-pipeline dogfooding works: `.convoys/README.md`, `scripts/log-convoy-event.sh`, `.cursor/rules/convoy-planning.mdc`, `.gitignore` entry for `.convoys/.metrics.jsonl`. + +Can run **in parallel** with Epic 1 (model provider registry). + +## In scope / out of scope + +**In scope** + +- Copy/adapt from `agent-pipeline` L3 `_common` templates (manifest-aware paths). +- Document kickoff ritual in `.convoys/README.md`. + +**Out of scope** + +- Convoy lifecycle MCP (Epic 4). +- Creating convoy files for other consumer repos. + +## Dependencies + +- None (bootstrap from agent-pipeline templates). + +## Blocks + +- Epic 4 (convoy lifecycle MCP) — operators need a place to write convoy files first. + +## Acceptance criteria + +- [ ] `bash scripts/log-convoy-event.sh role=role-conductor convoy=test` appends valid JSONL. +- [ ] `role-conductor` can write `.convoys/.md` without conflicting rules. + +## Tasks + +_To be decomposed by architect when epic graduates to `ready`._ diff --git a/plans/Plan-automation-foundation/Plan-automation-foundation.md b/plans/Plan-automation-foundation/Plan-automation-foundation.md new file mode 100644 index 0000000..3a01dba --- /dev/null +++ b/plans/Plan-automation-foundation/Plan-automation-foundation.md @@ -0,0 +1,228 @@ +--- +kind: plan +slug: automation-foundation +title: Automation foundation — mission control, model routing, orchestrator path +status: ready +priority: P1 +tenant_id: global +cursor_plan_id: null +updated_at: "2026-07-07" +agent_prompt: | + You are implementing Echodo's automation foundation. Read docs/adr-model-routing.md + before touching model routing code. This repo uses pnpm, Drizzle, tRPC, zod in + packages/shared, and multitenancy via workspace_id on every tenant table. + + Dogfooding (required for every task in this plan): + 1. At session start: call MCP claim_task with this task's backlogItemId (import + plans/ if the row is missing). + 2. At session end: call MCP complete_task with outcome, tokensTotal, and + modelAlias/modelTier when you used a routed model. + 3. Stay within the task's acceptance criteria; do not widen scope. + 4. Run pnpm lint && pnpm type-check && pnpm test before declaring done. + + Stack: Next.js App Router, packages/database for schema, apps/web for UI. + Never store API keys in committed files or DB — only api_key_env_var names. +--- + +# Plan overview + +Echodo is evolving from a multitenant task app with MCP coordination into **mission control** for an agent pipeline: fleet view, human gates, per-role model routing, and (later) a built-in orchestrator and worker. This plan delivers the **first concrete slice** — the model provider registry and role routing system defined in [docs/adr-model-routing.md](../../docs/adr-model-routing.md). + +Later epics in this plan (2–9) add convoy scaffolding, connected repos, lifecycle MCP, gates UI, orchestrator, worker, webhooks, and fleet dashboard. Epic 1 gives them a stable `resolveModel()` contract. + +## Dogfooding the agent pipeline + +**Yes — this plan is designed to dogfood the agent-pipeline end to end.** + +| Pipeline artifact | How we use it | +|-------------------|---------------| +| L2 roles | Implementation runs as `role-implementer` per task brief (or ad hoc with implementer rules) | +| `claim_task` / `complete_task` | Every task session claims and completes via Echodo MCP | +| `agent_prompt` (plan → epic → task) | First-message context for agents; inheritance from this frontmatter | +| `docs/adr-model-routing.md` | Architect contract — agents read before coding | +| `.convoys/model-routing-registry.md` | **Created at kickoff** via `role-conductor` — tracks IA/UX/architecture for the initiative (not stored under `plans/`) | +| `model_policy` in convoy | Dogfoods alias-based routing (Claude planning, Qwen local) once registry exists | +| `scripts/log-convoy-event.sh` | Log each role completion to `.convoys/.metrics.jsonl` when L3 convoy scaffolding is present | + +**Kickoff ritual (human + conductor):** + +1. Prompt: *"Start a new convoy: Implement workspace-scoped model provider registry and role routing per docs/adr-model-routing.md. Success = operators can configure Anthropic + Ollama aliases and assign them to pipeline roles from Settings."* +2. Conductor writes `.convoys/model-routing-registry.md` with `classification: server-only` (skip ia, ux, design, a11y). +3. Architect decomposes into briefs that **map 1:1 to tasks in `Epic-model-provider-registry`** (below). +4. Each implementer session: `claim_task` on the matching `Task-*.md` → implement → `complete_task`. + +## Getting started (operator checklist) + +Do these **before or in parallel with** Epic 1 implementation. Nothing here blocks writing code; some items unblock dogfooding and `testProvider` against real endpoints. + +### A. Repo and backlog (one time) + +- [ ] `pnpm install` at repo root +- [ ] `.env` from `.env.example` — `DATABASE_URL`, `REDIS_URL`, `NEXTAUTH_*` at minimum +- [ ] `pnpm db:migrate` if schema is behind +- [ ] `pnpm --filter @tasks/database import:markdown-backlog` — loads `plans/Plan-automation-foundation/**` into DB for `claim_task` +- [ ] Confirm Echodo MCP in `.cursor/mcp.json` works (restart Cursor after MCP changes) + +### B. Local inference on Mac (pick one or both) + +See ADR § **Local MLX on Apple Silicon** and **Local Ollama**. + +| Goal | Action | +|------|--------| +| Simplest local coder | Install [Ollama](https://ollama.com), `ollama pull qwen2.5-coder:32b`, listen on LAN if Coolify must reach Mac | +| Best Apple Silicon perf / MLX models | `pip install mlx-openai-server` (preferred if you expect **parallel implementers**), launch with `--host 0.0.0.0` | +| Quick MLX smoke test | `pip install mlx-lm` → `mlx_lm.server --model mlx-community/...` on port 8080 | + +Note your Mac LAN IP and port for registry aliases later (`http://:8000/v1` or `:11434/v1`). + +### C. Cloud / free-tier aliases (optional until Epic 1 UI exists) + +Configure env vars on the machine that runs `apps/web` / worker (Coolify or local): + +- `ANTHROPIC_API_KEY` — planning roles (or use NIM/Gemini free tier per ADR references) +- `GOOGLE_GENERATIVE_AI_API_KEY`, `NVIDIA_API_KEY` — optional free-tier planning/gates + +Until Settings UI ships, `OPENAI_BASE_URL` + `OPENAI_API_KEY` still power the existing chat route. + +### D. Agent-pipeline convoy (when you start building) + +- [ ] Run **role-conductor** (Cursor): convoy for model registry per kickoff ritual above → `.convoys/model-routing-registry.md` +- [ ] **role-architect** → briefs map to Epic 1 tasks +- [ ] Epic 2 (optional parallel): install `.convoys/` scaffolding + `log-convoy-event.sh` + +### E. First implementation task + +Start with **`Task-shared-schemas-resolve-model`** — no DB migration, pure `packages/shared` + tests. Then migration, tRPC, UI. + +### F. Not needed yet + +- Forgejo / connected repos (Epic 3) +- Orchestrator or worker (Epics 6–7) +- LiteLLM (optional gateway later) +- Symphony + +## Objectives and success criteria + +- Workspace admins can CRUD model provider aliases (Anthropic, OpenAI, Google, OpenAI-compatible for Ollama/NIM/LiteLLM). +- Workspace admins can map any `role_id` (nine L2 defaults + custom) to a provider alias. +- `resolveModel()` implements ADR resolution order (brief → convoy → workspace → template). +- Settings UI: Providers + Role routing pages with connection test. +- `agent_runs` records `model_alias`, `provider_type`, `model_id`, `model_tier` on completion. +- Documented in ADR; `.env.example` updated with referenced env var names. + +## Scope and boundaries + +**In scope (full plan)** + +- Nine epics — see [Epic roadmap](#epic-roadmap-complete) below. +- Epic 1 (model registry) is **ready** with six tasks; epics 2–9 are **draft** stubs for architect decomposition. +- ADR at `docs/adr-model-routing.md`. + +**Out of scope (separate future plans)** + +- Agent-pipeline Phase 2b deliverable templates. +- Agent-pipeline Phase 4 drift/rollup workers (replace GHA cron). +- LiteLLM deployment guide (operators use OpenAI-compatible alias). +- Symphony adoption. + +## Cross-team collaborators + +| Name / group | Role | +|--------------|------| +| Solo operator | Product, implement, dogfood pipeline | + +## Epic roadmap (complete) + +Sequential order with parallel opportunities noted. Only **Epic 1** has bead-scale tasks today; epics 2–9 are draft stubs ready for architect decomposition when the prior epic lands. + +```mermaid +flowchart LR + E1[1 Model registry] + E2[2 Convoy scaffolding] + E3[3 Connected repos] + E4[4 Convoy lifecycle MCP] + E5[5 Gates UI] + E6[6 Orchestrator] + E7[7 Worker v1] + E8[8 Forge webhooks] + E9[9 Fleet dashboard] + + E1 --> E6 + E1 --> E7 + E2 --> E4 + E3 --> E6 + E4 --> E5 + E4 --> E6 + E5 --> E6 + E6 --> E7 + E7 --> E8 + E5 --> E9 + E4 --> E9 +``` + +| # | Epic | Status | Delivers | Depends on | +|---|------|--------|----------|------------| +| 1 | [Model provider registry](./Epic-model-provider-registry/Epic-model-provider-registry.md) | **ready** (6 tasks) | `resolveModel()`, Settings AI pages, run model logging | — | +| 2 | [Pipeline convoy scaffolding](./Epic-pipeline-convoy-scaffolding/Epic-pipeline-convoy-scaffolding.md) | draft | `.convoys/`, `log-convoy-event.sh`, `convoy-planning.mdc` — dogfood metrics | — (parallel with 1) | +| 3 | [Connected repos](./Epic-connected-repos/Epic-connected-repos.md) | draft | `repo_links` table, “paste remote URL”, forge adapter interface | — | +| 4 | [Convoy lifecycle MCP](./Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md) | draft | `create_convoy`, `transition_convoy_status`, state machine §7, `convoy_events` | 2, 3 | +| 5 | [Mission control gates UI](./Epic-mission-control-gates-ui/Epic-mission-control-gates-ui.md) | draft | Convoy kanban, gate inbox, approve/deny/revise, gate co-pilot chat | 4 | +| 6 | [Orchestrator](./Epic-apps-orchestrator/Epic-apps-orchestrator.md) | draft | `apps/orchestrator`, Redis queue, role dispatch, pause at gates | 1, 3, 4 | +| 7 | [Worker v1](./Epic-apps-worker-v1/Epic-apps-worker-v1.md) | draft | `apps/worker` minimal tool loop, worktree-per-brief, MCP claim/complete | 1, 3, 6 | +| 8 | [Forge webhooks](./Epic-forge-webhooks/Epic-forge-webhooks.md) | draft | PR opened/merged/CI webhooks → convoy transitions | 3, 4, 7 | +| 9 | [Fleet dashboard](./Epic-fleet-dashboard/Epic-fleet-dashboard.md) | draft | Cross-repo home, `convoys-*` workspaces, “needs you” rollup | 4, 5 | + +### Overlap with existing plans + +| Existing artifact | How this roadmap relates | +|-------------------|-------------------------| +| [Plan-agent-coordination](../Plan-agent-coordination/Plan-agent-coordination.md) | Epics 1 (logging) and 4–7 **extend** it; `claim_task` / `complete_task` stay canonical | +| [Epic-optional-orchestrator](../Plan-agent-coordination/Epic-optional-orchestrator/Epic-optional-orchestrator.md) | **Superseded in intent** by Epic 6 here — richer spec, Echodo-native, no Symphony dependency | +| Agent-pipeline v0.4 Phase 2a | Epic 4 implements the MCP tools described there | +| Agent-pipeline v0.4 Phase 4 workers | Out of scope until Epic 8+; drift/rollup is a future Plan, not this roadmap | + +### End-state (what “done” looks like for the full plan) + +Operator opens Echodo fleet view → starts convoy chat → hits **Go** → orchestrator runs pipeline roles with per-role models → worker executes briefs against connected repos → gates surface in inbox with AI co-pilot → approve → repeat until `released`. + +## Epics (links) + +| Epic | Link | +|------|------| +| 1 Model provider registry | `./Epic-model-provider-registry/Epic-model-provider-registry.md` | +| 2 Pipeline convoy scaffolding | `./Epic-pipeline-convoy-scaffolding/Epic-pipeline-convoy-scaffolding.md` | +| 3 Connected repos | `./Epic-connected-repos/Epic-connected-repos.md` | +| 4 Convoy lifecycle MCP | `./Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md` | +| 5 Mission control gates UI | `./Epic-mission-control-gates-ui/Epic-mission-control-gates-ui.md` | +| 6 Orchestrator | `./Epic-apps-orchestrator/Epic-apps-orchestrator.md` | +| 7 Worker v1 | `./Epic-apps-worker-v1/Epic-apps-worker-v1.md` | +| 8 Forge webhooks | `./Epic-forge-webhooks/Epic-forge-webhooks.md` | +| 9 Fleet dashboard | `./Epic-fleet-dashboard/Epic-fleet-dashboard.md` | + +## Metrics and milestones + +| Milestone | Epic | Metric | +|-----------|------|--------| +| M0 ADR | — | `docs/adr-model-routing.md` merged | +| M1 Model registry | 1 | Settings: configure + test Anthropic + Ollama aliases | +| M2 Dogfood loop | 1–2 | One task via claim_task → complete_task with model fields | +| M3 Repos connected | 3 | Paste Forge/GitHub URL; clone succeeds from Echodo | +| M4 Convoy state | 4 | `transition_convoy_status` enforces gate actors | +| M5 Human surface | 5 | Gate inbox shows pending `convoy_review` | +| M6 Automation | 6–7 | Orchestrator dispatches one implementer brief unattended | +| M7 PR loop | 8 | Merge webhook moves convoy to `merged` | +| M8 Fleet | 9 | Single view across ≥2 connected repos | + +## Risks and mitigations + +| Risk | Mitigation | +|------|------------| +| Mac Ollama unreachable from Coolify-hosted web | `testProvider` surfaces error; document LAN URL; dev can run web locally | +| Cursor slug drift in old convoys | Legacy alias map in ADR; conductor uses registry aliases for new convoys | +| Scope creep into orchestrator | ADR + plan explicitly defer daemon/worker | + +## Links + +- [ADR: Model routing](../../docs/adr-model-routing.md) +- [Plan: Agent coordination](../Plan-agent-coordination/Plan-agent-coordination.md) +- Agent-pipeline: `docs/model-routing-policy.md`, `docs/role-reference.md` From cda4958b174ffaac4c4dac6fbbfe11e005f2d609 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 7 Jul 2026 13:56:36 -0500 Subject: [PATCH 2/7] feat(shared): add model routing schemas and resolveModel() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Epic 1 task 1 per docs/adr-model-routing.md: zod schemas for providers and role routes, pipeline default aliases, legacy Cursor slug map, and a pure resolveModel() with brief → convoy → workspace → default order. Co-authored-by: Cursor --- packages/shared/package.json | 1 + packages/shared/src/index.ts | 1 + .../shared/src/model-routing/constants.ts | 28 +++ packages/shared/src/model-routing/index.ts | 28 +++ .../src/model-routing/resolve-model.test.ts | 172 ++++++++++++++ .../shared/src/model-routing/resolve-model.ts | 213 ++++++++++++++++++ packages/shared/src/model-routing/schemas.ts | 74 ++++++ 7 files changed, 517 insertions(+) create mode 100644 packages/shared/src/model-routing/constants.ts create mode 100644 packages/shared/src/model-routing/index.ts create mode 100644 packages/shared/src/model-routing/resolve-model.test.ts create mode 100644 packages/shared/src/model-routing/resolve-model.ts create mode 100644 packages/shared/src/model-routing/schemas.ts diff --git a/packages/shared/package.json b/packages/shared/package.json index 0e29c6c..ce1e22a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -7,6 +7,7 @@ "types": "./src/index.ts", "exports": { ".": "./src/index.ts", + "./model-routing": "./src/model-routing/index.ts", "./types": "./src/types/index.ts", "./utils": "./src/utils/index.ts" }, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9e03490..f440e17 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,2 +1,3 @@ +export * from "./model-routing"; export * from "./types"; export * from "./utils"; diff --git a/packages/shared/src/model-routing/constants.ts b/packages/shared/src/model-routing/constants.ts new file mode 100644 index 0000000..835386f --- /dev/null +++ b/packages/shared/src/model-routing/constants.ts @@ -0,0 +1,28 @@ +/** + * Shipped pipeline defaults — alias *names*, not provider UUIDs. Operators remap + * aliases in Settings; these are fallbacks when no workspace route exists. + */ +export const PIPELINE_DEFAULT_ROLE_ROUTES: Readonly> = { + "role-conductor": "claude-planning", + "role-architect": "claude-planning", + "role-ia-architect": "claude-planning", + "role-ux-reviewer": "qwen-coder", + "role-implementer": "qwen-coder", + "role-reviewer": "qwen-coder", + "role-design-system-auditor": "qwen-coder", + "role-a11y-auditor": "qwen-coder", + "role-doc-writer": "qwen-coder", + "gate-copilot": "claude-planning", + "chat-default": "qwen-coder", +}; + +/** Cursor-era model slugs → registry alias names (workspace may override). */ +export const LEGACY_CURSOR_MODEL_ALIASES: Readonly> = { + "claude-4.6-opus-high-thinking": "claude-planning", + "claude-4.6-sonnet-medium-thinking": "claude-planning", + "claude-opus-4-8-thinking-high": "claude-planning", + "claude-sonnet-5-thinking-medium": "claude-planning", + "composer-2.5-fast": "qwen-coder", + "composer-2.5": "qwen-coder", + auto: "qwen-coder", +}; diff --git a/packages/shared/src/model-routing/index.ts b/packages/shared/src/model-routing/index.ts new file mode 100644 index 0000000..c6fe5ed --- /dev/null +++ b/packages/shared/src/model-routing/index.ts @@ -0,0 +1,28 @@ +export { + LEGACY_CURSOR_MODEL_ALIASES, + PIPELINE_DEFAULT_ROLE_ROUTES, +} from "./constants.js"; +export { + convoyModelPolicySchema, + modelProviderSchema, + modelTierSchema, + providerTypeSchema, + resolvedModelSchema, + roleModelRouteSchema, + type ConvoyModelPolicy, + type ModelProvider, + type ModelTier, + type ProviderType, + type ResolvedModel, + type RoleModelRoute, +} from "./schemas.js"; +export { + resolveModel, + resolveModelErrorCodeSchema, + type ResolveModelError, + type ResolveModelErrorCode, + type ResolveModelFailure, + type ResolveModelInput, + type ResolveModelResult, + type ResolveModelSuccess, +} from "./resolve-model.js"; diff --git a/packages/shared/src/model-routing/resolve-model.test.ts b/packages/shared/src/model-routing/resolve-model.test.ts new file mode 100644 index 0000000..354b49c --- /dev/null +++ b/packages/shared/src/model-routing/resolve-model.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; + +import { LEGACY_CURSOR_MODEL_ALIASES } from "./constants.js"; +import type { ModelProvider, RoleModelRoute } from "./schemas.js"; +import { resolveModel } from "./resolve-model.js"; + +const WORKSPACE_ID = "379ecb2f-6db9-4c53-93ad-72c594eb44a5"; + +function provider( + overrides: Partial & Pick, +): ModelProvider { + return { + id: overrides.id ?? "11111111-1111-1111-1111-111111111111", + workspaceId: WORKSPACE_ID, + displayName: overrides.displayName ?? overrides.alias, + enabled: true, + ...overrides, + }; +} + +const claude = provider({ + id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + alias: "claude-planning", + providerType: "anthropic", + modelId: "claude-sonnet-4-20250514", + apiKeyEnvVar: "ANTHROPIC_API_KEY", + metadata: { tier: "premium" }, +}); + +const qwen = provider({ + id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + alias: "qwen-coder", + providerType: "openai_compatible", + modelId: "qwen2.5-coder:32b", + baseUrl: "http://127.0.0.1:11434/v1", + apiKeyEnvVar: "OLLAMA_API_KEY", + metadata: { tier: "local" }, +}); + +const routes: RoleModelRoute[] = [ + { + id: "cccccccc-cccc-cccc-cccc-cccccccccccc", + workspaceId: WORKSPACE_ID, + roleId: "role-implementer", + providerAliasId: qwen.id, + enabled: true, + }, +]; + +const getApiKey = (name: string) => + ({ ANTHROPIC_API_KEY: "sk-ant-test", OLLAMA_API_KEY: "ollama" })[name]; + +describe("resolveModel", () => { + it("prefers brief recommended_model over convoy, workspace, and default", () => { + const result = resolveModel({ + roleId: "role-implementer", + providers: [claude, qwen], + routes, + convoyPolicy: { roles: { "role-implementer": "qwen-coder" } }, + briefRecommendedModel: "claude-planning", + getApiKey, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.source).toBe("brief"); + expect(result.model.alias).toBe("claude-planning"); + }); + + it("prefers convoy policy over workspace route and pipeline default", () => { + const result = resolveModel({ + roleId: "role-implementer", + providers: [claude, qwen], + routes, + convoyPolicy: { roles: { "role-implementer": "claude-planning" } }, + getApiKey, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.source).toBe("convoy"); + expect(result.model.alias).toBe("claude-planning"); + }); + + it("uses workspace route when convoy and brief are absent", () => { + const result = resolveModel({ + roleId: "role-implementer", + providers: [claude, qwen], + routes, + getApiKey, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.source).toBe("workspace"); + expect(result.model.alias).toBe("qwen-coder"); + expect(result.model.tier).toBe("local"); + }); + + it("falls back to pipeline default role routes", () => { + const result = resolveModel({ + roleId: "role-architect", + providers: [claude, qwen], + routes: [], + getApiKey, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.source).toBe("default"); + expect(result.model.alias).toBe("claude-planning"); + }); + + it("returns ALIAS_NOT_RESOLVED when no layer matches", () => { + const result = resolveModel({ + roleId: "role-custom-unknown", + providers: [claude, qwen], + routes: [], + defaultRoutes: {}, + getApiKey, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe("ALIAS_NOT_RESOLVED"); + }); + + it("returns PROVIDER_DISABLED when alias maps to a disabled provider", () => { + const result = resolveModel({ + roleId: "role-implementer", + providers: [{ ...qwen, enabled: false }], + routes: [], + briefRecommendedModel: "qwen-coder", + getApiKey, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe("PROVIDER_DISABLED"); + expect(result.error.alias).toBe("qwen-coder"); + }); + + it("remaps legacy Cursor model slugs before catalog lookup", () => { + const legacySlug = "composer-2.5-fast"; + expect(LEGACY_CURSOR_MODEL_ALIASES[legacySlug]).toBe("qwen-coder"); + + const result = resolveModel({ + roleId: "role-implementer", + providers: [qwen], + routes: [], + briefRecommendedModel: legacySlug, + getApiKey, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.resolvedAlias).toBe("qwen-coder"); + }); + + it("returns API_KEY_MISSING when required env var is absent", () => { + const result = resolveModel({ + roleId: "role-architect", + providers: [claude], + routes: [], + getApiKey: () => undefined, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe("API_KEY_MISSING"); + }); +}); diff --git a/packages/shared/src/model-routing/resolve-model.ts b/packages/shared/src/model-routing/resolve-model.ts new file mode 100644 index 0000000..6db25b0 --- /dev/null +++ b/packages/shared/src/model-routing/resolve-model.ts @@ -0,0 +1,213 @@ +import { + LEGACY_CURSOR_MODEL_ALIASES, + PIPELINE_DEFAULT_ROLE_ROUTES, +} from "./constants.js"; +import type { + ConvoyModelPolicy, + ModelProvider, + ModelTier, + ResolvedModel, + RoleModelRoute, +} from "./schemas.js"; +import { resolvedModelSchema } from "./schemas.js"; + +export const resolveModelErrorCodeSchema = [ + "ALIAS_NOT_RESOLVED", + "PROVIDER_NOT_FOUND", + "PROVIDER_DISABLED", + "BASE_URL_REQUIRED", + "API_KEY_MISSING", +] as const; + +export type ResolveModelErrorCode = (typeof resolveModelErrorCodeSchema)[number]; + +export type ResolveModelError = { + code: ResolveModelErrorCode; + message: string; + alias?: string; + roleId: string; +}; + +export type ResolveModelSuccess = { + ok: true; + model: ResolvedModel; + source: "brief" | "convoy" | "workspace" | "default"; + resolvedAlias: string; +}; + +export type ResolveModelFailure = { + ok: false; + error: ResolveModelError; +}; + +export type ResolveModelResult = ResolveModelSuccess | ResolveModelFailure; + +export type ResolveModelInput = { + roleId: string; + providers: ModelProvider[]; + routes: RoleModelRoute[]; + convoyPolicy?: ConvoyModelPolicy; + briefRecommendedModel?: string; + /** Workspace metadata `legacy_model_aliases` — checked after global legacy map. */ + legacyAliases?: Record; + getApiKey: (envVarName: string) => string | undefined; + defaultRoutes?: Readonly>; +}; + +function normalizeAlias( + rawAlias: string, + legacyAliases?: Record, +): string { + const trimmed = rawAlias.trim(); + return ( + legacyAliases?.[trimmed] ?? + LEGACY_CURSOR_MODEL_ALIASES[trimmed] ?? + trimmed + ); +} + +function readTier(metadata: ModelProvider["metadata"]): ModelTier | undefined { + if (!metadata || typeof metadata !== "object") return undefined; + const tier = (metadata as { tier?: unknown }).tier; + if ( + tier === "premium" || + tier === "fast" || + tier === "local" || + tier === "auto" + ) { + return tier; + } + return undefined; +} + +function pickAlias( + input: ResolveModelInput, +): { alias: string; source: ResolveModelSuccess["source"] } | null { + const brief = input.briefRecommendedModel?.trim(); + if (brief) { + return { alias: brief, source: "brief" }; + } + + const convoyAlias = input.convoyPolicy?.roles?.[input.roleId]?.trim(); + if (convoyAlias) { + return { alias: convoyAlias, source: "convoy" }; + } + + const route = input.routes.find( + (row) => row.roleId === input.roleId && row.enabled, + ); + if (route) { + const provider = input.providers.find((p) => p.id === route.providerAliasId); + if (provider?.alias) { + return { alias: provider.alias, source: "workspace" }; + } + } + + const defaults = input.defaultRoutes ?? PIPELINE_DEFAULT_ROLE_ROUTES; + const fallback = defaults[input.roleId]?.trim(); + if (fallback) { + return { alias: fallback, source: "default" }; + } + + return null; +} + +function fail( + code: ResolveModelErrorCode, + message: string, + roleId: string, + alias?: string, +): ResolveModelFailure { + return { + ok: false, + error: { code, message, roleId, alias }, + }; +} + +/** + * Pure resolver — callers load workspace provider/route rows and pass plain + * objects. Resolution order: brief → convoy → workspace route → pipeline default. + */ +export function resolveModel(input: ResolveModelInput): ResolveModelResult { + const picked = pickAlias(input); + if (!picked) { + return fail( + "ALIAS_NOT_RESOLVED", + `No model alias resolved for role "${input.roleId}"`, + input.roleId, + ); + } + + const resolvedAlias = normalizeAlias(picked.alias, input.legacyAliases); + const provider = input.providers.find((p) => p.alias === resolvedAlias); + if (!provider) { + return fail( + "PROVIDER_NOT_FOUND", + `Provider alias "${resolvedAlias}" is not registered in this workspace`, + input.roleId, + resolvedAlias, + ); + } + + if (!provider.enabled) { + return fail( + "PROVIDER_DISABLED", + `Provider alias "${resolvedAlias}" is disabled`, + input.roleId, + resolvedAlias, + ); + } + + if (provider.providerType === "openai_compatible" && !provider.baseUrl) { + return fail( + "BASE_URL_REQUIRED", + `Provider "${resolvedAlias}" requires baseUrl for openai_compatible`, + input.roleId, + resolvedAlias, + ); + } + + let apiKey = ""; + if (provider.apiKeyEnvVar) { + const value = input.getApiKey(provider.apiKeyEnvVar); + if (value === undefined || value.length === 0) { + return fail( + "API_KEY_MISSING", + `Environment variable "${provider.apiKeyEnvVar}" is not set for provider "${resolvedAlias}"`, + input.roleId, + resolvedAlias, + ); + } + apiKey = value; + } + + const model: ResolvedModel = { + alias: resolvedAlias, + providerType: provider.providerType, + modelId: provider.modelId, + apiKey, + ...(provider.baseUrl ? { baseUrl: provider.baseUrl } : {}), + ...(provider.extraHeaders ? { extraHeaders: provider.extraHeaders } : {}), + ...(provider.defaultParams ? { defaultParams: provider.defaultParams } : {}), + ...(readTier(provider.metadata) + ? { tier: readTier(provider.metadata) } + : {}), + }; + + const parsed = resolvedModelSchema.safeParse(model); + if (!parsed.success) { + return fail( + "PROVIDER_NOT_FOUND", + parsed.error.issues.map((i) => i.message).join("; "), + input.roleId, + resolvedAlias, + ); + } + + return { + ok: true, + model: parsed.data, + source: picked.source, + resolvedAlias, + }; +} diff --git a/packages/shared/src/model-routing/schemas.ts b/packages/shared/src/model-routing/schemas.ts new file mode 100644 index 0000000..85a17cb --- /dev/null +++ b/packages/shared/src/model-routing/schemas.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; + +export const providerTypeSchema = z.enum([ + "anthropic", + "openai", + "google", + "openai_compatible", + "azure_openai", +]); + +export type ProviderType = z.infer; + +export const modelTierSchema = z.enum(["premium", "fast", "local", "auto"]); + +export type ModelTier = z.infer; + +export const modelProviderSchema = z.object({ + id: z.string().uuid(), + workspaceId: z.string().uuid(), + alias: z.string().min(1), + displayName: z.string().min(1), + providerType: providerTypeSchema, + modelId: z.string().min(1), + baseUrl: z.string().url().nullable().optional(), + apiKeyEnvVar: z.string().min(1).nullable().optional(), + extraHeaders: z.record(z.string()).nullable().optional(), + defaultParams: z + .object({ + temperature: z.number().optional(), + maxTokens: z.number().optional(), + }) + .nullable() + .optional(), + metadata: z.record(z.unknown()).nullable().optional(), + enabled: z.boolean().default(true), +}); + +export type ModelProvider = z.infer; + +export const roleModelRouteSchema = z.object({ + id: z.string().uuid(), + workspaceId: z.string().uuid(), + roleId: z.string().min(1), + providerAliasId: z.string().uuid(), + enabled: z.boolean().default(true), + sortOrder: z.number().int().optional(), +}); + +export type RoleModelRoute = z.infer; + +export const resolvedModelSchema = z.object({ + alias: z.string().min(1), + providerType: providerTypeSchema, + modelId: z.string().min(1), + baseUrl: z.string().url().optional(), + apiKey: z.string(), + extraHeaders: z.record(z.string()).optional(), + defaultParams: z + .object({ + temperature: z.number().optional(), + maxTokens: z.number().optional(), + }) + .optional(), + tier: modelTierSchema.optional(), +}); + +export type ResolvedModel = z.infer; + +export const convoyModelPolicySchema = z.object({ + roles: z.record(z.string()).optional(), + escalate_to: z.string().min(1).optional(), +}); + +export type ConvoyModelPolicy = z.infer; From fde698f439e620c2e6c6143a6bc3b51f51780df4 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 7 Jul 2026 13:56:41 -0500 Subject: [PATCH 3/7] chore(plans): mark shared-schemas-resolve-model task done Co-authored-by: Cursor --- .../Task-shared-schemas-resolve-model.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-shared-schemas-resolve-model.md b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-shared-schemas-resolve-model.md index 65b1cfd..221f6b7 100644 --- a/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-shared-schemas-resolve-model.md +++ b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-shared-schemas-resolve-model.md @@ -4,7 +4,7 @@ slug: shared-schemas-resolve-model title: Shared zod schemas and resolveModel() pure function plan_slug: automation-foundation epic_slug: model-provider-registry -status: ready +status: done priority: P1 tenant_id: global owner: unassigned @@ -43,10 +43,10 @@ Add zod schemas and types for model providers, role routes, and a pure `resolveM ## Subtasks -- [ ] Add schemas and types -- [ ] Implement `resolveModel` -- [ ] Add vitest cases (≥6 scenarios) -- [ ] Export from `packages/shared` index +- [x] Add schemas and types +- [x] Implement `resolveModel` +- [x] Add vitest cases (≥6 scenarios) +- [x] Export from `packages/shared` index ## Estimation @@ -54,9 +54,9 @@ S (one agent session) ## Acceptance criteria -- [ ] `pnpm --filter @tasks/shared test` passes with new tests -- [ ] `pnpm type-check` clean -- [ ] Resolver matches resolution order in ADR § Architecture +- [x] `pnpm --filter @tasks/shared test` passes with new tests +- [x] `pnpm type-check` clean +- [x] Resolver matches resolution order in ADR § Architecture ## Links From f04122fd042f8c4687ec6e466e70773d50f52af6 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 7 Jul 2026 14:00:48 -0500 Subject: [PATCH 4/7] feat(database): add model_providers and role_model_routes tables Adds workspace-scoped provider registry schema, relations, migration 0010, and seedDefaultRoleRoutes() for pipeline defaults when aliases exist. Co-authored-by: Cursor --- .../database/migrations/0010_legal_stryfe.sql | 36 + .../migrations/meta/0010_snapshot.json | 3351 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + packages/database/package.json | 4 +- packages/database/src/model-routing/index.ts | 2 + .../model-routing/seed-default-role-routes.ts | 67 + packages/database/src/schema/index.ts | 2 + .../database/src/schema/model_providers.ts | 51 + packages/database/src/schema/relations.ts | 23 + .../database/src/schema/role_model_routes.ts | 45 + .../Task-db-model-providers-migration.md | 14 +- pnpm-lock.yaml | 3 + 12 files changed, 3597 insertions(+), 8 deletions(-) create mode 100644 packages/database/migrations/0010_legal_stryfe.sql create mode 100644 packages/database/migrations/meta/0010_snapshot.json create mode 100644 packages/database/src/model-routing/index.ts create mode 100644 packages/database/src/model-routing/seed-default-role-routes.ts create mode 100644 packages/database/src/schema/model_providers.ts create mode 100644 packages/database/src/schema/role_model_routes.ts diff --git a/packages/database/migrations/0010_legal_stryfe.sql b/packages/database/migrations/0010_legal_stryfe.sql new file mode 100644 index 0000000..5078526 --- /dev/null +++ b/packages/database/migrations/0010_legal_stryfe.sql @@ -0,0 +1,36 @@ +CREATE TABLE "model_providers" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "alias" varchar(120) NOT NULL, + "display_name" varchar(200) NOT NULL, + "provider_type" varchar(40) NOT NULL, + "model_id" varchar(200) NOT NULL, + "base_url" text, + "api_key_env_var" varchar(120), + "extra_headers" jsonb, + "default_params" jsonb, + "metadata" jsonb, + "enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "role_model_routes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "role_id" varchar(120) NOT NULL, + "provider_alias_id" uuid NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "sort_order" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "model_providers" ADD CONSTRAINT "model_providers_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "role_model_routes" ADD CONSTRAINT "role_model_routes_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "role_model_routes" ADD CONSTRAINT "role_model_routes_provider_alias_id_model_providers_id_fk" FOREIGN KEY ("provider_alias_id") REFERENCES "public"."model_providers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "model_providers_workspace_id_idx" ON "model_providers" USING btree ("workspace_id");--> statement-breakpoint +CREATE UNIQUE INDEX "model_providers_workspace_id_alias_unique" ON "model_providers" USING btree ("workspace_id","alias");--> statement-breakpoint +CREATE INDEX "role_model_routes_workspace_id_idx" ON "role_model_routes" USING btree ("workspace_id");--> statement-breakpoint +CREATE UNIQUE INDEX "role_model_routes_workspace_id_role_id_unique" ON "role_model_routes" USING btree ("workspace_id","role_id");--> statement-breakpoint +CREATE INDEX "role_model_routes_provider_alias_id_idx" ON "role_model_routes" USING btree ("provider_alias_id"); \ No newline at end of file diff --git a/packages/database/migrations/meta/0010_snapshot.json b/packages/database/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000..72f594d --- /dev/null +++ b/packages/database/migrations/meta/0010_snapshot.json @@ -0,0 +1,3351 @@ +{ + "id": "69ed8958-b67e-4ca2-9884-b878c5d40c34", + "prevId": "8887562c-99dd-4b61-8b06-44dd39ddfc78", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.workspace_invites": { + "name": "workspace_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now() + interval '14 days'" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_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": { + "workspace_invites_workspace_id_idx": { + "name": "workspace_invites_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_token_unique": { + "name": "workspace_invites_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_open_email_unique": { + "name": "workspace_invites_open_email_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_invites\".\"accepted_at\" IS NULL AND \"workspace_invites\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invites_workspace_id_workspaces_id_fk": { + "name": "workspace_invites_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invites_invited_by_user_id_users_id_fk": { + "name": "workspace_invites_invited_by_user_id_users_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "users", + "columnsFrom": [ + "invited_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan_tier": { + "name": "plan_tier", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "archived_at": { + "name": "archived_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_user_id_idx": { + "name": "workspaces_owner_user_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_user_id_users_id_fk": { + "name": "workspaces_owner_user_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.object_assignees": { + "name": "object_assignees", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'assignee'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "object_assignees_object_id_user_id_unique": { + "name": "object_assignees_object_id_user_id_unique", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_assignees_object_id_idx": { + "name": "object_assignees_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_assignees_user_id_idx": { + "name": "object_assignees_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "object_assignees_object_id_objects_id_fk": { + "name": "object_assignees_object_id_objects_id_fk", + "tableFrom": "object_assignees", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "object_assignees_user_id_users_id_fk": { + "name": "object_assignees_user_id_users_id_fk", + "tableFrom": "object_assignees", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.objects": { + "name": "objects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image": { + "name": "cover_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "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()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "objects_parent_id_idx": { + "name": "objects_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_type_idx": { + "name": "objects_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_workspace_id_idx": { + "name": "objects_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_template_id_idx": { + "name": "objects_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_created_by_idx": { + "name": "objects_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_type_workspace_id_idx": { + "name": "objects_type_workspace_id_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "objects_workspace_id_workspaces_id_fk": { + "name": "objects_workspace_id_workspaces_id_fk", + "tableFrom": "objects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "objects_created_by_users_id_fk": { + "name": "objects_created_by_users_id_fk", + "tableFrom": "objects", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "objects_parent_id_objects_id_fk": { + "name": "objects_parent_id_objects_id_fk", + "tableFrom": "objects", + "tableTo": "objects", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "objects_template_id_templates_id_fk": { + "name": "objects_template_id_templates_id_fk", + "tableFrom": "objects", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_members": { + "name": "workspace_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_workspace_id_user_id_unique": { + "name": "workspace_members_workspace_id_user_id_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_members_workspace_id_idx": { + "name": "workspace_members_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_users_id_fk": { + "name": "workspace_members_user_id_users_id_fk", + "tableFrom": "workspace_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.object_type_defs": { + "name": "object_type_defs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "layout": { + "name": "layout", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "default_properties": { + "name": "default_properties", + "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": { + "object_type_defs_workspace_id_idx": { + "name": "object_type_defs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_type_defs_slug_idx": { + "name": "object_type_defs_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "object_type_defs_workspace_id_workspaces_id_fk": { + "name": "object_type_defs_workspace_id_workspaces_id_fk", + "tableFrom": "object_type_defs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_definitions": { + "name": "property_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "property_definitions_workspace_id_idx": { + "name": "property_definitions_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_definitions_workspace_id_name_idx": { + "name": "property_definitions_workspace_id_name_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_definitions_workspace_id_workspaces_id_fk": { + "name": "property_definitions_workspace_id_workspaces_id_fk", + "tableFrom": "property_definitions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_values": { + "name": "property_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "property_def_id": { + "name": "property_def_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "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": { + "property_values_object_id_property_def_id_unique": { + "name": "property_values_object_id_property_def_id_unique", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "property_def_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_values_object_id_idx": { + "name": "property_values_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_values_property_def_id_idx": { + "name": "property_values_property_def_id_idx", + "columns": [ + { + "expression": "property_def_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_values_object_id_objects_id_fk": { + "name": "property_values_object_id_objects_id_fk", + "tableFrom": "property_values", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "property_values_property_def_id_property_definitions_id_fk": { + "name": "property_values_property_def_id_property_definitions_id_fk", + "tableFrom": "property_values", + "tableTo": "property_definitions", + "columnsFrom": [ + "property_def_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.views": { + "name": "views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "view_type": { + "name": "view_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "views_object_id_idx": { + "name": "views_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "views_object_id_objects_id_fk": { + "name": "views_object_id_objects_id_fk", + "tableFrom": "views", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "accounts_provider_provider_account_id_unique": { + "name": "accounts_provider_provider_account_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_token": { + "name": "session_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "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": {} + } + }, + "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_session_token_unique": { + "name": "sessions_session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "session_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_email_identities": { + "name": "user_email_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_identities_user_id_idx": { + "name": "user_email_identities_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_email_identities_email_idx": { + "name": "user_email_identities_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_email_identities_user_id_email_unique": { + "name": "user_email_identities_user_id_email_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_email_identities_verified_email_unique": { + "name": "user_email_identities_verified_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_email_identities\".\"verified_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_email_identities_user_id_users_id_fk": { + "name": "user_email_identities_user_id_users_id_fk", + "tableFrom": "user_email_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "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": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_lower_unique": { + "name": "users_email_lower_unique", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier_token_pk": { + "name": "verification_tokens_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.object_relations": { + "name": "object_relations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "relation_type": { + "name": "relation_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "object_relations_source_id_idx": { + "name": "object_relations_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_relations_target_id_idx": { + "name": "object_relations_target_id_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_relations_relation_type_idx": { + "name": "object_relations_relation_type_idx", + "columns": [ + { + "expression": "relation_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_relations_source_target_type_unique": { + "name": "object_relations_source_target_type_unique", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relation_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "object_relations_source_id_objects_id_fk": { + "name": "object_relations_source_id_objects_id_fk", + "tableFrom": "object_relations", + "tableTo": "objects", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "object_relations_target_id_objects_id_fk": { + "name": "object_relations_target_id_objects_id_fk", + "tableFrom": "object_relations", + "tableTo": "objects", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "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": { + "templates_workspace_id_idx": { + "name": "templates_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workspace_id_workspaces_id_fk": { + "name": "templates_workspace_id_workspaces_id_fk", + "tableFrom": "templates", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form_responses": { + "name": "form_responses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "form_id": { + "name": "form_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "respondent_id": { + "name": "respondent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_object_id": { + "name": "created_object_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_responses_form_id_idx": { + "name": "form_responses_form_id_idx", + "columns": [ + { + "expression": "form_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_responses_respondent_id_idx": { + "name": "form_responses_respondent_id_idx", + "columns": [ + { + "expression": "respondent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_responses_form_id_forms_id_fk": { + "name": "form_responses_form_id_forms_id_fk", + "tableFrom": "form_responses", + "tableTo": "forms", + "columnsFrom": [ + "form_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_responses_respondent_id_users_id_fk": { + "name": "form_responses_respondent_id_users_id_fk", + "tableFrom": "form_responses", + "tableTo": "users", + "columnsFrom": [ + "respondent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "form_responses_created_object_id_objects_id_fk": { + "name": "form_responses_created_object_id_objects_id_fk", + "tableFrom": "form_responses", + "tableTo": "objects", + "columnsFrom": [ + "created_object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.forms": { + "name": "forms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image": { + "name": "cover_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_type": { + "name": "target_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "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": { + "forms_workspace_id_idx": { + "name": "forms_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forms_object_id_idx": { + "name": "forms_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "forms_workspace_id_workspaces_id_fk": { + "name": "forms_workspace_id_workspaces_id_fk", + "tableFrom": "forms", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "forms_object_id_objects_id_fk": { + "name": "forms_object_id_objects_id_fk", + "tableFrom": "forms", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "forms_created_by_users_id_fk": { + "name": "forms_created_by_users_id_fk", + "tableFrom": "forms", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_favorites": { + "name": "user_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_favorites_user_object_idx": { + "name": "user_favorites_user_object_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_favorites_user_id_users_id_fk": { + "name": "user_favorites_user_id_users_id_fk", + "tableFrom": "user_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_favorites_object_id_objects_id_fk": { + "name": "user_favorites_object_id_objects_id_fk", + "tableFrom": "user_favorites", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.markdown_backlog_items": { + "name": "markdown_backlog_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "plan_slug": { + "name": "plan_slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "epic_slug": { + "name": "epic_slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frontmatter": { + "name": "frontmatter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "body_markdown": { + "name": "body_markdown", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content_hash": { + "name": "content_hash", + "type": "varchar(64)", + "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()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "workflow_prompt": { + "name": "workflow_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "markdown_backlog_workspace_repo_path_unique": { + "name": "markdown_backlog_workspace_repo_path_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "markdown_backlog_workspace_plan_idx": { + "name": "markdown_backlog_workspace_plan_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plan_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "markdown_backlog_parent_id_idx": { + "name": "markdown_backlog_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "markdown_backlog_workspace_kind_idx": { + "name": "markdown_backlog_workspace_kind_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "markdown_backlog_items_workspace_id_workspaces_id_fk": { + "name": "markdown_backlog_items_workspace_id_workspaces_id_fk", + "tableFrom": "markdown_backlog_items", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "markdown_backlog_items_parent_id_markdown_backlog_items_id_fk": { + "name": "markdown_backlog_items_parent_id_markdown_backlog_items_id_fk", + "tableFrom": "markdown_backlog_items", + "tableTo": "markdown_backlog_items", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cursor_sync_mappings": { + "name": "cursor_sync_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "backlog_item_id": { + "name": "backlog_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cursor_plan_id": { + "name": "cursor_plan_id", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "cursor_item_id": { + "name": "cursor_item_id", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "last_pulled_at": { + "name": "last_pulled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_pushed_at": { + "name": "last_pushed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_content_hash": { + "name": "sync_content_hash", + "type": "varchar(64)", + "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": { + "cursor_sync_mappings_backlog_item_id_unique": { + "name": "cursor_sync_mappings_backlog_item_id_unique", + "columns": [ + { + "expression": "backlog_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cursor_sync_mappings_workspace_id_idx": { + "name": "cursor_sync_mappings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cursor_sync_mappings_workspace_id_workspaces_id_fk": { + "name": "cursor_sync_mappings_workspace_id_workspaces_id_fk", + "tableFrom": "cursor_sync_mappings", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cursor_sync_mappings_backlog_item_id_markdown_backlog_items_id_fk": { + "name": "cursor_sync_mappings_backlog_item_id_markdown_backlog_items_id_fk", + "tableFrom": "cursor_sync_mappings", + "tableTo": "markdown_backlog_items", + "columnsFrom": [ + "backlog_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "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()" + } + }, + "indexes": { + "audit_log_workspace_id_created_at_idx": { + "name": "audit_log_workspace_id_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_user_id_idx": { + "name": "audit_log_actor_user_id_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspaces_id_fk": { + "name": "audit_log_workspace_id_workspaces_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_log_actor_user_id_users_id_fk": { + "name": "audit_log_actor_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_runs": { + "name": "agent_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "backlog_item_id": { + "name": "backlog_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_input": { + "name": "tokens_input", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_output": { + "name": "tokens_output", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_total": { + "name": "tokens_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_runs_workspace_id_started_at_idx": { + "name": "agent_runs_workspace_id_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_runs_backlog_item_id_started_at_idx": { + "name": "agent_runs_backlog_item_id_started_at_idx", + "columns": [ + { + "expression": "backlog_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_runs_workspace_id_workspaces_id_fk": { + "name": "agent_runs_workspace_id_workspaces_id_fk", + "tableFrom": "agent_runs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_runs_backlog_item_id_markdown_backlog_items_id_fk": { + "name": "agent_runs_backlog_item_id_markdown_backlog_items_id_fk", + "tableFrom": "agent_runs", + "tableTo": "markdown_backlog_items", + "columnsFrom": [ + "backlog_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_runs_actor_user_id_users_id_fk": { + "name": "agent_runs_actor_user_id_users_id_fk", + "tableFrom": "agent_runs", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_providers": { + "name": "model_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "alias": { + "name": "alias", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_env_var": { + "name": "api_key_env_var", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "extra_headers": { + "name": "extra_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "default_params": { + "name": "default_params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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": { + "model_providers_workspace_id_idx": { + "name": "model_providers_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "model_providers_workspace_id_alias_unique": { + "name": "model_providers_workspace_id_alias_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "alias", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "model_providers_workspace_id_workspaces_id_fk": { + "name": "model_providers_workspace_id_workspaces_id_fk", + "tableFrom": "model_providers", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_model_routes": { + "name": "role_model_routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "provider_alias_id": { + "name": "provider_alias_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "role_model_routes_workspace_id_idx": { + "name": "role_model_routes_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "role_model_routes_workspace_id_role_id_unique": { + "name": "role_model_routes_workspace_id_role_id_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "role_model_routes_provider_alias_id_idx": { + "name": "role_model_routes_provider_alias_id_idx", + "columns": [ + { + "expression": "provider_alias_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_model_routes_workspace_id_workspaces_id_fk": { + "name": "role_model_routes_workspace_id_workspaces_id_fk", + "tableFrom": "role_model_routes", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_model_routes_provider_alias_id_model_providers_id_fk": { + "name": "role_model_routes_provider_alias_id_model_providers_id_fk", + "tableFrom": "role_model_routes", + "tableTo": "model_providers", + "columnsFrom": [ + "provider_alias_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index e8bd8b4..6ed2ed6 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1780455865684, "tag": "0009_loving_rogue", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1783450794221, + "tag": "0010_legal_stryfe", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/database/package.json b/packages/database/package.json index 2ad297f..2a688ab 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -9,7 +9,8 @@ ".": "./src/index.ts", "./schema": "./src/schema/index.ts", "./client": "./src/client.ts", - "./markdown-backlog": "./src/markdown-backlog/index.ts" + "./markdown-backlog": "./src/markdown-backlog/index.ts", + "./model-routing": "./src/model-routing/index.ts" }, "scripts": { "db:generate": "drizzle-kit generate", @@ -23,6 +24,7 @@ "import:markdown-backlog": "tsx src/scripts/import-markdown-backlog.ts" }, "dependencies": { + "@tasks/shared": "workspace:*", "drizzle-orm": "^0.38.0", "postgres": "^3.4.5", "yaml": "^2.8.3", diff --git a/packages/database/src/model-routing/index.ts b/packages/database/src/model-routing/index.ts new file mode 100644 index 0000000..b109daf --- /dev/null +++ b/packages/database/src/model-routing/index.ts @@ -0,0 +1,2 @@ +export { seedDefaultRoleRoutes } from "./seed-default-role-routes"; +export type { SeedDefaultRoleRoutesResult } from "./seed-default-role-routes"; diff --git a/packages/database/src/model-routing/seed-default-role-routes.ts b/packages/database/src/model-routing/seed-default-role-routes.ts new file mode 100644 index 0000000..403dc22 --- /dev/null +++ b/packages/database/src/model-routing/seed-default-role-routes.ts @@ -0,0 +1,67 @@ +import { and, eq } from "drizzle-orm"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; + +import { PIPELINE_DEFAULT_ROLE_ROUTES } from "@tasks/shared/model-routing"; + +import type * as schema from "../schema/index"; +import { modelProviders, roleModelRoutes } from "../schema/index"; + +type Database = PostgresJsDatabase; + +export type SeedDefaultRoleRoutesResult = { + inserted: number; + skipped: number; +}; + +/** + * Inserts pipeline default role → alias routes for a workspace when matching + * provider aliases already exist. Does not create providers. + */ +export async function seedDefaultRoleRoutes( + db: Database, + workspaceId: string, +): Promise { + const providers = await db + .select({ id: modelProviders.id, alias: modelProviders.alias }) + .from(modelProviders) + .where(eq(modelProviders.workspaceId, workspaceId)); + + const aliasToId = new Map(providers.map((row) => [row.alias, row.id])); + let inserted = 0; + let skipped = 0; + let sortOrder = 0; + + for (const [roleId, alias] of Object.entries(PIPELINE_DEFAULT_ROLE_ROUTES)) { + const providerId = aliasToId.get(alias); + if (!providerId) { + skipped++; + continue; + } + + const [existing] = await db + .select({ id: roleModelRoutes.id }) + .from(roleModelRoutes) + .where( + and( + eq(roleModelRoutes.workspaceId, workspaceId), + eq(roleModelRoutes.roleId, roleId), + ), + ) + .limit(1); + + if (existing) { + skipped++; + continue; + } + + await db.insert(roleModelRoutes).values({ + workspaceId, + roleId, + providerAliasId: providerId, + sortOrder: sortOrder++, + }); + inserted++; + } + + return { inserted, skipped }; +} diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts index 364958e..0e57ad5 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -13,3 +13,5 @@ export * from "./markdown_backlog"; export * from "./cursor_sync"; export * from "./audit"; export * from "./agent_runs"; +export * from "./model_providers"; +export * from "./role_model_routes"; diff --git a/packages/database/src/schema/model_providers.ts b/packages/database/src/schema/model_providers.ts new file mode 100644 index 0000000..49edae2 --- /dev/null +++ b/packages/database/src/schema/model_providers.ts @@ -0,0 +1,51 @@ +import { + boolean, + index, + jsonb, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, + varchar, + integer, +} from "drizzle-orm/pg-core"; + +import { workspaces } from "./workspaces"; + +/** Workspace-scoped model provider alias — secrets live in env vars, not here. */ +export const modelProviders = pgTable( + "model_providers", + { + id: uuid("id").primaryKey().defaultRandom(), + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + alias: varchar("alias", { length: 120 }).notNull(), + displayName: varchar("display_name", { length: 200 }).notNull(), + providerType: varchar("provider_type", { length: 40 }).notNull(), + modelId: varchar("model_id", { length: 200 }).notNull(), + baseUrl: text("base_url"), + apiKeyEnvVar: varchar("api_key_env_var", { length: 120 }), + extraHeaders: jsonb("extra_headers").$type | null>(), + defaultParams: jsonb("default_params").$type<{ + temperature?: number; + maxTokens?: number; + } | null>(), + metadata: jsonb("metadata").$type | null>(), + enabled: boolean("enabled").notNull().default(true), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => ({ + workspaceIdIdx: index("model_providers_workspace_id_idx").on(table.workspaceId), + workspaceAliasUnique: uniqueIndex("model_providers_workspace_id_alias_unique").on( + table.workspaceId, + table.alias, + ), + }), +); diff --git a/packages/database/src/schema/relations.ts b/packages/database/src/schema/relations.ts index 279497b..3bbbcc7 100644 --- a/packages/database/src/schema/relations.ts +++ b/packages/database/src/schema/relations.ts @@ -19,6 +19,8 @@ import { cursorSyncMappings } from "./cursor_sync"; import { workspaces, workspaceInvites } from "./workspaces"; import { auditLog } from "./audit"; import { agentRuns } from "./agent_runs"; +import { modelProviders } from "./model_providers"; +import { roleModelRoutes } from "./role_model_routes"; export const objectRelations = pgTable( "object_relations", @@ -116,6 +118,8 @@ export const workspacesRelations = relations(workspaces, ({ one, many }) => ({ objectTypeDefs: many(objectTypeDefs), propertyDefinitions: many(propertyDefinitions), markdownBacklogItems: many(markdownBacklogItems), + modelProviders: many(modelProviders), + roleModelRoutes: many(roleModelRoutes), })); export const objectsRelations = relations(objects, ({ one, many }) => ({ @@ -264,3 +268,22 @@ export const cursorSyncMappingsRelations = relations(cursorSyncMappings, ({ one references: [markdownBacklogItems.id], }), })); + +export const modelProvidersRelations = relations(modelProviders, ({ one, many }) => ({ + workspace: one(workspaces, { + fields: [modelProviders.workspaceId], + references: [workspaces.id], + }), + roleRoutes: many(roleModelRoutes), +})); + +export const roleModelRoutesRelations = relations(roleModelRoutes, ({ one }) => ({ + workspace: one(workspaces, { + fields: [roleModelRoutes.workspaceId], + references: [workspaces.id], + }), + provider: one(modelProviders, { + fields: [roleModelRoutes.providerAliasId], + references: [modelProviders.id], + }), +})); diff --git a/packages/database/src/schema/role_model_routes.ts b/packages/database/src/schema/role_model_routes.ts new file mode 100644 index 0000000..f974063 --- /dev/null +++ b/packages/database/src/schema/role_model_routes.ts @@ -0,0 +1,45 @@ +import { + boolean, + index, + integer, + pgTable, + timestamp, + uniqueIndex, + uuid, + varchar, +} from "drizzle-orm/pg-core"; + +import { workspaces } from "./workspaces"; +import { modelProviders } from "./model_providers"; + +/** Maps pipeline role_id strings to a workspace provider alias row. */ +export const roleModelRoutes = pgTable( + "role_model_routes", + { + id: uuid("id").primaryKey().defaultRandom(), + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + roleId: varchar("role_id", { length: 120 }).notNull(), + providerAliasId: uuid("provider_alias_id") + .notNull() + .references(() => modelProviders.id, { onDelete: "cascade" }), + enabled: boolean("enabled").notNull().default(true), + sortOrder: integer("sort_order").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => ({ + workspaceIdIdx: index("role_model_routes_workspace_id_idx").on(table.workspaceId), + workspaceRoleUnique: uniqueIndex( + "role_model_routes_workspace_id_role_id_unique", + ).on(table.workspaceId, table.roleId), + providerAliasIdIdx: index("role_model_routes_provider_alias_id_idx").on( + table.providerAliasId, + ), + }), +); diff --git a/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-db-model-providers-migration.md b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-db-model-providers-migration.md index 49d761d..4484413 100644 --- a/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-db-model-providers-migration.md +++ b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-db-model-providers-migration.md @@ -4,7 +4,7 @@ slug: db-model-providers-migration title: Drizzle schema and migration for model_providers and role_model_routes plan_slug: automation-foundation epic_slug: model-provider-registry -status: ready +status: done priority: P1 tenant_id: global owner: unassigned @@ -39,9 +39,9 @@ Add `model_providers` and `role_model_routes` tables with workspace scoping and ## Subtasks -- [ ] Define Drizzle tables -- [ ] Generate and commit migration -- [ ] Verify `workspace_id` index on both tables +- [x] Define Drizzle tables +- [x] Generate and commit migration +- [x] Verify `workspace_id` index on both tables ## Estimation @@ -49,9 +49,9 @@ S ## Acceptance criteria -- [ ] Migration applies cleanly with `pnpm db:migrate` -- [ ] Every query example in epic doc filters by `workspace_id` -- [ ] Types align with `packages/shared` zod schemas +- [x] Migration applies cleanly with `pnpm db:migrate` +- [x] Every query example in epic doc filters by `workspace_id` +- [x] Types align with `packages/shared` zod schemas ## Links diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c925166..6e029dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -354,6 +354,9 @@ importers: packages/database: dependencies: + '@tasks/shared': + specifier: workspace:* + version: link:../shared drizzle-orm: specifier: ^0.38.0 version: 0.38.4(@opentelemetry/api@1.9.0)(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4) From dfe73b3647596c07b4b43ee0f891f5786e084489 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 7 Jul 2026 14:07:47 -0500 Subject: [PATCH 5/7] =?UTF-8?q?feat(automation):=20complete=20model=20prov?= =?UTF-8?q?ider=20registry=20epic=20(tasks=203=E2=80=936)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ai.models tRPC router with provider CRUD, role routes, testProvider, and resolveForRole; Vercel AI SDK client factory in packages/ai; Settings pages for providers and role routing; agent_runs model metadata columns with MCP complete_task fields and runs UI column. Co-authored-by: Cursor --- apps/mcp-server/src/tools/complete-task.ts | 22 + .../settings/ai/providers/page.tsx | 419 ++ .../settings/ai/role-routing/page.tsx | 260 ++ .../[workspaceSlug]/settings/runs/page.tsx | 20 +- apps/web/components/layout/top-header.tsx | 19 + .../components/settings/settings-ai-nav.tsx | 39 + apps/web/server/routers/ai-models.ts | 533 +++ apps/web/server/routers/ai.ts | 3 + apps/web/server/routers/runs.ts | 8 + packages/ai/package.json | 1 + packages/ai/src/index.ts | 4 + packages/ai/src/providers/resolve-client.ts | 48 + .../migrations/0011_cool_captain_america.sql | 4 + .../migrations/meta/0011_snapshot.json | 3375 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + packages/database/src/schema/agent_runs.ts | 4 + .../Epic-model-provider-registry.md | 2 +- .../Task-agent-runs-model-logging.md | 2 +- .../Task-settings-ai-providers-ui.md | 2 +- .../Task-settings-ai-role-routing-ui.md | 2 +- .../Task-trpc-models-router.md | 2 +- pnpm-lock.yaml | 15 + 22 files changed, 4785 insertions(+), 6 deletions(-) create mode 100644 apps/web/app/(app)/[workspaceSlug]/settings/ai/providers/page.tsx create mode 100644 apps/web/app/(app)/[workspaceSlug]/settings/ai/role-routing/page.tsx create mode 100644 apps/web/components/settings/settings-ai-nav.tsx create mode 100644 apps/web/server/routers/ai-models.ts create mode 100644 packages/ai/src/providers/resolve-client.ts create mode 100644 packages/database/migrations/0011_cool_captain_america.sql create mode 100644 packages/database/migrations/meta/0011_snapshot.json diff --git a/apps/mcp-server/src/tools/complete-task.ts b/apps/mcp-server/src/tools/complete-task.ts index 1b49672..1ed44a3 100644 --- a/apps/mcp-server/src/tools/complete-task.ts +++ b/apps/mcp-server/src/tools/complete-task.ts @@ -1,5 +1,6 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; +import { modelTierSchema, providerTypeSchema } from "@tasks/shared/model-routing"; import { db } from "../db.js"; import { eq } from "../drizzle.js"; import { agentRuns, auditLog, markdownBacklogItems } from "../schema.js"; @@ -44,6 +45,15 @@ const completeTaskInputSchema = z.object({ .describe( "Late-bound model handle. Same merge semantics as `client` — pass this when the model that closed the session differs from the one that opened it.", ), + modelAlias: z + .string() + .min(1) + .max(120) + .optional() + .describe("Registry alias used for this session (e.g. qwen-coder)."), + modelTier: modelTierSchema.optional(), + providerType: providerTypeSchema.optional(), + modelId: z.string().min(1).max(200).optional(), }); type CompleteTaskInput = z.infer; @@ -115,6 +125,10 @@ export function registerCompleteTaskTool(mcp: McpServer): void { const incomingMeta: Record = {}; if (input.client) incomingMeta.client = input.client; if (input.model) incomingMeta.model = input.model; + if (input.modelAlias) incomingMeta.modelAlias = input.modelAlias; + if (input.modelTier) incomingMeta.modelTier = input.modelTier; + if (input.providerType) incomingMeta.providerType = input.providerType; + if (input.modelId) incomingMeta.modelId = input.modelId; const hasIncomingMeta = Object.keys(incomingMeta).length > 0; const mergedMeta = hasIncomingMeta ? { ...(run.metadata ?? {}), ...incomingMeta } @@ -131,6 +145,14 @@ export function registerCompleteTaskTool(mcp: McpServer): void { tokensTotal, notes: input.notes ?? null, error: input.error ?? null, + ...(input.modelAlias !== undefined + ? { modelAlias: input.modelAlias } + : {}), + ...(input.modelTier !== undefined ? { modelTier: input.modelTier } : {}), + ...(input.providerType !== undefined + ? { providerType: input.providerType } + : {}), + ...(input.modelId !== undefined ? { modelId: input.modelId } : {}), ...(hasIncomingMeta ? { metadata: mergedMeta } : {}), }) .where(eq(agentRuns.id, input.runId)); diff --git a/apps/web/app/(app)/[workspaceSlug]/settings/ai/providers/page.tsx b/apps/web/app/(app)/[workspaceSlug]/settings/ai/providers/page.tsx new file mode 100644 index 0000000..f83f8b4 --- /dev/null +++ b/apps/web/app/(app)/[workspaceSlug]/settings/ai/providers/page.tsx @@ -0,0 +1,419 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { Bot, Loader2, Plus, Sparkles, X } from "lucide-react"; +import { useParams } from "next/navigation"; + +import { SettingsAiNav } from "@/components/settings/settings-ai-nav"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { api } from "@/lib/trpc"; +import type { ProviderType } from "@tasks/shared/model-routing"; + +const PROVIDER_TYPES: ProviderType[] = [ + "anthropic", + "openai", + "google", + "openai_compatible", + "azure_openai", +]; + +type ProviderForm = { + alias: string; + displayName: string; + providerType: ProviderType; + modelId: string; + baseUrl: string; + apiKeyEnvVar: string; + enabled: boolean; +}; + +const EMPTY_FORM: ProviderForm = { + alias: "", + displayName: "", + providerType: "openai_compatible", + modelId: "", + baseUrl: "", + apiKeyEnvVar: "", + enabled: true, +}; + +const PRESETS: Array<{ label: string; form: Partial }> = [ + { + label: "Ollama local", + form: { + alias: "qwen-coder", + displayName: "Qwen Coder (Ollama)", + providerType: "openai_compatible", + modelId: "qwen2.5-coder:32b", + baseUrl: "http://127.0.0.1:11434/v1", + apiKeyEnvVar: "OLLAMA_API_KEY", + }, + }, + { + label: "MLX local", + form: { + alias: "qwen-coder-mlx", + displayName: "Qwen Coder (MLX)", + providerType: "openai_compatible", + modelId: "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit", + baseUrl: "http://127.0.0.1:8000/v1", + apiKeyEnvVar: "MLX_API_KEY", + }, + }, + { + label: "Anthropic", + form: { + alias: "claude-planning", + displayName: "Claude Planning", + providerType: "anthropic", + modelId: "claude-sonnet-4-20250514", + apiKeyEnvVar: "ANTHROPIC_API_KEY", + }, + }, + { + label: "Gemini", + form: { + alias: "gemini-flash", + displayName: "Gemini Flash", + providerType: "google", + modelId: "gemini-2.0-flash", + apiKeyEnvVar: "GOOGLE_GENERATIVE_AI_API_KEY", + }, + }, + { + label: "NVIDIA NIM", + form: { + alias: "nim-llama-70b", + displayName: "NIM Llama 70B", + providerType: "openai_compatible", + modelId: "meta/llama-3.1-70b-instruct", + baseUrl: "https://integrate.api.nvidia.com/v1", + apiKeyEnvVar: "NVIDIA_API_KEY", + }, + }, +]; + +export default function AiProvidersSettingsPage() { + const params = useParams(); + const workspaceSlug = params?.workspaceSlug as string | undefined; + const utils = api.useUtils(); + + const listQuery = api.ai.models.listProviders.useQuery( + { workspace: workspaceSlug ?? "" }, + { enabled: Boolean(workspaceSlug) }, + ); + + const [dialogOpen, setDialogOpen] = React.useState(false); + const [editingId, setEditingId] = React.useState(null); + const [form, setForm] = React.useState(EMPTY_FORM); + const [testResult, setTestResult] = React.useState>({}); + + const createMut = api.ai.models.createProvider.useMutation({ + onSuccess: () => { + void utils.ai.models.listProviders.invalidate(); + setDialogOpen(false); + }, + }); + const updateMut = api.ai.models.updateProvider.useMutation({ + onSuccess: () => { + void utils.ai.models.listProviders.invalidate(); + setDialogOpen(false); + }, + }); + const deleteMut = api.ai.models.deleteProvider.useMutation({ + onSuccess: () => void utils.ai.models.listProviders.invalidate(), + }); + const testMut = api.ai.models.testProvider.useMutation({ + onSuccess: (result, vars) => { + setTestResult((prev) => ({ + ...prev, + [vars.providerId]: result.ok + ? `OK in ${result.latencyMs}ms` + : `Failed: ${result.error ?? "unknown"}`, + })); + }, + }); + + const canManage = listQuery.data?.canManage ?? false; + const providers = listQuery.data?.providers ?? []; + + const openCreate = () => { + setEditingId(null); + setForm(EMPTY_FORM); + setDialogOpen(true); + }; + + const openEdit = (p: (typeof providers)[number]) => { + setEditingId(p.id); + setForm({ + alias: p.alias, + displayName: p.displayName, + providerType: p.providerType, + modelId: p.modelId, + baseUrl: p.baseUrl ?? "", + apiKeyEnvVar: p.apiKeyEnvVar ?? "", + enabled: p.enabled, + }); + setDialogOpen(true); + }; + + const submit = () => { + if (!workspaceSlug) return; + const payload = { + workspace: workspaceSlug, + alias: form.alias, + displayName: form.displayName, + providerType: form.providerType, + modelId: form.modelId, + baseUrl: form.baseUrl.trim() || null, + apiKeyEnvVar: form.apiKeyEnvVar.trim() || null, + enabled: form.enabled, + }; + if (editingId) { + updateMut.mutate({ ...payload, id: editingId }); + } else { + createMut.mutate(payload); + } + }; + + if (!workspaceSlug) { + return

No workspace selected.

; + } + + return ( +
+
+
+
+ +
+
+

AI model providers

+

+ Registry aliases for orchestrator and pipeline roles. API keys live in server env + vars only. +

+
+
+ {canManage ? ( + + ) : null} +
+ + + + {listQuery.isLoading ? ( +
+ +
+ ) : listQuery.error ? ( +

{listQuery.error.message}

+ ) : providers.length === 0 ? ( +
+ + No providers yet.{" "} + {canManage ? "Add Ollama, MLX, or cloud aliases to get started." : null} +
+ ) : ( +
+ + + + + + + + + {canManage ? ( + + ) : null} + + + + {providers.map((p) => ( + + + + + + + {canManage ? ( + + ) : null} + + ))} + +
AliasTypeModelBase URLOnActions
+
{p.alias}
+
{p.displayName}
+
{p.providerType} + {p.modelId} + + {p.baseUrl ?? "—"} + {p.enabled ? "yes" : "no"} + + + + {testResult[p.id] ? ( +
+ {testResult[p.id]} +
+ ) : null} +
+
+ )} + + + + + +
+ + {editingId ? "Edit provider" : "Add provider"} + + + + +
+ +
+ {PRESETS.map((preset) => ( + + ))} +
+ +
+ + setForm((f) => ({ ...f, alias: e.target.value }))} + /> + + + setForm((f) => ({ ...f, displayName: e.target.value }))} + /> + + + + + + setForm((f) => ({ ...f, modelId: e.target.value }))} + /> + + {form.providerType === "openai_compatible" || + form.providerType === "azure_openai" ? ( + + setForm((f) => ({ ...f, baseUrl: e.target.value }))} + /> + + ) : null} + + setForm((f) => ({ ...f, apiKeyEnvVar: e.target.value }))} + /> +

+ Secret value is read from the server environment at inference time — never stored + in the database. +

+
+ +
+ +
+ + +
+
+
+
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/apps/web/app/(app)/[workspaceSlug]/settings/ai/role-routing/page.tsx b/apps/web/app/(app)/[workspaceSlug]/settings/ai/role-routing/page.tsx new file mode 100644 index 0000000..d4c2968 --- /dev/null +++ b/apps/web/app/(app)/[workspaceSlug]/settings/ai/role-routing/page.tsx @@ -0,0 +1,260 @@ +"use client"; + +import * as React from "react"; +import { GitBranch, Loader2, Plus, Route } from "lucide-react"; +import { useParams } from "next/navigation"; + +import { SettingsAiNav } from "@/components/settings/settings-ai-nav"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { api } from "@/lib/trpc"; + +const ROLE_LABELS: Record = { + "role-conductor": "Conductor", + "role-architect": "Architect", + "role-ia-architect": "IA architect", + "role-ux-reviewer": "UX reviewer", + "role-implementer": "Implementer", + "role-reviewer": "Reviewer", + "role-design-system-auditor": "Design system auditor", + "role-a11y-auditor": "A11y auditor", + "role-doc-writer": "Doc writer", + "gate-copilot": "Gate co-pilot", + "chat-default": "Chat default", +}; + +function roleLabel(roleId: string): string { + return ROLE_LABELS[roleId] ?? roleId; +} + +function readTier(metadata: unknown): string | null { + if (!metadata || typeof metadata !== "object") return null; + const tier = (metadata as { tier?: unknown }).tier; + return typeof tier === "string" ? tier : null; +} + +export default function AiRoleRoutingSettingsPage() { + const params = useParams(); + const workspaceSlug = params?.workspaceSlug as string | undefined; + const utils = api.useUtils(); + + const routesQuery = api.ai.models.listRoleRoutes.useQuery( + { workspace: workspaceSlug ?? "" }, + { enabled: Boolean(workspaceSlug) }, + ); + const providersQuery = api.ai.models.listProviders.useQuery( + { workspace: workspaceSlug ?? "" }, + { enabled: Boolean(workspaceSlug) }, + ); + + const [newRoleId, setNewRoleId] = React.useState(""); + const [newProviderId, setNewProviderId] = React.useState(""); + + const upsertMut = api.ai.models.upsertRoleRoute.useMutation({ + onSuccess: () => { + void utils.ai.models.listRoleRoutes.invalidate(); + setNewRoleId(""); + setNewProviderId(""); + }, + }); + const deleteMut = api.ai.models.deleteRoleRoute.useMutation({ + onSuccess: () => void utils.ai.models.listRoleRoutes.invalidate(), + }); + const resetMut = api.ai.models.resetPipelineDefaults.useMutation({ + onSuccess: () => void utils.ai.models.listRoleRoutes.invalidate(), + }); + + const canManage = routesQuery.data?.canManage ?? false; + const routes = routesQuery.data?.routes ?? []; + const enabledProviders = + providersQuery.data?.providers.filter((p) => p.enabled) ?? []; + + if (!workspaceSlug) { + return

No workspace selected.

; + } + + return ( +
+
+
+
+ +
+
+

Role model routing

+

+ Map pipeline role_id strings to provider aliases. + Convoy model_policy overrides these at dispatch + time — see docs/adr-model-routing.md. +

+
+
+ {canManage ? ( + + ) : null} +
+ + + + {routesQuery.isLoading ? ( +
+ +
+ ) : ( +
+ + + + + + + + {canManage ? ( + + ) : null} + + + + {routes.length === 0 ? ( + + + + ) : ( + routes.map((route) => { + const tier = readTier(route.providerMetadata); + return ( + + + + + + {canManage ? ( + + ) : null} + + ); + }) + )} + +
RoleProvider aliasTierOnActions
+ No routes configured. Add providers first, then map roles. +
+
{roleLabel(route.roleId)}
+
+ {route.roleId} +
+
{route.providerAlias} + {tier ? ( + + {tier} + + ) : ( + "—" + )} + + {route.enabled && route.providerEnabled ? "yes" : "no"} + + + +
+
+ )} + + {canManage ? ( +
+
+ + Add custom role +
+
+
+ + setNewRoleId(e.target.value)} + /> +
+
+ + +
+ +
+
+ ) : null} +
+ ); +} diff --git a/apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx b/apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx index f23b2f2..9d45c60 100644 --- a/apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx +++ b/apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx @@ -105,6 +105,19 @@ function describeOutcome( * null and ignore values that aren't strings (defensive against * hand-inserted rows from psql). */ +function describeModel(row: { + modelAlias: string | null; + modelTier: string | null; + modelId: string | null; + metadata: unknown; +}): string | null { + if (row.modelAlias) { + const tier = row.modelTier ? ` (${row.modelTier})` : ""; + return `${row.modelAlias}${tier}`; + } + return describeAgent(row.metadata).label; +} + function describeAgent(metadata: unknown): { client: string | null; model: string | null; @@ -221,6 +234,7 @@ export default function RunsPage() { Started Duration Actor + Model Outcome Tokens @@ -229,6 +243,7 @@ export default function RunsPage() { {runsQuery.data.rows.map((row) => { const { label, key } = describeOutcome(row.outcome, row.finishedAt); const agent = describeAgent(row.metadata); + const modelLabel = describeModel(row); const isOpen = expanded === row.id; const hasDetail = Boolean(row.notes || row.error); return ( @@ -289,6 +304,9 @@ export default function RunsPage() { ) : null} + + {modelLabel ?? "—"} + - + {row.notes ? (
diff --git a/apps/web/components/layout/top-header.tsx b/apps/web/components/layout/top-header.tsx index 3a71207..6f67233 100644 --- a/apps/web/components/layout/top-header.tsx +++ b/apps/web/components/layout/top-header.tsx @@ -19,6 +19,7 @@ import { ScrollText, Search, Settings, + Sparkles, User, Users, Zap, @@ -144,6 +145,24 @@ export function TopHeader({ onOpenSearch, onQuickAction }: TopHeaderProps) { Audit log + + workspaceId && router.push(`/${workspaceId}/settings/ai/providers`) + } + > + + AI providers + + + workspaceId && router.push(`/${workspaceId}/settings/ai/role-routing`) + } + > + + Role routing + diff --git a/apps/web/components/settings/settings-ai-nav.tsx b/apps/web/components/settings/settings-ai-nav.tsx new file mode 100644 index 0000000..4156c08 --- /dev/null +++ b/apps/web/components/settings/settings-ai-nav.tsx @@ -0,0 +1,39 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { cn } from "@/lib/utils"; + +const LINKS = [ + { segment: "providers", label: "Providers" }, + { segment: "role-routing", label: "Role routing" }, +] as const; + +export function SettingsAiNav({ workspaceSlug }: { workspaceSlug: string }) { + const pathname = usePathname(); + const base = `/${workspaceSlug}/settings/ai`; + + return ( + + ); +} diff --git a/apps/web/server/routers/ai-models.ts b/apps/web/server/routers/ai-models.ts new file mode 100644 index 0000000..7e5def3 --- /dev/null +++ b/apps/web/server/routers/ai-models.ts @@ -0,0 +1,533 @@ +import { createLanguageModelFromResolved } from "@tasks/ai"; +import { seedDefaultRoleRoutes } from "@tasks/database/model-routing"; +import { + modelProviders, + roleModelRoutes, +} from "@tasks/database/schema"; +import { + convoyModelPolicySchema, + providerTypeSchema, + resolveModel, + type ModelProvider, + type RoleModelRoute, +} from "@tasks/shared/model-routing"; +import { TRPCError } from "@trpc/server"; +import { generateText } from "ai"; +import { and, asc, eq } from "drizzle-orm"; +import { z } from "zod"; + +import { rateLimit } from "@/server/lib/rate-limit"; +import { router, workspaceProcedure } from "@/server/trpc"; +import type { WorkspaceProcedureContext } from "@/server/trpc"; + +type ProviderRow = typeof modelProviders.$inferSelect; +type RouteRow = typeof roleModelRoutes.$inferSelect; + +function assertWorkspaceAdmin(ctx: WorkspaceProcedureContext): void { + if (ctx.workspace.role !== "owner" && ctx.workspace.role !== "admin") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only workspace owners and admins can manage model providers.", + }); + } +} + +function canManageWorkspace(ctx: WorkspaceProcedureContext): boolean { + return ctx.workspace.role === "owner" || ctx.workspace.role === "admin"; +} + +function mapProviderRow(row: ProviderRow): ModelProvider { + return { + id: row.id, + workspaceId: row.workspaceId, + alias: row.alias, + displayName: row.displayName, + providerType: providerTypeSchema.parse(row.providerType), + modelId: row.modelId, + baseUrl: row.baseUrl, + apiKeyEnvVar: row.apiKeyEnvVar, + extraHeaders: row.extraHeaders, + defaultParams: row.defaultParams, + metadata: row.metadata, + enabled: row.enabled, + }; +} + +function mapRouteRow(row: RouteRow): RoleModelRoute { + return { + id: row.id, + workspaceId: row.workspaceId, + roleId: row.roleId, + providerAliasId: row.providerAliasId, + enabled: row.enabled, + sortOrder: row.sortOrder, + }; +} + +function readApiKey(envVarName: string): string | undefined { + return process.env[envVarName]?.trim() || undefined; +} + +function validateProviderFields(input: { + providerType: z.infer; + baseUrl?: string | null; +}): void { + if (input.providerType === "openai_compatible" && !input.baseUrl?.trim()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "baseUrl is required for openai_compatible providers.", + }); + } +} + +function providerToResolved(row: ProviderRow): ReturnType { + const provider = mapProviderRow(row); + const result = resolveModel({ + roleId: "test", + providers: [provider], + routes: [], + briefRecommendedModel: provider.alias, + getApiKey: readApiKey, + }); + return result; +} + +const providerFieldsSchema = z.object({ + alias: z.string().min(1).max(120), + displayName: z.string().min(1).max(200), + providerType: providerTypeSchema, + modelId: z.string().min(1).max(200), + baseUrl: z.string().url().nullable().optional(), + apiKeyEnvVar: z.string().min(1).max(120).nullable().optional(), + extraHeaders: z.record(z.string()).nullable().optional(), + defaultParams: z + .object({ + temperature: z.number().min(0).max(2).optional(), + maxTokens: z.number().int().positive().optional(), + }) + .nullable() + .optional(), + metadata: z.record(z.unknown()).nullable().optional(), + enabled: z.boolean().optional(), +}); + +export const aiModelsRouter = router({ + listProviders: workspaceProcedure.query(async ({ ctx }) => { + const rows = await ctx.db + .select() + .from(modelProviders) + .where(eq(modelProviders.workspaceId, ctx.workspace.id)) + .orderBy(asc(modelProviders.alias)); + + return { + providers: rows.map(mapProviderRow), + canManage: canManageWorkspace(ctx), + }; + }), + + createProvider: workspaceProcedure + .input(providerFieldsSchema) + .mutation(async ({ ctx, input }) => { + assertWorkspaceAdmin(ctx); + validateProviderFields(input); + + try { + const [created] = await ctx.db + .insert(modelProviders) + .values({ + workspaceId: ctx.workspace.id, + alias: input.alias.trim(), + displayName: input.displayName.trim(), + providerType: input.providerType, + modelId: input.modelId.trim(), + baseUrl: input.baseUrl?.trim() || null, + apiKeyEnvVar: input.apiKeyEnvVar?.trim() || null, + extraHeaders: input.extraHeaders ?? null, + defaultParams: input.defaultParams ?? null, + metadata: input.metadata ?? null, + enabled: input.enabled ?? true, + updatedAt: new Date(), + }) + .returning(); + + if (!created) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create provider.", + }); + } + + return mapProviderRow(created); + } catch (e) { + if (isUniqueViolation(e)) { + throw new TRPCError({ + code: "CONFLICT", + message: `Alias "${input.alias}" already exists in this workspace.`, + }); + } + throw e; + } + }), + + updateProvider: workspaceProcedure + .input( + providerFieldsSchema.partial().extend({ + id: z.string().uuid(), + }), + ) + .mutation(async ({ ctx, input }) => { + assertWorkspaceAdmin(ctx); + + const [existing] = await ctx.db + .select() + .from(modelProviders) + .where( + and( + eq(modelProviders.id, input.id), + eq(modelProviders.workspaceId, ctx.workspace.id), + ), + ) + .limit(1); + + if (!existing) { + throw new TRPCError({ code: "NOT_FOUND", message: "Provider not found." }); + } + + const nextType = input.providerType ?? existing.providerType; + const nextBaseUrl = + input.baseUrl !== undefined ? input.baseUrl : existing.baseUrl; + validateProviderFields({ + providerType: providerTypeSchema.parse(nextType), + baseUrl: nextBaseUrl, + }); + + try { + const [updated] = await ctx.db + .update(modelProviders) + .set({ + ...(input.alias !== undefined ? { alias: input.alias.trim() } : {}), + ...(input.displayName !== undefined + ? { displayName: input.displayName.trim() } + : {}), + ...(input.providerType !== undefined + ? { providerType: input.providerType } + : {}), + ...(input.modelId !== undefined ? { modelId: input.modelId.trim() } : {}), + ...(input.baseUrl !== undefined + ? { baseUrl: input.baseUrl?.trim() || null } + : {}), + ...(input.apiKeyEnvVar !== undefined + ? { apiKeyEnvVar: input.apiKeyEnvVar?.trim() || null } + : {}), + ...(input.extraHeaders !== undefined + ? { extraHeaders: input.extraHeaders } + : {}), + ...(input.defaultParams !== undefined + ? { defaultParams: input.defaultParams } + : {}), + ...(input.metadata !== undefined ? { metadata: input.metadata } : {}), + ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), + updatedAt: new Date(), + }) + .where( + and( + eq(modelProviders.id, input.id), + eq(modelProviders.workspaceId, ctx.workspace.id), + ), + ) + .returning(); + + if (!updated) { + throw new TRPCError({ code: "NOT_FOUND", message: "Provider not found." }); + } + + return mapProviderRow(updated); + } catch (e) { + if (isUniqueViolation(e)) { + throw new TRPCError({ + code: "CONFLICT", + message: "Another provider already uses that alias.", + }); + } + throw e; + } + }), + + deleteProvider: workspaceProcedure + .input(z.object({ id: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + assertWorkspaceAdmin(ctx); + const [deleted] = await ctx.db + .delete(modelProviders) + .where( + and( + eq(modelProviders.id, input.id), + eq(modelProviders.workspaceId, ctx.workspace.id), + ), + ) + .returning({ id: modelProviders.id }); + + if (!deleted) { + throw new TRPCError({ code: "NOT_FOUND", message: "Provider not found." }); + } + + return { id: deleted.id }; + }), + + testProvider: workspaceProcedure + .input(z.object({ providerId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + assertWorkspaceAdmin(ctx); + + const limit = rateLimit({ + key: `ai:testProvider:${ctx.workspace.id}`, + limit: 10, + windowMs: 60_000, + }); + if (!limit.allowed) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Test rate limit exceeded. Try again shortly.", + }); + } + + const [row] = await ctx.db + .select() + .from(modelProviders) + .where( + and( + eq(modelProviders.id, input.providerId), + eq(modelProviders.workspaceId, ctx.workspace.id), + ), + ) + .limit(1); + + if (!row) { + throw new TRPCError({ code: "NOT_FOUND", message: "Provider not found." }); + } + + const resolved = providerToResolved(row); + if (!resolved.ok) { + return { + ok: false as const, + latencyMs: 0, + error: resolved.error.message, + }; + } + + const client = createLanguageModelFromResolved(resolved.model); + if (!client.ok) { + return { ok: false as const, latencyMs: 0, error: client.reason }; + } + + const started = Date.now(); + try { + await generateText({ + model: client.model, + prompt: "Reply with exactly: OK", + maxTokens: 5, + temperature: 0, + }); + return { + ok: true as const, + latencyMs: Date.now() - started, + }; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return { + ok: false as const, + latencyMs: Date.now() - started, + error: message, + }; + } + }), + + listRoleRoutes: workspaceProcedure.query(async ({ ctx }) => { + const routes = await ctx.db + .select({ + route: roleModelRoutes, + providerAlias: modelProviders.alias, + providerEnabled: modelProviders.enabled, + providerMetadata: modelProviders.metadata, + }) + .from(roleModelRoutes) + .innerJoin( + modelProviders, + eq(roleModelRoutes.providerAliasId, modelProviders.id), + ) + .where(eq(roleModelRoutes.workspaceId, ctx.workspace.id)) + .orderBy(asc(roleModelRoutes.sortOrder), asc(roleModelRoutes.roleId)); + + return { + routes: routes.map((row) => ({ + ...mapRouteRow(row.route), + providerAlias: row.providerAlias, + providerEnabled: row.providerEnabled, + providerMetadata: row.providerMetadata, + })), + canManage: canManageWorkspace(ctx), + }; + }), + + upsertRoleRoute: workspaceProcedure + .input( + z.object({ + roleId: z.string().min(1).max(120), + providerAliasId: z.string().uuid(), + enabled: z.boolean().optional(), + sortOrder: z.number().int().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + assertWorkspaceAdmin(ctx); + + const [provider] = await ctx.db + .select({ id: modelProviders.id }) + .from(modelProviders) + .where( + and( + eq(modelProviders.id, input.providerAliasId), + eq(modelProviders.workspaceId, ctx.workspace.id), + ), + ) + .limit(1); + + if (!provider) { + throw new TRPCError({ code: "NOT_FOUND", message: "Provider not found." }); + } + + const [existing] = await ctx.db + .select({ id: roleModelRoutes.id }) + .from(roleModelRoutes) + .where( + and( + eq(roleModelRoutes.workspaceId, ctx.workspace.id), + eq(roleModelRoutes.roleId, input.roleId), + ), + ) + .limit(1); + + if (existing) { + const [updated] = await ctx.db + .update(roleModelRoutes) + .set({ + providerAliasId: input.providerAliasId, + enabled: input.enabled ?? true, + ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}), + updatedAt: new Date(), + }) + .where( + and( + eq(roleModelRoutes.id, existing.id), + eq(roleModelRoutes.workspaceId, ctx.workspace.id), + ), + ) + .returning(); + + if (!updated) { + throw new TRPCError({ code: "NOT_FOUND", message: "Route not found." }); + } + + return mapRouteRow(updated); + } + + const [created] = await ctx.db + .insert(roleModelRoutes) + .values({ + workspaceId: ctx.workspace.id, + roleId: input.roleId, + providerAliasId: input.providerAliasId, + enabled: input.enabled ?? true, + sortOrder: input.sortOrder ?? 0, + updatedAt: new Date(), + }) + .returning(); + + if (!created) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create role route.", + }); + } + + return mapRouteRow(created); + }), + + deleteRoleRoute: workspaceProcedure + .input(z.object({ id: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + assertWorkspaceAdmin(ctx); + const [deleted] = await ctx.db + .delete(roleModelRoutes) + .where( + and( + eq(roleModelRoutes.id, input.id), + eq(roleModelRoutes.workspaceId, ctx.workspace.id), + ), + ) + .returning({ id: roleModelRoutes.id }); + + if (!deleted) { + throw new TRPCError({ code: "NOT_FOUND", message: "Route not found." }); + } + + return { id: deleted.id }; + }), + + resetPipelineDefaults: workspaceProcedure.mutation(async ({ ctx }) => { + assertWorkspaceAdmin(ctx); + const result = await seedDefaultRoleRoutes(ctx.db, ctx.workspace.id); + return result; + }), + + resolveForRole: workspaceProcedure + .input( + z.object({ + roleId: z.string().min(1).max(120), + briefRecommendedModel: z.string().optional(), + convoyPolicy: convoyModelPolicySchema.optional(), + }), + ) + .query(async ({ ctx, input }) => { + const providerRows = await ctx.db + .select() + .from(modelProviders) + .where(eq(modelProviders.workspaceId, ctx.workspace.id)); + + const routeRows = await ctx.db + .select() + .from(roleModelRoutes) + .where(eq(roleModelRoutes.workspaceId, ctx.workspace.id)); + + const result = resolveModel({ + roleId: input.roleId, + providers: providerRows.map(mapProviderRow), + routes: routeRows.map(mapRouteRow), + briefRecommendedModel: input.briefRecommendedModel, + convoyPolicy: input.convoyPolicy, + getApiKey: readApiKey, + }); + + if (!result.ok) { + return result; + } + + const { apiKey: _removed, ...publicModel } = result.model; + void _removed; + return { + ok: true as const, + model: publicModel, + source: result.source, + resolvedAlias: result.resolvedAlias, + }; + }), +}); + +function isUniqueViolation(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: string }).code === "23505" + ); +} diff --git a/apps/web/server/routers/ai.ts b/apps/web/server/routers/ai.ts index 74c2b34..c20935d 100644 --- a/apps/web/server/routers/ai.ts +++ b/apps/web/server/routers/ai.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { db as dbInstance } from "@tasks/database"; import { objects } from "@tasks/database/schema"; import { router, workspaceProcedure } from "@/server/trpc"; +import { aiModelsRouter } from "@/server/routers/ai-models"; type Db = typeof dbInstance; @@ -185,4 +186,6 @@ export const aiRouter = router({ } return { actions: suggestionsForContext({ ...input, objectType }) }; }), + + models: aiModelsRouter, }); diff --git a/apps/web/server/routers/runs.ts b/apps/web/server/routers/runs.ts index 9244e94..92b39d2 100644 --- a/apps/web/server/routers/runs.ts +++ b/apps/web/server/routers/runs.ts @@ -73,6 +73,10 @@ export const runsRouter = router({ tokensTotal: agentRuns.tokensTotal, notes: agentRuns.notes, metadata: agentRuns.metadata, + modelAlias: agentRuns.modelAlias, + providerType: agentRuns.providerType, + modelId: agentRuns.modelId, + modelTier: agentRuns.modelTier, }) .from(agentRuns) .leftJoin( @@ -147,6 +151,10 @@ export const runsRouter = router({ tokensTotal: agentRuns.tokensTotal, notes: agentRuns.notes, metadata: agentRuns.metadata, + modelAlias: agentRuns.modelAlias, + providerType: agentRuns.providerType, + modelId: agentRuns.modelId, + modelTier: agentRuns.modelTier, }) .from(agentRuns) .leftJoin( diff --git a/packages/ai/package.json b/packages/ai/package.json index 8735425..83d22ef 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -17,6 +17,7 @@ "ai": "^4.0.0", "@ai-sdk/openai": "^1.0.0", "@ai-sdk/anthropic": "^1.0.0", + "@ai-sdk/google": "^1.0.0", "@tasks/shared": "workspace:*", "zod": "^3.24.0" }, diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index ef3fdc6..9e26371 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -5,6 +5,10 @@ export { type OpenAIClientResult, type OpenAIModelId, } from "./providers/openai"; +export { + createLanguageModelFromResolved, + type CreateLanguageModelResult, +} from "./providers/resolve-client"; export { EDITOR_SYSTEM_PROMPT, GENERAL_SYSTEM_PROMPT, diff --git a/packages/ai/src/providers/resolve-client.ts b/packages/ai/src/providers/resolve-client.ts new file mode 100644 index 0000000..47fb70a --- /dev/null +++ b/packages/ai/src/providers/resolve-client.ts @@ -0,0 +1,48 @@ +import { createAnthropic } from "@ai-sdk/anthropic"; +import { createGoogleGenerativeAI } from "@ai-sdk/google"; +import { createOpenAI } from "@ai-sdk/openai"; +import type { LanguageModel } from "ai"; + +import type { ResolvedModel } from "@tasks/shared/model-routing"; + +export type CreateLanguageModelResult = + | { ok: true; model: LanguageModel } + | { ok: false; reason: string }; + +/** + * Build a Vercel AI SDK `LanguageModel` from a resolved registry config. + * Callers must pass a fully resolved `ResolvedModel` (including apiKey). + */ +export function createLanguageModelFromResolved( + resolved: ResolvedModel, +): CreateLanguageModelResult { + try { + switch (resolved.providerType) { + case "anthropic": { + const anthropic = createAnthropic({ apiKey: resolved.apiKey }); + return { ok: true, model: anthropic(resolved.modelId) }; + } + case "openai": + case "openai_compatible": + case "azure_openai": { + const openai = createOpenAI({ + apiKey: resolved.apiKey, + baseURL: resolved.baseUrl, + }); + return { ok: true, model: openai(resolved.modelId) }; + } + case "google": { + const google = createGoogleGenerativeAI({ apiKey: resolved.apiKey }); + return { ok: true, model: google(resolved.modelId) }; + } + default: + return { + ok: false, + reason: `Unsupported provider type: ${resolved.providerType as string}`, + }; + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return { ok: false, reason: message }; + } +} diff --git a/packages/database/migrations/0011_cool_captain_america.sql b/packages/database/migrations/0011_cool_captain_america.sql new file mode 100644 index 0000000..033a194 --- /dev/null +++ b/packages/database/migrations/0011_cool_captain_america.sql @@ -0,0 +1,4 @@ +ALTER TABLE "agent_runs" ADD COLUMN "model_alias" varchar(120);--> statement-breakpoint +ALTER TABLE "agent_runs" ADD COLUMN "provider_type" varchar(40);--> statement-breakpoint +ALTER TABLE "agent_runs" ADD COLUMN "model_id" varchar(200);--> statement-breakpoint +ALTER TABLE "agent_runs" ADD COLUMN "model_tier" varchar(20); \ No newline at end of file diff --git a/packages/database/migrations/meta/0011_snapshot.json b/packages/database/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000..cf3f64e --- /dev/null +++ b/packages/database/migrations/meta/0011_snapshot.json @@ -0,0 +1,3375 @@ +{ + "id": "1e188bcd-0e55-4da9-bca9-ded2ab16f0ff", + "prevId": "69ed8958-b67e-4ca2-9884-b878c5d40c34", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.workspace_invites": { + "name": "workspace_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now() + interval '14 days'" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_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": { + "workspace_invites_workspace_id_idx": { + "name": "workspace_invites_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_token_unique": { + "name": "workspace_invites_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_open_email_unique": { + "name": "workspace_invites_open_email_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_invites\".\"accepted_at\" IS NULL AND \"workspace_invites\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invites_workspace_id_workspaces_id_fk": { + "name": "workspace_invites_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invites_invited_by_user_id_users_id_fk": { + "name": "workspace_invites_invited_by_user_id_users_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "users", + "columnsFrom": [ + "invited_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan_tier": { + "name": "plan_tier", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "archived_at": { + "name": "archived_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_user_id_idx": { + "name": "workspaces_owner_user_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_user_id_users_id_fk": { + "name": "workspaces_owner_user_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.object_assignees": { + "name": "object_assignees", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'assignee'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "object_assignees_object_id_user_id_unique": { + "name": "object_assignees_object_id_user_id_unique", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_assignees_object_id_idx": { + "name": "object_assignees_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_assignees_user_id_idx": { + "name": "object_assignees_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "object_assignees_object_id_objects_id_fk": { + "name": "object_assignees_object_id_objects_id_fk", + "tableFrom": "object_assignees", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "object_assignees_user_id_users_id_fk": { + "name": "object_assignees_user_id_users_id_fk", + "tableFrom": "object_assignees", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.objects": { + "name": "objects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image": { + "name": "cover_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "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()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "objects_parent_id_idx": { + "name": "objects_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_type_idx": { + "name": "objects_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_workspace_id_idx": { + "name": "objects_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_template_id_idx": { + "name": "objects_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_created_by_idx": { + "name": "objects_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_type_workspace_id_idx": { + "name": "objects_type_workspace_id_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "objects_workspace_id_workspaces_id_fk": { + "name": "objects_workspace_id_workspaces_id_fk", + "tableFrom": "objects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "objects_created_by_users_id_fk": { + "name": "objects_created_by_users_id_fk", + "tableFrom": "objects", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "objects_parent_id_objects_id_fk": { + "name": "objects_parent_id_objects_id_fk", + "tableFrom": "objects", + "tableTo": "objects", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "objects_template_id_templates_id_fk": { + "name": "objects_template_id_templates_id_fk", + "tableFrom": "objects", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_members": { + "name": "workspace_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_workspace_id_user_id_unique": { + "name": "workspace_members_workspace_id_user_id_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_members_workspace_id_idx": { + "name": "workspace_members_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_users_id_fk": { + "name": "workspace_members_user_id_users_id_fk", + "tableFrom": "workspace_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.object_type_defs": { + "name": "object_type_defs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "layout": { + "name": "layout", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "default_properties": { + "name": "default_properties", + "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": { + "object_type_defs_workspace_id_idx": { + "name": "object_type_defs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_type_defs_slug_idx": { + "name": "object_type_defs_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "object_type_defs_workspace_id_workspaces_id_fk": { + "name": "object_type_defs_workspace_id_workspaces_id_fk", + "tableFrom": "object_type_defs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_definitions": { + "name": "property_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "property_definitions_workspace_id_idx": { + "name": "property_definitions_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_definitions_workspace_id_name_idx": { + "name": "property_definitions_workspace_id_name_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_definitions_workspace_id_workspaces_id_fk": { + "name": "property_definitions_workspace_id_workspaces_id_fk", + "tableFrom": "property_definitions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_values": { + "name": "property_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "property_def_id": { + "name": "property_def_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "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": { + "property_values_object_id_property_def_id_unique": { + "name": "property_values_object_id_property_def_id_unique", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "property_def_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_values_object_id_idx": { + "name": "property_values_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_values_property_def_id_idx": { + "name": "property_values_property_def_id_idx", + "columns": [ + { + "expression": "property_def_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_values_object_id_objects_id_fk": { + "name": "property_values_object_id_objects_id_fk", + "tableFrom": "property_values", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "property_values_property_def_id_property_definitions_id_fk": { + "name": "property_values_property_def_id_property_definitions_id_fk", + "tableFrom": "property_values", + "tableTo": "property_definitions", + "columnsFrom": [ + "property_def_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.views": { + "name": "views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "view_type": { + "name": "view_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "views_object_id_idx": { + "name": "views_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "views_object_id_objects_id_fk": { + "name": "views_object_id_objects_id_fk", + "tableFrom": "views", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "accounts_provider_provider_account_id_unique": { + "name": "accounts_provider_provider_account_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_token": { + "name": "session_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "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": {} + } + }, + "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_session_token_unique": { + "name": "sessions_session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "session_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_email_identities": { + "name": "user_email_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_identities_user_id_idx": { + "name": "user_email_identities_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_email_identities_email_idx": { + "name": "user_email_identities_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_email_identities_user_id_email_unique": { + "name": "user_email_identities_user_id_email_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_email_identities_verified_email_unique": { + "name": "user_email_identities_verified_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_email_identities\".\"verified_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_email_identities_user_id_users_id_fk": { + "name": "user_email_identities_user_id_users_id_fk", + "tableFrom": "user_email_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "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": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_lower_unique": { + "name": "users_email_lower_unique", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier_token_pk": { + "name": "verification_tokens_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.object_relations": { + "name": "object_relations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "relation_type": { + "name": "relation_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "object_relations_source_id_idx": { + "name": "object_relations_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_relations_target_id_idx": { + "name": "object_relations_target_id_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_relations_relation_type_idx": { + "name": "object_relations_relation_type_idx", + "columns": [ + { + "expression": "relation_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_relations_source_target_type_unique": { + "name": "object_relations_source_target_type_unique", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relation_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "object_relations_source_id_objects_id_fk": { + "name": "object_relations_source_id_objects_id_fk", + "tableFrom": "object_relations", + "tableTo": "objects", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "object_relations_target_id_objects_id_fk": { + "name": "object_relations_target_id_objects_id_fk", + "tableFrom": "object_relations", + "tableTo": "objects", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "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": { + "templates_workspace_id_idx": { + "name": "templates_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workspace_id_workspaces_id_fk": { + "name": "templates_workspace_id_workspaces_id_fk", + "tableFrom": "templates", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form_responses": { + "name": "form_responses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "form_id": { + "name": "form_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "respondent_id": { + "name": "respondent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_object_id": { + "name": "created_object_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_responses_form_id_idx": { + "name": "form_responses_form_id_idx", + "columns": [ + { + "expression": "form_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_responses_respondent_id_idx": { + "name": "form_responses_respondent_id_idx", + "columns": [ + { + "expression": "respondent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_responses_form_id_forms_id_fk": { + "name": "form_responses_form_id_forms_id_fk", + "tableFrom": "form_responses", + "tableTo": "forms", + "columnsFrom": [ + "form_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_responses_respondent_id_users_id_fk": { + "name": "form_responses_respondent_id_users_id_fk", + "tableFrom": "form_responses", + "tableTo": "users", + "columnsFrom": [ + "respondent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "form_responses_created_object_id_objects_id_fk": { + "name": "form_responses_created_object_id_objects_id_fk", + "tableFrom": "form_responses", + "tableTo": "objects", + "columnsFrom": [ + "created_object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.forms": { + "name": "forms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image": { + "name": "cover_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_type": { + "name": "target_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "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": { + "forms_workspace_id_idx": { + "name": "forms_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forms_object_id_idx": { + "name": "forms_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "forms_workspace_id_workspaces_id_fk": { + "name": "forms_workspace_id_workspaces_id_fk", + "tableFrom": "forms", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "forms_object_id_objects_id_fk": { + "name": "forms_object_id_objects_id_fk", + "tableFrom": "forms", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "forms_created_by_users_id_fk": { + "name": "forms_created_by_users_id_fk", + "tableFrom": "forms", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_favorites": { + "name": "user_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_favorites_user_object_idx": { + "name": "user_favorites_user_object_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_favorites_user_id_users_id_fk": { + "name": "user_favorites_user_id_users_id_fk", + "tableFrom": "user_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_favorites_object_id_objects_id_fk": { + "name": "user_favorites_object_id_objects_id_fk", + "tableFrom": "user_favorites", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.markdown_backlog_items": { + "name": "markdown_backlog_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "plan_slug": { + "name": "plan_slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "epic_slug": { + "name": "epic_slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frontmatter": { + "name": "frontmatter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "body_markdown": { + "name": "body_markdown", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content_hash": { + "name": "content_hash", + "type": "varchar(64)", + "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()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "workflow_prompt": { + "name": "workflow_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "markdown_backlog_workspace_repo_path_unique": { + "name": "markdown_backlog_workspace_repo_path_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "markdown_backlog_workspace_plan_idx": { + "name": "markdown_backlog_workspace_plan_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plan_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "markdown_backlog_parent_id_idx": { + "name": "markdown_backlog_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "markdown_backlog_workspace_kind_idx": { + "name": "markdown_backlog_workspace_kind_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "markdown_backlog_items_workspace_id_workspaces_id_fk": { + "name": "markdown_backlog_items_workspace_id_workspaces_id_fk", + "tableFrom": "markdown_backlog_items", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "markdown_backlog_items_parent_id_markdown_backlog_items_id_fk": { + "name": "markdown_backlog_items_parent_id_markdown_backlog_items_id_fk", + "tableFrom": "markdown_backlog_items", + "tableTo": "markdown_backlog_items", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cursor_sync_mappings": { + "name": "cursor_sync_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "backlog_item_id": { + "name": "backlog_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cursor_plan_id": { + "name": "cursor_plan_id", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "cursor_item_id": { + "name": "cursor_item_id", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "last_pulled_at": { + "name": "last_pulled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_pushed_at": { + "name": "last_pushed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_content_hash": { + "name": "sync_content_hash", + "type": "varchar(64)", + "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": { + "cursor_sync_mappings_backlog_item_id_unique": { + "name": "cursor_sync_mappings_backlog_item_id_unique", + "columns": [ + { + "expression": "backlog_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cursor_sync_mappings_workspace_id_idx": { + "name": "cursor_sync_mappings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cursor_sync_mappings_workspace_id_workspaces_id_fk": { + "name": "cursor_sync_mappings_workspace_id_workspaces_id_fk", + "tableFrom": "cursor_sync_mappings", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cursor_sync_mappings_backlog_item_id_markdown_backlog_items_id_fk": { + "name": "cursor_sync_mappings_backlog_item_id_markdown_backlog_items_id_fk", + "tableFrom": "cursor_sync_mappings", + "tableTo": "markdown_backlog_items", + "columnsFrom": [ + "backlog_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "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()" + } + }, + "indexes": { + "audit_log_workspace_id_created_at_idx": { + "name": "audit_log_workspace_id_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_user_id_idx": { + "name": "audit_log_actor_user_id_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspaces_id_fk": { + "name": "audit_log_workspace_id_workspaces_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_log_actor_user_id_users_id_fk": { + "name": "audit_log_actor_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_runs": { + "name": "agent_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "backlog_item_id": { + "name": "backlog_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_input": { + "name": "tokens_input", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_output": { + "name": "tokens_output", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_total": { + "name": "tokens_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "model_alias": { + "name": "model_alias", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "model_tier": { + "name": "model_tier", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_runs_workspace_id_started_at_idx": { + "name": "agent_runs_workspace_id_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_runs_backlog_item_id_started_at_idx": { + "name": "agent_runs_backlog_item_id_started_at_idx", + "columns": [ + { + "expression": "backlog_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_runs_workspace_id_workspaces_id_fk": { + "name": "agent_runs_workspace_id_workspaces_id_fk", + "tableFrom": "agent_runs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_runs_backlog_item_id_markdown_backlog_items_id_fk": { + "name": "agent_runs_backlog_item_id_markdown_backlog_items_id_fk", + "tableFrom": "agent_runs", + "tableTo": "markdown_backlog_items", + "columnsFrom": [ + "backlog_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_runs_actor_user_id_users_id_fk": { + "name": "agent_runs_actor_user_id_users_id_fk", + "tableFrom": "agent_runs", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_providers": { + "name": "model_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "alias": { + "name": "alias", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_env_var": { + "name": "api_key_env_var", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "extra_headers": { + "name": "extra_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "default_params": { + "name": "default_params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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": { + "model_providers_workspace_id_idx": { + "name": "model_providers_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "model_providers_workspace_id_alias_unique": { + "name": "model_providers_workspace_id_alias_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "alias", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "model_providers_workspace_id_workspaces_id_fk": { + "name": "model_providers_workspace_id_workspaces_id_fk", + "tableFrom": "model_providers", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_model_routes": { + "name": "role_model_routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "provider_alias_id": { + "name": "provider_alias_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "role_model_routes_workspace_id_idx": { + "name": "role_model_routes_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "role_model_routes_workspace_id_role_id_unique": { + "name": "role_model_routes_workspace_id_role_id_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "role_model_routes_provider_alias_id_idx": { + "name": "role_model_routes_provider_alias_id_idx", + "columns": [ + { + "expression": "provider_alias_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_model_routes_workspace_id_workspaces_id_fk": { + "name": "role_model_routes_workspace_id_workspaces_id_fk", + "tableFrom": "role_model_routes", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_model_routes_provider_alias_id_model_providers_id_fk": { + "name": "role_model_routes_provider_alias_id_model_providers_id_fk", + "tableFrom": "role_model_routes", + "tableTo": "model_providers", + "columnsFrom": [ + "provider_alias_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 6ed2ed6..734682c 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1783450794221, "tag": "0010_legal_stryfe", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1783451144455, + "tag": "0011_cool_captain_america", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/database/src/schema/agent_runs.ts b/packages/database/src/schema/agent_runs.ts index c1049b8..c4f603d 100644 --- a/packages/database/src/schema/agent_runs.ts +++ b/packages/database/src/schema/agent_runs.ts @@ -67,6 +67,10 @@ export const agentRuns = pgTable( tokensTotal: integer("tokens_total"), notes: text("notes"), metadata: jsonb("metadata").$type | null>(), + modelAlias: varchar("model_alias", { length: 120 }), + providerType: varchar("provider_type", { length: 40 }), + modelId: varchar("model_id", { length: 200 }), + modelTier: varchar("model_tier", { length: 20 }), }, (table) => ({ workspaceStartedIdx: index("agent_runs_workspace_id_started_at_idx").on( diff --git a/plans/Plan-automation-foundation/Epic-model-provider-registry/Epic-model-provider-registry.md b/plans/Plan-automation-foundation/Epic-model-provider-registry/Epic-model-provider-registry.md index 3085c46..abf4237 100644 --- a/plans/Plan-automation-foundation/Epic-model-provider-registry/Epic-model-provider-registry.md +++ b/plans/Plan-automation-foundation/Epic-model-provider-registry/Epic-model-provider-registry.md @@ -3,7 +3,7 @@ kind: epic slug: model-provider-registry title: Model provider registry and per-role routing plan_slug: automation-foundation -status: ready +status: done priority: P1 tenant_id: global cursor_epic_id: null diff --git a/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-agent-runs-model-logging.md b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-agent-runs-model-logging.md index e682db8..ae9125c 100644 --- a/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-agent-runs-model-logging.md +++ b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-agent-runs-model-logging.md @@ -4,7 +4,7 @@ slug: agent-runs-model-logging title: agent_runs model metadata and complete_task logging fields plan_slug: automation-foundation epic_slug: model-provider-registry -status: ready +status: done priority: P1 tenant_id: global owner: unassigned diff --git a/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-settings-ai-providers-ui.md b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-settings-ai-providers-ui.md index c19741d..7cbe6dd 100644 --- a/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-settings-ai-providers-ui.md +++ b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-settings-ai-providers-ui.md @@ -4,7 +4,7 @@ slug: settings-ai-providers-ui title: Settings UI — AI model providers plan_slug: automation-foundation epic_slug: model-provider-registry -status: ready +status: done priority: P1 tenant_id: global owner: unassigned diff --git a/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-settings-ai-role-routing-ui.md b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-settings-ai-role-routing-ui.md index 2915c9b..bef119f 100644 --- a/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-settings-ai-role-routing-ui.md +++ b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-settings-ai-role-routing-ui.md @@ -4,7 +4,7 @@ slug: settings-ai-role-routing-ui title: Settings UI — per-role model routing plan_slug: automation-foundation epic_slug: model-provider-registry -status: ready +status: done priority: P1 tenant_id: global owner: unassigned diff --git a/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-trpc-models-router.md b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-trpc-models-router.md index 0a779b2..53012a7 100644 --- a/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-trpc-models-router.md +++ b/plans/Plan-automation-foundation/Epic-model-provider-registry/Task-trpc-models-router.md @@ -4,7 +4,7 @@ slug: trpc-models-router title: tRPC ai.models router — CRUD, role routes, testProvider plan_slug: automation-foundation epic_slug: model-provider-registry -status: ready +status: done priority: P1 tenant_id: global owner: unassigned diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e029dc..15696d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -335,6 +335,9 @@ importers: '@ai-sdk/anthropic': specifier: ^1.0.0 version: 1.2.12(zod@3.25.76) + '@ai-sdk/google': + specifier: ^1.0.0 + version: 1.2.22(zod@3.25.76) '@ai-sdk/openai': specifier: ^1.0.0 version: 1.3.24(zod@3.25.76) @@ -404,6 +407,12 @@ packages: peerDependencies: zod: ^3.0.0 + '@ai-sdk/google@1.2.22': + resolution: {integrity: sha512-Ppxu3DIieF1G9pyQ5O1Z646GYR0gkC57YdBqXJ82qvCdhEhZHu0TWhmnOoeIWe2olSbuDeoOY+MfJrW8dzS3Hw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.0.0 + '@ai-sdk/openai@1.3.24': resolution: {integrity: sha512-GYXnGJTHRTZc4gJMSmFRgEQudjqd4PUN0ZjQhPwOAYH1yOAvQoG/Ikqs+HyISRbLPCrhbZnPKCNHuRU4OfpW0Q==} engines: {node: '>=18'} @@ -5394,6 +5403,12 @@ snapshots: '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) zod: 3.25.76 + '@ai-sdk/google@1.2.22(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) + zod: 3.25.76 + '@ai-sdk/openai@1.3.24(zod@3.25.76)': dependencies: '@ai-sdk/provider': 1.1.3 From a6898617b5851bb853b236c5c837a4b5c22189b9 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 7 Jul 2026 14:09:40 -0500 Subject: [PATCH 6/7] fix(shared): drop .js extensions in model-routing imports for Next.js Webpack resolves workspace packages without Node ESM .js suffixes; extensionless paths match the rest of packages/shared and fix middleware build failures. Co-authored-by: Cursor --- packages/shared/src/model-routing/index.ts | 6 +++--- packages/shared/src/model-routing/resolve-model.test.ts | 6 +++--- packages/shared/src/model-routing/resolve-model.ts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/model-routing/index.ts b/packages/shared/src/model-routing/index.ts index c6fe5ed..c58f3df 100644 --- a/packages/shared/src/model-routing/index.ts +++ b/packages/shared/src/model-routing/index.ts @@ -1,7 +1,7 @@ export { LEGACY_CURSOR_MODEL_ALIASES, PIPELINE_DEFAULT_ROLE_ROUTES, -} from "./constants.js"; +} from "./constants"; export { convoyModelPolicySchema, modelProviderSchema, @@ -15,7 +15,7 @@ export { type ProviderType, type ResolvedModel, type RoleModelRoute, -} from "./schemas.js"; +} from "./schemas"; export { resolveModel, resolveModelErrorCodeSchema, @@ -25,4 +25,4 @@ export { type ResolveModelInput, type ResolveModelResult, type ResolveModelSuccess, -} from "./resolve-model.js"; +} from "./resolve-model"; diff --git a/packages/shared/src/model-routing/resolve-model.test.ts b/packages/shared/src/model-routing/resolve-model.test.ts index 354b49c..b8a3e60 100644 --- a/packages/shared/src/model-routing/resolve-model.test.ts +++ b/packages/shared/src/model-routing/resolve-model.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import { LEGACY_CURSOR_MODEL_ALIASES } from "./constants.js"; -import type { ModelProvider, RoleModelRoute } from "./schemas.js"; -import { resolveModel } from "./resolve-model.js"; +import { LEGACY_CURSOR_MODEL_ALIASES } from "./constants"; +import type { ModelProvider, RoleModelRoute } from "./schemas"; +import { resolveModel } from "./resolve-model"; const WORKSPACE_ID = "379ecb2f-6db9-4c53-93ad-72c594eb44a5"; diff --git a/packages/shared/src/model-routing/resolve-model.ts b/packages/shared/src/model-routing/resolve-model.ts index 6db25b0..37c6c80 100644 --- a/packages/shared/src/model-routing/resolve-model.ts +++ b/packages/shared/src/model-routing/resolve-model.ts @@ -1,15 +1,15 @@ import { LEGACY_CURSOR_MODEL_ALIASES, PIPELINE_DEFAULT_ROLE_ROUTES, -} from "./constants.js"; +} from "./constants"; import type { ConvoyModelPolicy, ModelProvider, ModelTier, ResolvedModel, RoleModelRoute, -} from "./schemas.js"; -import { resolvedModelSchema } from "./schemas.js"; +} from "./schemas"; +import { resolvedModelSchema } from "./schemas"; export const resolveModelErrorCodeSchema = [ "ALIAS_NOT_RESOLVED", From 863d2d9d44efb22018561b590e60254ab92f499d Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 7 Jul 2026 14:11:04 -0500 Subject: [PATCH 7/7] fix(web): stop exporting formatDuration from runs settings page Next.js App Router rejects non-page exports from page modules; duration formatting stays a local helper. Co-authored-by: Cursor --- apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx b/apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx index 9d45c60..6ecdf58 100644 --- a/apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx +++ b/apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx @@ -66,7 +66,7 @@ function formatTimestamp(when: Date | string): string { * for in-flight runs (no `finishedAt`). Below a minute we show seconds * with a single decimal so quick MCP calls don't all look like "0s". */ -export function formatDuration( +function formatDuration( startedAt: Date | string, finishedAt: Date | string | null, ): string {