diff --git a/.claude/learnings.md b/.claude/learnings.md index b999973..ca5a962 100644 --- a/.claude/learnings.md +++ b/.claude/learnings.md @@ -63,3 +63,13 @@ The /cost + /evals collectors were first built against assumed endpoints that DO - Multi-`task_ids` queries work and accumulate correctly; batch the ids ~50/request to keep the GET URL well under server limits (`ArthurClient.listTraces`/`countTraces` chunk + paginate + merge). - **Evals: there is no success-rate field.** Compute pass-rate from `countTraces(..., { continuous_eval_run_status })` (enum: `pending|passed|running|failed|skipped|error`): `score = passed / (passed+failed)`. On this instance continuous evals are NOT configured (`GET /api/v2/tasks/{id}/metrics` → 404, trace `metrics` carry only token/cost, `annotations: null`, spans have no `metric_results`), so passed=failed=0 and /evals correctly degrades to `available:false`. The logic lights up once evals are enabled. - Verified live (MTD): /cost = $26.33 over 94 traces / 23M tokens, real per-workflow + daily breakdown. The dashboard reads the **deployed** worker — these fixes need a worker redeploy to show. + +## Pino error logging convention (2026-06-10) +- Pino's error serializer only applies to the `err` key. `logger.warn({ error: err }, ...)` JSON-stringifies the Error to `{}` (message/stack are non-enumerable). +- Codebase convention: `logger.warn({ err: (err as Error).message }, "msg")` — see src/lib/reconcile.ts:145, src/routes/webhooks/github.post.ts:146. +- Known pre-existing violation (out of scope, left as-is): `poll_dispatch_failed` in src/routes/cron/poll.get.ts (~line 94) uses `{ error: err }`. + +## 2026-06-10 — Redis→Neon migration implementation learnings +- **Stale `apps/worker/dist/` breaks `pnpm build` after removing a dependency.** The nitro/Workflow-DevKit build scans compiled JS under the gitignored `dist/` tree; a pre-migration `dist/src/adapters/run-registry/upstash.js` still importing `@upstash/redis` failed esbuild resolution after `pnpm remove @upstash/redis`. Fresh clones (Vercel CI) are unaffected. Fix: `rm -rf apps/worker/dist` locally. Also: piping build output through `tail` masks the exit code — check `PIPESTATUS`/run unpiped. +- **`pnpm exec tsx --env-file=X` exits 9 under standalone-pnpm installs** (pnpm's embedded Node makes tsx re-spawn `process.execPath` = the pnpm wrapper, which mishandles the forwarded flag). Root-cause fix used in `scripts/db-migrate.ts` + `scripts/clear-run-registry.ts`: `config({ path: [".env.local", ".env"], quiet: true })` from dotenv 17.x — first file wins, never overrides pre-set process env, so `vercel env pull .env.local && pnpm db:migrate` just works. Note `init-env/SKILL.md:193` still carries the fragile `pnpm tsx --env-file` pattern (pre-existing). +- **`pnpm dedupe` does NOT purge stale optional-peer suffixes** (e.g. `@upstash/redis` baked into `drizzle-orm(...)` snapshot keys when both coexisted) — it churned ~950 lockfile lines without removing the package. Reverted; the stale entry is peer-only, nothing imports it, harmless. diff --git a/.claude/skills/init-agent/SKILL.md b/.claude/skills/init-agent/SKILL.md index 8d11124..81f9139 100644 --- a/.claude/skills/init-agent/SKILL.md +++ b/.claude/skills/init-agent/SKILL.md @@ -7,7 +7,7 @@ description: Configure or rotate the agent runtime (Claude or Codex) for the Bla Branch-on-choice skill. Asks **Claude or Codex**, then emits a single paste-template for the chosen runtime. Cross-field rule in `env.ts` (`AGENT_KIND=claude` requires `ANTHROPIC_API_KEY`; `AGENT_KIND=codex` requires `CODEX_API_KEY` or `CODEX_CHATGPT_OAUTH_TOKEN`) is enforced by construction. -> If you want full project setup (Jira + VCS + Agent + Slack + Upstash + deploy), invoke `init-env` instead. This skill only handles the agent runtime. +> If you want full project setup (Jira + VCS + Agent + Slack + Neon + deploy), invoke `init-env` instead. This skill only handles the agent runtime. ## Precondition diff --git a/.claude/skills/init-env/SKILL.md b/.claude/skills/init-env/SKILL.md index 1efe374..ce81c1a 100644 --- a/.claude/skills/init-env/SKILL.md +++ b/.claude/skills/init-env/SKILL.md @@ -1,6 +1,6 @@ --- name: init-env -description: First-time setup orchestrator for the Blazebot ai-workflow repo. Mirrors SETUP.md as an agent-driven flow — project linking, env vars across Jira / VCS / Agent / Slack / Upstash, production deploy, post-deploy registrations (Jira webhook + Slack /ai-workflow slash command), and smoke checks. Use when starting fresh on this repo for the first time — "init project", "first-time setup", "bootstrap this repo", "onboard me", "set up env from scratch". +description: First-time setup orchestrator for the Blazebot ai-workflow repo. Mirrors SETUP.md as an agent-driven flow — project linking, env vars across Jira / VCS / Agent / Slack / Neon, production deploy, post-deploy registrations (Jira webhook + Slack /ai-workflow slash command), and smoke checks. Use when starting fresh on this repo for the first time — "init project", "first-time setup", "bootstrap this repo", "onboard me", "set up env from scratch". --- # Initialize Project Environment (Cold Start) @@ -36,7 +36,7 @@ If the user replies with anything other than a clear go-signal, do not advance 3. init-vcs → branch on github | gitlab 4. init-agent → branch on claude | codex 5. init-slack → bot token, channel, signing secret -6. init-upstash → Marketplace install runbook +6. init-neon → Marketplace install runbook 7. Inline: CRON_SECRET → auto-generate, paste-template 8. vercel env pull + validate → .env.local + pnpm tsx env.ts 9. vercel --prod → single production deploy @@ -153,15 +153,15 @@ Invoke `init-agent`. It asks **claude or codex** and emits a single paste-templa Invoke `init-slack`. It walks the user through creating the Slack app (or finding an existing bot token), the bot's `chat:write` scope, and the channel ID format. -→ **Stop. Ask:** *"Slack configured. Ready for Step 6: Upstash Redis?"* +→ **Stop. Ask:** *"Slack configured. Ready for Step 6: Neon Postgres?"* --- -## Step 6 — Invoke `init-upstash` +## Step 6 — Invoke `init-neon` -Invoke `init-upstash`. It walks the user through the Vercel Marketplace install of Upstash for Redis, with the env-var prefix set to `AI_WORKFLOW_KV` so Vercel auto-injects the two keys `env.ts` expects. +Invoke `init-neon`. It walks the user through the Vercel Marketplace install of Neon Postgres with branch-per-environment enabled so Vercel auto-injects `DATABASE_URL` for each environment that `env.ts` expects. -→ **Stop. Ask:** *"Upstash installed. Ready for Step 7: cron secret?"* +→ **Stop. Ask:** *"Neon installed. Ready for Step 7: cron secret?"* --- @@ -351,7 +351,7 @@ Configured: VCS / Agent model Slack channel bot @, slash - Upstash AI_WORKFLOW_KV prefix via Marketplace + Neon DATABASE_URL per env via Marketplace Cron CRON_SECRET set schedule * * * * * Skipped (see SETUP.md for the full how-to): @@ -377,7 +377,7 @@ Smoke checks: Maintenance: Rotate one integration later by invoking that subskill standalone: - init-jira | init-vcs | init-agent | init-slack | init-upstash + init-jira | init-vcs | init-agent | init-slack | init-neon Inspect the deployment: vercel logs --prod diff --git a/.claude/skills/init-jira/SKILL.md b/.claude/skills/init-jira/SKILL.md index e47aec1..903418b 100644 --- a/.claude/skills/init-jira/SKILL.md +++ b/.claude/skills/init-jira/SKILL.md @@ -10,7 +10,7 @@ State-aware skill for the Jira side of Blazebot. Two phases triggered by detecte - **Phase 1 — Credentials, columns, secret pre-gen.** Runs when `JIRA_BASE_URL` is not yet in Vercel env. - **Phase 2 — Webhook registration.** Runs when phase 1 is done and a production deploy exists. -> If you want full project setup (Jira + VCS + Agent + Slack + Upstash + deploy), invoke `init-env` instead. This skill only handles Jira. +> If you want full project setup (Jira + VCS + Agent + Slack + Neon + deploy), invoke `init-env` instead. This skill only handles Jira. ## Precondition diff --git a/.claude/skills/init-neon/SKILL.md b/.claude/skills/init-neon/SKILL.md new file mode 100644 index 0000000..5cc1683 --- /dev/null +++ b/.claude/skills/init-neon/SKILL.md @@ -0,0 +1,80 @@ +--- +name: init-neon +description: Configure the Neon Postgres database for Blazebot (run registry + post-PR gate store) via the Vercel Marketplace. Verifies DATABASE_URL is injected per environment, that environments do NOT share a branch, and that migrations apply. Use for "set up neon", "set up postgres", "configure database", "fix run registry", "env_marker error". +--- + +# Initialize Neon Postgres + +Walks the user through installing **Neon Postgres** from the Vercel Marketplace with branch-per-environment enabled so Vercel auto-injects a separate `DATABASE_URL` per environment that `env.ts` expects. + +Blazebot uses Postgres as its run registry and post-PR-gate store — tracking active workflow runs per ticket, deduplicating dispatch, and locking concurrent cron cycles. Tables are created automatically; migrations run during every deploy's build step (`apps/worker/scripts/db-migrate.ts`). + +> If you want full project setup (Jira + VCS + Agent + Slack + Neon + deploy), invoke `init-env` instead. This skill only handles Neon. + +## Precondition + +`.vercel/project.json` must exist. If missing: + +``` +ERROR: no Vercel project linked. Run `vercel link` first, or invoke `init-env` +for the full first-time setup. +``` + +Halt. + +## State detection + +1. `vercel env ls | grep DATABASE_URL` — if present for all three environments, skip install and go to verification. +2. If missing: walk the user through the Marketplace install below. + +## Step 1 — Marketplace install + +Walk the user through these steps (Vercel dashboard install is faster than CLI): + +1. Open https://vercel.com/marketplace/neon and click **Install**. +2. Select the team and connect it to the ai-workflow Vercel project. +3. **Critical:** enable **branch per environment** (development / preview / production) when configuring the integration. Each environment's `DATABASE_URL` must point at its own Neon branch. The build fails with an `env_marker` error if two environments share one branch — that guard protects the production run registry from preview deployments. +4. Confirm the install. Vercel auto-injects `DATABASE_URL` for all three environments. + +CLI alternative: `vercel integration add neon` + +## Step 2 — Confirm the key landed + +Tell the user to confirm in Vercel → Project Settings → Environment Variables that they see `DATABASE_URL` scoped to all three environments (Production, Preview, Development). + +CLI alternative (faster from a terminal): + +```bash +vercel env ls | grep DATABASE_URL +``` + +Success: `DATABASE_URL` appears for each of the three environments, with different values (distinct `ep-…` endpoint hosts confirm branch isolation; ignore any `-pooler` suffix when comparing hosts — pooled vs direct URLs of the same branch differ textually). + +If `DATABASE_URL` is missing or the same value appears across environments, the branch-per-environment option wasn't enabled during install. Recovery paths: + +- **Easier:** disconnect the Neon integration (Project → Storage → Neon → Disconnect), reinstall with branch-per-environment enabled. +- **Manual fix:** in the Neon console, create separate branches per environment and update each environment's `DATABASE_URL` in Vercel manually. Works but the integration won't keep them in sync automatically. + +## Verification (all must pass) + +1. `vercel env ls` shows `DATABASE_URL` for development, preview, and production. +2. Branch isolation: pull each environment's value and confirm the hosts differ (`vercel env pull --environment=production .env.prod` etc., compare the `ep-…` endpoint hosts; ignore any `-pooler` suffix when comparing hosts — pooled vs direct URLs of the same branch differ textually). Identical hosts across environments = the build's `env_marker` guard will fail — fix the integration's branch settings. +3. Migrations: `cd apps/worker && vercel env pull .env.local && pnpm db:migrate` against the development branch — expect "[db-migrate] OK — branch claimed by 'development'." (The script loads `.env.local` then `.env` via dotenv; vars already set in the shell env are never overridden.) + +## Step 3 — Done + +No paste-template needed — `DATABASE_URL` is auto-injected by Vercel. The end-of-flow validator (in `init-env`) confirms it made it. + +If invoked from `init-env`, return control. If standalone, end. + +## Troubleshooting + +- Build fails with `[db-migrate] FATAL: this Neon branch is already claimed by VERCEL_ENV='production', but this build is VERCEL_ENV='…'`: two environments share one Neon branch (the `env_marker` guard). Reconfigure the integration for branch-per-environment, redeploy. +- `DATABASE_URL undefined` at build: integration not connected to this project, or env var scoped to the wrong environments. +- Stale run registry (e.g. after a bad deploy or smoke test): run `pnpm exec tsx scripts/clear-run-registry.ts ` from `apps/worker` (after `vercel env pull .env.local`) to dump and clear `active_runs` / `failed_tickets` / `thread_parents`. + +## Don'ts + +- **Don't manually create a Neon database outside the Marketplace.** You'd lose the auto-injection benefit and have to manage `DATABASE_URL` by hand. The Marketplace integration is the preferred path. +- **Don't share one Neon branch across environments.** The `env_marker` build guard will fail — it's there to protect the production run registry from preview deployments polluting it. +- **Don't skip branch isolation.** A preview deploy writing to the production Neon branch corrupts the run registry and can orphan live sandboxes. diff --git a/.claude/skills/init-slack/SKILL.md b/.claude/skills/init-slack/SKILL.md index bf41ef8..411a71d 100644 --- a/.claude/skills/init-slack/SKILL.md +++ b/.claude/skills/init-slack/SKILL.md @@ -7,7 +7,7 @@ description: Configure or rotate the Slack bot integration for Blazebot notifica Configures the Slack bot Blazebot uses to post status updates (run started, PR opened, run failed, etc.) to a single channel. -> If you want full project setup (Jira + VCS + Agent + Slack + Upstash + deploy), invoke `init-env` instead. This skill only handles Slack. +> If you want full project setup (Jira + VCS + Agent + Slack + Neon + deploy), invoke `init-env` instead. This skill only handles Slack. ## Precondition diff --git a/.claude/skills/init-slack/references/slash-commands.md b/.claude/skills/init-slack/references/slash-commands.md index 401dc34..4605bca 100644 --- a/.claude/skills/init-slack/references/slash-commands.md +++ b/.claude/skills/init-slack/references/slash-commands.md @@ -57,7 +57,7 @@ Expect: 1. An ephemeral "Working on `/ai-workflow list`…" message (within ~1s). 2. A second message visible in the channel with either the list of active runs or "No active workflows." -If you instead see Slack's "operation_timeout" error, the function probably can't reach Upstash — check Vercel runtime logs for the `slack_command_dispatching` log line. +If you instead see Slack's "operation_timeout" error, the function probably can't reach the Postgres run registry — check Vercel runtime logs for the `slack_command_dispatching` log line. ## Troubleshooting diff --git a/.claude/skills/init-upstash/SKILL.md b/.claude/skills/init-upstash/SKILL.md deleted file mode 100644 index edf5a42..0000000 --- a/.claude/skills/init-upstash/SKILL.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -name: init-upstash -description: Configure the Upstash Redis run registry for Blazebot via the Vercel Marketplace. Verifies the AI_WORKFLOW_KV prefix and the auto-injected env vars. Use for "set up redis", "configure upstash", "install upstash marketplace", "fix run registry". ---- - -# Initialize Upstash Redis - -Walks the user through installing **Upstash for Redis** from the Vercel Marketplace with the env-var prefix set to `AI_WORKFLOW_KV` so Vercel auto-injects the two keys `env.ts` expects: - -- `AI_WORKFLOW_KV_REST_API_URL` -- `AI_WORKFLOW_KV_REST_API_TOKEN` - -Blazebot uses Redis as a run registry — a small key-value store tracking active workflow runs per ticket, used by reconcile and webhook cancellation. - -> If you want full project setup (Jira + VCS + Agent + Slack + Upstash + deploy), invoke `init-env` instead. This skill only handles Upstash. - -## Precondition - -`.vercel/project.json` must exist. If missing: - -``` -ERROR: no Vercel project linked. Run `vercel link` first, or invoke `init-env` -for the full first-time setup. -``` - -Halt. - -## Step 1 — Marketplace install - -Walk the user through these clicks (Vercel dashboard, dashboard install is faster than CLI): - -1. Open https://vercel.com/dashboard → pick the linked project. -2. Click **Storage** in the project sidebar. -3. **Browse Marketplace** → search "Upstash for Redis" → **Open**. -4. **Add to project** → pick the linked project. -5. **Choose plan** — Free tier is fine for getting started. -6. **Connect Project** → on the connection screen, look for **"Environment Variables Prefix"** (or similar wording) and **set it to `AI_WORKFLOW_KV`**. This is the critical step — without the right prefix, Vercel injects keys named `KV_REST_API_URL` etc. which `env.ts` doesn't recognize. -7. Confirm the install. Vercel auto-injects `AI_WORKFLOW_KV_REST_API_URL` and `AI_WORKFLOW_KV_REST_API_TOKEN` for all three environments. - -## Step 2 — Confirm the keys landed - -Tell the user to confirm in Vercel → Project Settings → Environment Variables that they see: - -- `AI_WORKFLOW_KV_REST_API_URL` (value: `https://.upstash.io`) -- `AI_WORKFLOW_KV_REST_API_TOKEN` - -CLI alternative (faster from a terminal): - -```bash -vercel env ls | grep AI_WORKFLOW_KV -``` - -Success: both `AI_WORKFLOW_KV_REST_API_URL` and `AI_WORKFLOW_KV_REST_API_TOKEN` appear, scoped to all three environments (Production, Preview, Development). - -If the keys are named differently (e.g. `KV_REST_API_URL` without the `AI_WORKFLOW_KV` prefix), the prefix wasn't set correctly during install. Two recovery paths: - -- **Easier:** uninstall the Upstash integration (Storage → Upstash → Disconnect), reinstall with the correct prefix. -- **Manual rename:** rename the env vars in Vercel from `KV_*` to `AI_WORKFLOW_KV_*`. Works but the integration won't keep them in sync if Upstash later rotates the underlying values. - -## Step 3 — Done - -No paste-template needed — keys are auto-injected by Vercel. The end-of-flow validator (in `init-env`) confirms they made it. - -If invoked from `init-env`, return control. If standalone, end. - -## Don'ts - -- **Don't manually create an Upstash database outside the Marketplace.** You'd lose the auto-injection benefit and have to manage env vars by hand. The Marketplace integration is preferred per Decision 6. -- **Don't change the prefix after install.** Vercel rewrites the env keys on the integration's behalf; if you rename them manually, Upstash's update flow gets confused. -- **Don't try to use Upstash REST URL/token from a non-Vercel deployment.** They work — but you'd be bypassing the Marketplace integration's billing and quota limits. diff --git a/.claude/skills/init-vcs/SKILL.md b/.claude/skills/init-vcs/SKILL.md index 460eb3b..2c082db 100644 --- a/.claude/skills/init-vcs/SKILL.md +++ b/.claude/skills/init-vcs/SKILL.md @@ -7,7 +7,7 @@ description: Configure or rotate the VCS provider (GitHub or GitLab) for the Bla Branch-on-choice skill. Asks **GitHub or GitLab**, then emits a single paste-template for the chosen provider. The cross-field rule in `env.ts` (`VCS_KIND=github` requires `GITHUB_TOKEN` + `GITHUB_OWNER` + `GITHUB_REPO`; `VCS_KIND=gitlab` requires `GITLAB_TOKEN` + `GITLAB_PROJECT_ID`) is enforced by construction — only the chosen branch's keys are emitted. -> If you want full project setup (Jira + VCS + Agent + Slack + Upstash + deploy), invoke `init-env` instead. This skill only handles VCS. +> If you want full project setup (Jira + VCS + Agent + Slack + Neon + deploy), invoke `init-env` instead. This skill only handles VCS. ## Precondition diff --git a/README.md b/README.md index 1ec80d5..6561113 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ flowchart TD | Issue Tracker | Jira REST API | Ticket lifecycle management | | VCS | GitHub ([Octokit](https://github.com/octokit/rest.js)) or GitLab ([@gitbeaker/rest](https://github.com/jdalrymple/gitbeaker)) | Branches, PRs/MRs, comments | | Messaging | [Chat SDK](https://chat-sdk.dev) + Slack | Team notifications + `/ai-workflow` slash commands | -| Run Registry | [Upstash Redis](https://upstash.com) (via Vercel Marketplace integration) | Atomic claim/release for concurrent runs | +| Run Registry | [Neon Postgres](https://neon.tech) (via Vercel Marketplace integration) | Atomic claim/release for concurrent runs | | Tracing (optional) | [Arthur AI Engine](https://www.arthur.ai/) | Per-run prompt/tool tracing inside the sandbox | | Validation | [Zod](https://zod.dev) | Schema validation for config and agent output | | Logging | [Pino](https://getpino.io) | Structured JSON logs | @@ -133,7 +133,7 @@ There is a single durable workflow — `agentWorkflow` in [`apps/worker/src/work | `ensureArthurTaskForTicket` | Optional — creates an Arthur trace task when `GENAI_ENGINE_*` is configured | | `resolveAgentKindOverride` | Per-ticket override via labels (e.g. `agent:codex`); falls back to `AGENT_KIND` | | `provisionSandbox` | Provisions a Vercel Sandbox, installs the agent CLI + skills, configures auth + Arthur tracer | -| `registerTicketSandbox` | Pins the sandbox id to the ticket in Redis so cleanup paths can stop it by id | +| `registerTicketSandbox` | Pins the sandbox id to the ticket in Postgres so cleanup paths can stop it by id | | `writeAttachments` | Writes downloaded attachments under `/tmp/attachments/` inside the sandbox | | **Phase 1 — Research/Plan** | `setCommitGuardStep(false)` → `planPhaseStep("research")` → `writeAndStartPhase` → `pollUntilDone` (20 min) → `collectPhase` → `parseResearchStep`. Result is `completed`, `clarification_needed`, or `failed` | | **Phase 2 — Implementation** | `setCommitGuardStep(true)` → `planPhaseStep("impl", AGENT_SCHEMA)` → `writeAndStartPhase` → `pollUntilDone` (35 min) → `collectPhase` → `parseAgentOutputStep` | @@ -141,7 +141,7 @@ There is a single durable workflow — `agentWorkflow` in [`apps/worker/src/work | `fixAndRetryPush` | Fallback: if the push is rejected (e.g. pre-receive hook), spawns a lightweight fix agent in the same sandbox, then retries the push once | | `createPullRequest` / `findPRForBranch` | Opens a new PR (no prior PR) or re-fetches the existing PR (review-fix path) | | `moveTicket` → `notifyTicket("pr_ready")` | Moves the ticket to "AI Review" and sends the Slack notification with the usage report | -| `unregisterRun` | Removes the ticket from the Redis run registry | +| `unregisterRun` | Removes the ticket from the Postgres run registry | | `teardownSandbox` | Always runs in `finally` — destroys the sandbox regardless of outcome | If either phase returns `clarification_needed`, the workflow posts numbered questions as a Jira comment, moves the ticket to Backlog, and emits a `needs_clarification` Slack event. If a phase fails or times out, the ticket is moved to Backlog with a `failed` event. @@ -222,9 +222,9 @@ The sandbox is **always destroyed** after each run (in a `finally` block), wheth ### Run Registry and Reconciliation -ai workflow uses an **atomic claim pattern** via Upstash Redis to prevent duplicate runs: +ai workflow uses an **atomic claim pattern** via Postgres (`INSERT … ON CONFLICT DO NOTHING`) to prevent duplicate runs: -- When a ticket is dispatched, a `claiming:{timestamp}` sentinel is set atomically (`hsetnx`) +- When a ticket is dispatched, a `claiming:{timestamp}` sentinel is set atomically (`INSERT … ON CONFLICT DO NOTHING`) - Only one poller instance can win the claim — others see it's taken - After the workflow starts, the sentinel is replaced with the real workflow run ID and the sandbox id is pinned to the ticket - On every poll cycle, the **reconciler** ([`apps/worker/src/lib/reconcile.ts`](./apps/worker/src/lib/reconcile.ts)) cleans up: diff --git a/SETUP.md b/SETUP.md index c1c094c..092697d 100644 --- a/SETUP.md +++ b/SETUP.md @@ -9,7 +9,7 @@ End-to-end instructions for deploying ai-workflow to your own Vercel account. Re 1. [Prerequisites](#1-prerequisites) 2. [Provision external accounts](#2-provision-external-accounts) 3. [Clone the repo and link to Vercel](#3-clone-the-repo-and-link-to-vercel) -4. [Install the Upstash marketplace integration](#4-install-the-upstash-marketplace-integration) +4. [Install the Neon Postgres marketplace integration](#4-install-the-neon-postgres-marketplace-integration) 5. [Configure environment variables](#5-configure-environment-variables) 6. [Deploy to Vercel](#6-deploy-to-vercel) 7. [Register the Jira webhook](#7-register-the-jira-webhook) @@ -40,7 +40,7 @@ Accounts you must own: - **GitHub** _or_ **GitLab** — admin on the target repository (PR + branch creation). - **Slack** workspace — admin to install a custom app and register slash commands. - **Anthropic** _or_ **OpenAI** — API key for the agent runtime. -- **Upstash** — installed via Vercel Marketplace in step 4. +- **Neon Postgres** — installed via Vercel Marketplace in step 4. --- @@ -198,21 +198,31 @@ cd ../.. --- -## 4. Install the Upstash marketplace integration +## 4. Install the Neon Postgres marketplace integration -ai-workflow uses Upstash Redis as its run registry (atomic claim/release for concurrent runs). +ai-workflow uses Neon Postgres as its run registry and post-PR-gate store +(atomic claim/release for concurrent runs, dedupe, locking). Tables are +created automatically — migrations run during every deploy's build step. -1. Open https://vercel.com/marketplace/upstash and click **Install**. -2. Pick the team and project you just linked. -3. **Critical:** when prompted for the env-var prefix, set it to `AI_WORKFLOW` (not `AI_WORKFLOW_KV`). Upstash appends `_KV_REST_API_URL` / `_KV_REST_API_TOKEN`, so the resulting vars are `AI_WORKFLOW_KV_REST_API_URL` and `AI_WORKFLOW_KV_REST_API_TOKEN` — which is what the code reads. Wrong prefix means ai-workflow can't find the registry. -4. Vercel auto-injects both vars into Production, Preview, and Development environments. +1. Open https://vercel.com/marketplace/neon and click **Install**. +2. Connect it to the ai-workflow Vercel project. +3. **Critical:** enable a **separate branch per environment** (development / + preview / production) when configuring the integration. Each environment's + `DATABASE_URL` must point at its own Neon branch. The build fails with an + `env_marker` error if two environments share one branch — that guard + protects the production run registry from preview deployments. Verify: ```bash -vercel env ls | grep AI_WORKFLOW_KV +vercel env ls | grep DATABASE_URL ``` +You should see `DATABASE_URL` present for each environment. (`vercel env ls` +shows values as Encrypted, so it can't confirm branch isolation — use the +pull-and-compare check in `.claude/skills/init-neon/` to verify each +environment points at its own Neon branch.) + --- ## 5. Configure environment variables @@ -248,7 +258,7 @@ vercel env add JIRA_API_TOKEN production | `GITLAB_TOKEN`, `GITLAB_PROJECT_ID` | If `VCS_KIND=gitlab` | | `ANTHROPIC_API_KEY` | If `AGENT_KIND=claude` (default) | | `CODEX_API_KEY` (or `CODEX_CHATGPT_OAUTH_TOKEN`) | If `AGENT_KIND=codex` | -| `AI_WORKFLOW_KV_REST_API_URL`, `AI_WORKFLOW_KV_REST_API_TOKEN` | Auto-injected by Upstash integration | +| `DATABASE_URL` | Auto-injected by Neon integration | | `WORKER_API_TOKEN` | Shared bearer secret gating the read-only `/api/v1/*` API that the dashboard consumes. Required even if you don't deploy the dashboard. Generate: `openssl rand -hex 32`. The dashboard's `WORKER_API_TOKEN` must match this value. | ### Optional / has defaults @@ -479,13 +489,13 @@ Flip `VCS_KIND=gitlab` and provide `GITLAB_TOKEN` + `GITLAB_PROJECT_ID`. For sel | `/cron/poll` returns 401 from Vercel Cron | `CRON_SECRET` mismatch | Ensure the var is set in Production environment. Redeploy after changing. | | Tickets in AI column never get picked up | Cron disabled / webhook misregistered | Check **Vercel → Project → Cron Jobs** is enabled. Curl `/cron/poll` with the secret to test manually. | | Workflow starts but sandbox fails to provision | Missing Vercel OIDC / Sandbox quota | On Vercel, OIDC is automatic. Check the project has Sandbox enabled (Pro plan). For local dev, set `VERCEL_TOKEN`/`VERCEL_TEAM_ID`/`VERCEL_PROJECT_ID`. | -| Run registry: `AI_WORKFLOW_KV_REST_API_URL undefined` | Upstash integration installed with wrong prefix | Reinstall with prefix `AI_WORKFLOW` (Upstash appends `_KV_REST_API_URL` / `_KV_REST_API_TOKEN`). | +| Run registry: `DATABASE_URL undefined` | Neon integration not connected to this project, or env var scoped to the wrong environments | Reinstall the Neon integration / check it's connected to this project. | | Agent runs but PR isn't created | GitHub App missing **Pull requests: Read & write** or **Contents: Read & write**, App not installed on target repo, or wrong owner/repo | In the App settings, re-check **Repository permissions** and the **Installations** list. Verify `GITHUB_OWNER`/`GITHUB_REPO` point at the _target_ repo, not this repo. | | Post-PR gate never runs on opened PRs | App webhook inactive, `Pull request` event not subscribed, missing `Checks: Read & write`, or permission/event change not re-accepted on the installed repo | App settings → **Webhook: Active** + URL set to `/webhooks/github`. Subscribe to **Pull request**. Bump **Checks** to read & write. Then have a repo admin re-accept the install at `https://github.com/organizations//settings/installations/`. Check **Advanced → Recent Deliveries** for 2xx responses. | | Post-PR gate webhook returns 401 in Recent Deliveries | `GITHUB_WEBHOOK_SECRET` missing on the deployment or different from the value pasted into the App | Set the var on **every** environment (production + preview + development) — preview deployments receive the webhook too. Redeploy after changing. Test by re-sending a delivery from the App's Recent Deliveries tab. | | Slack messages don't arrive | Bot not in channel, or wrong `CHAT_SDK_CHANNEL_ID` | Invite bot to the channel. Re-copy the channel ID. | | Slash command returns `dispatch_failed` | Signing secret wrong, or app not reinstalled | Verify `SLACK_SIGNING_SECRET`. Reinstall the Slack app after adding the slash command. | -| Two pollers race on the same ticket | Stale claim sentinel | The reconciler clears claims older than 5 minutes on every poll — wait one cycle, or flush the registry key in Upstash. | +| Two pollers race on the same ticket | Stale claim sentinel | The reconciler clears claims older than 5 minutes on every poll — wait one cycle, or run `pnpm exec tsx scripts/clear-run-registry.ts ` from `apps/worker` (after `vercel env pull .env.local`). | | Sandbox times out | Job too large for `JOB_TIMEOUT_MS` | Increase to 60–90 minutes for complex tickets, or split the work. | ### Useful logs @@ -501,4 +511,4 @@ Flip `VCS_KIND=gitlab` and provide `GITLAB_TOKEN` + `GITLAB_PROJECT_ID`. For sel - Architecture and workflow internals → [README.md](./README.md) - Spec → [docs/SPEC.md](./docs/SPEC.md) - User stories → [docs/user-stories.md](./docs/user-stories.md) -- Per-integration walkthroughs → `.claude/skills/init-*/` (Jira, Slack, Upstash, VCS, agent runtime) +- Per-integration walkthroughs → `.claude/skills/init-*/` (Jira, Slack, Neon, VCS, agent runtime) diff --git a/apps/worker/.env.e2e.example b/apps/worker/.env.e2e.example index 8113b19..e0b6b53 100644 --- a/apps/worker/.env.e2e.example +++ b/apps/worker/.env.e2e.example @@ -18,9 +18,9 @@ GITHUB_REPO= # Cron auth CRON_SECRET= -# Upstash Redis -AI_WORKFLOW_KV_REST_API_URL= -AI_WORKFLOW_KV_REST_API_TOKEN= +# Neon Postgres — must point at the SAME Neon branch as the deployment under +# test (vercel env pull for the matching environment). +DATABASE_URL= # Vercel Deployment Protection bypass (optional, needed for preview URLs) # VERCEL_AUTOMATION_BYPASS_SECRET= diff --git a/apps/worker/.env.example b/apps/worker/.env.example index 57c135b..3589f24 100644 --- a/apps/worker/.env.example +++ b/apps/worker/.env.example @@ -67,11 +67,10 @@ CLAUDE_MODEL=claude-opus-4-6 # COMMIT_AUTHOR= # COMMIT_EMAIL= -# Upstash Redis (run registry). -# On Vercel: install the Upstash for Redis Marketplace integration with the -# env-var prefix AI_WORKFLOW_KV — these two keys are auto-injected by Vercel. -AI_WORKFLOW_KV_REST_API_URL= -AI_WORKFLOW_KV_REST_API_TOKEN= +# Neon Postgres (run registry + post-PR gate store). +# On Vercel: install the Neon Postgres Marketplace integration with +# branch-per-environment enabled — DATABASE_URL is auto-injected by Vercel. +DATABASE_URL= # Cron auth — required in production so /cron/poll rejects unauthenticated callers. # Generate with `openssl rand -hex 32`. diff --git a/apps/worker/drizzle.config.ts b/apps/worker/drizzle.config.ts new file mode 100644 index 0000000..79c6478 --- /dev/null +++ b/apps/worker/drizzle.config.ts @@ -0,0 +1,12 @@ +import "dotenv/config"; +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "postgresql", + schema: "./src/db/schema.ts", + out: "./drizzle", + dbCredentials: { + // Only needed by `drizzle-kit migrate`; `generate` works without it. + url: process.env.DATABASE_URL ?? "", + }, +}); diff --git a/apps/worker/drizzle/0000_elite_paibok.sql b/apps/worker/drizzle/0000_elite_paibok.sql new file mode 100644 index 0000000..95ce0ca --- /dev/null +++ b/apps/worker/drizzle/0000_elite_paibok.sql @@ -0,0 +1,51 @@ +CREATE TABLE "active_runs" ( + "ticket_key" text PRIMARY KEY NOT NULL, + "run_id" text NOT NULL, + "sandbox_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "env_marker" ( + "id" integer PRIMARY KEY NOT NULL, + "env" text NOT NULL, + "endpoint_host" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "failed_tickets" ( + "ticket_key" text PRIMARY KEY NOT NULL, + "run_id" text NOT NULL, + "error" text NOT NULL, + "failed_at" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "gate_current" ( + "repo" text NOT NULL, + "pr" integer NOT NULL, + "run_id" text NOT NULL, + "head_sha" text NOT NULL, + "check_run_ids" bigint[] DEFAULT '{}'::bigint[] NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + CONSTRAINT "gate_current_repo_pr_pk" PRIMARY KEY("repo","pr") +); +--> statement-breakpoint +CREATE TABLE "gate_dedupe" ( + "repo" text NOT NULL, + "pr" integer NOT NULL, + "head_sha" text NOT NULL, + "run_id" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + CONSTRAINT "gate_dedupe_repo_pr_head_sha_pk" PRIMARY KEY("repo","pr","head_sha") +); +--> statement-breakpoint +CREATE TABLE "gate_locks" ( + "repo" text NOT NULL, + "pr" integer NOT NULL, + "token" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + CONSTRAINT "gate_locks_repo_pr_pk" PRIMARY KEY("repo","pr") +); +--> statement-breakpoint +CREATE TABLE "thread_parents" ( + "ticket_key" text PRIMARY KEY NOT NULL, + "message_id" text NOT NULL +); diff --git a/apps/worker/drizzle/0001_abnormal_dreaming_celestial.sql b/apps/worker/drizzle/0001_abnormal_dreaming_celestial.sql new file mode 100644 index 0000000..b25c157 --- /dev/null +++ b/apps/worker/drizzle/0001_abnormal_dreaming_celestial.sql @@ -0,0 +1,30 @@ +CREATE TABLE "workflow_runs" ( + "run_id" text PRIMARY KEY NOT NULL, + "workflow_id" text, + "workflow_name" text, + "status" text, + "ticket_key" text, + "ticket_title" text, + "ticket_url" text, + "model" text, + "sandbox_id" text, + "created_at" timestamp with time zone, + "started_at" timestamp with time zone, + "completed_at" timestamp with time zone, + "duration_sec" integer, + "pr_url" text, + "pr_number" integer, + "pr_repo" text, + "cost_usd" real, + "cost_known" boolean, + "tokens_input" integer, + "tokens_cached" integer, + "tokens_output" integer, + "phases" jsonb, + "first_seen_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX "workflow_runs_status_idx" ON "workflow_runs" USING btree ("status");--> statement-breakpoint +CREATE INDEX "workflow_runs_started_at_idx" ON "workflow_runs" USING btree ("started_at");--> statement-breakpoint +CREATE INDEX "workflow_runs_ticket_key_idx" ON "workflow_runs" USING btree ("ticket_key"); \ No newline at end of file diff --git a/apps/worker/drizzle/0002_icy_bastion.sql b/apps/worker/drizzle/0002_icy_bastion.sql new file mode 100644 index 0000000..58719a5 --- /dev/null +++ b/apps/worker/drizzle/0002_icy_bastion.sql @@ -0,0 +1 @@ +ALTER TABLE "workflow_runs" ALTER COLUMN "cost_usd" SET DATA TYPE numeric(19, 4); \ No newline at end of file diff --git a/apps/worker/drizzle/meta/0000_snapshot.json b/apps/worker/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..bfb508d --- /dev/null +++ b/apps/worker/drizzle/meta/0000_snapshot.json @@ -0,0 +1,305 @@ +{ + "id": "e1ec17de-0787-4332-ace5-f63f50f5a2c8", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.active_runs": { + "name": "active_runs", + "schema": "", + "columns": { + "ticket_key": { + "name": "ticket_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.env_marker": { + "name": "env_marker", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint_host": { + "name": "endpoint_host", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.failed_tickets": { + "name": "failed_tickets", + "schema": "", + "columns": { + "ticket_key": { + "name": "ticket_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "failed_at": { + "name": "failed_at", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gate_current": { + "name": "gate_current", + "schema": "", + "columns": { + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr": { + "name": "pr", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "check_run_ids": { + "name": "check_run_ids", + "type": "bigint[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::bigint[]" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "gate_current_repo_pr_pk": { + "name": "gate_current_repo_pr_pk", + "columns": [ + "repo", + "pr" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gate_dedupe": { + "name": "gate_dedupe", + "schema": "", + "columns": { + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr": { + "name": "pr", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "gate_dedupe_repo_pr_head_sha_pk": { + "name": "gate_dedupe_repo_pr_head_sha_pk", + "columns": [ + "repo", + "pr", + "head_sha" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gate_locks": { + "name": "gate_locks", + "schema": "", + "columns": { + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr": { + "name": "pr", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "gate_locks_repo_pr_pk": { + "name": "gate_locks_repo_pr_pk", + "columns": [ + "repo", + "pr" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.thread_parents": { + "name": "thread_parents", + "schema": "", + "columns": { + "ticket_key": { + "name": "ticket_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "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/apps/worker/drizzle/meta/0001_snapshot.json b/apps/worker/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..c28b7e0 --- /dev/null +++ b/apps/worker/drizzle/meta/0001_snapshot.json @@ -0,0 +1,510 @@ +{ + "id": "e02cd833-b255-447a-805a-08f7deb120fc", + "prevId": "e1ec17de-0787-4332-ace5-f63f50f5a2c8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.active_runs": { + "name": "active_runs", + "schema": "", + "columns": { + "ticket_key": { + "name": "ticket_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.env_marker": { + "name": "env_marker", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint_host": { + "name": "endpoint_host", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.failed_tickets": { + "name": "failed_tickets", + "schema": "", + "columns": { + "ticket_key": { + "name": "ticket_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "failed_at": { + "name": "failed_at", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gate_current": { + "name": "gate_current", + "schema": "", + "columns": { + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr": { + "name": "pr", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "check_run_ids": { + "name": "check_run_ids", + "type": "bigint[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::bigint[]" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "gate_current_repo_pr_pk": { + "name": "gate_current_repo_pr_pk", + "columns": [ + "repo", + "pr" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gate_dedupe": { + "name": "gate_dedupe", + "schema": "", + "columns": { + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr": { + "name": "pr", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "gate_dedupe_repo_pr_head_sha_pk": { + "name": "gate_dedupe_repo_pr_head_sha_pk", + "columns": [ + "repo", + "pr", + "head_sha" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gate_locks": { + "name": "gate_locks", + "schema": "", + "columns": { + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr": { + "name": "pr", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "gate_locks_repo_pr_pk": { + "name": "gate_locks_repo_pr_pk", + "columns": [ + "repo", + "pr" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.thread_parents": { + "name": "thread_parents", + "schema": "", + "columns": { + "ticket_key": { + "name": "ticket_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_runs": { + "name": "workflow_runs", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_name": { + "name": "workflow_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ticket_key": { + "name": "ticket_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ticket_title": { + "name": "ticket_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ticket_url": { + "name": "ticket_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_sec": { + "name": "duration_sec", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_repo": { + "name": "pr_repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cost_known": { + "name": "cost_known", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "tokens_input": { + "name": "tokens_input", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_cached": { + "name": "tokens_cached", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_output": { + "name": "tokens_output", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "phases": { + "name": "phases", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_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": { + "workflow_runs_status_idx": { + "name": "workflow_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_runs_started_at_idx": { + "name": "workflow_runs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_runs_ticket_key_idx": { + "name": "workflow_runs_ticket_key_idx", + "columns": [ + { + "expression": "ticket_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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/apps/worker/drizzle/meta/0002_snapshot.json b/apps/worker/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..d6e815e --- /dev/null +++ b/apps/worker/drizzle/meta/0002_snapshot.json @@ -0,0 +1,510 @@ +{ + "id": "1ce211ab-8fea-49da-8d80-e9c0e5d038f9", + "prevId": "e02cd833-b255-447a-805a-08f7deb120fc", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.active_runs": { + "name": "active_runs", + "schema": "", + "columns": { + "ticket_key": { + "name": "ticket_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.env_marker": { + "name": "env_marker", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint_host": { + "name": "endpoint_host", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.failed_tickets": { + "name": "failed_tickets", + "schema": "", + "columns": { + "ticket_key": { + "name": "ticket_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "failed_at": { + "name": "failed_at", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gate_current": { + "name": "gate_current", + "schema": "", + "columns": { + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr": { + "name": "pr", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "check_run_ids": { + "name": "check_run_ids", + "type": "bigint[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::bigint[]" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "gate_current_repo_pr_pk": { + "name": "gate_current_repo_pr_pk", + "columns": [ + "repo", + "pr" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gate_dedupe": { + "name": "gate_dedupe", + "schema": "", + "columns": { + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr": { + "name": "pr", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "gate_dedupe_repo_pr_head_sha_pk": { + "name": "gate_dedupe_repo_pr_head_sha_pk", + "columns": [ + "repo", + "pr", + "head_sha" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gate_locks": { + "name": "gate_locks", + "schema": "", + "columns": { + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr": { + "name": "pr", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "gate_locks_repo_pr_pk": { + "name": "gate_locks_repo_pr_pk", + "columns": [ + "repo", + "pr" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.thread_parents": { + "name": "thread_parents", + "schema": "", + "columns": { + "ticket_key": { + "name": "ticket_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_runs": { + "name": "workflow_runs", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_name": { + "name": "workflow_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ticket_key": { + "name": "ticket_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ticket_title": { + "name": "ticket_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ticket_url": { + "name": "ticket_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_sec": { + "name": "duration_sec", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_repo": { + "name": "pr_repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(19, 4)", + "primaryKey": false, + "notNull": false + }, + "cost_known": { + "name": "cost_known", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "tokens_input": { + "name": "tokens_input", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_cached": { + "name": "tokens_cached", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_output": { + "name": "tokens_output", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "phases": { + "name": "phases", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_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": { + "workflow_runs_status_idx": { + "name": "workflow_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_runs_started_at_idx": { + "name": "workflow_runs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_runs_ticket_key_idx": { + "name": "workflow_runs_ticket_key_idx", + "columns": [ + { + "expression": "ticket_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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/apps/worker/drizzle/meta/_journal.json b/apps/worker/drizzle/meta/_journal.json new file mode 100644 index 0000000..7368f95 --- /dev/null +++ b/apps/worker/drizzle/meta/_journal.json @@ -0,0 +1,27 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1781076160947, + "tag": "0000_elite_paibok", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1781519933529, + "tag": "0001_abnormal_dreaming_celestial", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1781522704332, + "tag": "0002_icy_bastion", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/apps/worker/e2e/env.ts b/apps/worker/e2e/env.ts index 1f80f20..fb37c78 100644 --- a/apps/worker/e2e/env.ts +++ b/apps/worker/e2e/env.ts @@ -27,10 +27,13 @@ const schema = z.object({ /** Only required by webhook-signing tests (US-12). */ JIRA_WEBHOOK_SECRET: z.string().min(1).optional(), - AI_WORKFLOW_KV_REST_API_URL: z.string().url(), - AI_WORKFLOW_KV_REST_API_TOKEN: z.string().min(1), + /** + * Neon Postgres connection for the SAME branch the deployment under test + * uses (registry seeding/cleanup). Pull with `vercel env pull` for the + * matching environment. + */ + DATABASE_URL: z.string().url(), - /** Must match the deployed app's VERCEL_ENV (e.g. "preview", "production") */ VERCEL_ENV: z.string().min(1).default("preview"), VERCEL_AUTOMATION_BYPASS_SECRET: z.string().optional(), diff --git a/apps/worker/e2e/helpers/redis.ts b/apps/worker/e2e/helpers/redis.ts deleted file mode 100644 index 83b5a43..0000000 --- a/apps/worker/e2e/helpers/redis.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Redis } from "@upstash/redis"; -import { e2eEnv } from "../env.js"; - -const HASH_KEY = `blazebot:active-runs:${e2eEnv.VERCEL_ENV}`; -const FAILED_HASH_KEY = `blazebot:failed-tickets:${e2eEnv.VERCEL_ENV}`; -const SANDBOX_HASH_KEY = `blazebot:sandboxes:${e2eEnv.VERCEL_ENV}`; -const ENTRY_TS_HASH_KEY = `blazebot:entry-timestamps:${e2eEnv.VERCEL_ENV}`; - -const redis = new Redis({ - url: e2eEnv.AI_WORKFLOW_KV_REST_API_URL, - token: e2eEnv.AI_WORKFLOW_KV_REST_API_TOKEN, -}); - -export async function getRunId(ticketKey: string): Promise { - return redis.hget(HASH_KEY, ticketKey); -} - - -export async function listAll(): Promise< - Array<{ ticketKey: string; runId: string }> -> { - const all = await redis.hgetall>(HASH_KEY); - if (!all) return []; - return Object.entries(all).map(([ticketKey, runId]) => ({ - ticketKey, - runId, - })); -} - -export async function setEntry( - ticketKey: string, - runId: string, - opts?: { ageMs?: number }, -): Promise { - await redis.hset(HASH_KEY, { [ticketKey]: runId }); - // Mirror the production adapter: stamp a creation timestamp so - // reconcile's orphan grace window (src/lib/reconcile.ts:ORPHAN_GRACE_MS) - // treats the seeded entry as fresh, not as stale junk to clean up. - // Callers exercising the orphan-cancel path (US-15) pass `ageMs` to - // backdate past the grace window so reconcile acts on the first tick. - const createdAt = Date.now() - (opts?.ageMs ?? 0); - await redis.hset(ENTRY_TS_HASH_KEY, { [ticketKey]: String(createdAt) }); -} - -export async function cleanup(ticketKey: string): Promise { - await Promise.all([ - redis.hdel(HASH_KEY, ticketKey).catch(() => {}), - redis.hdel(SANDBOX_HASH_KEY, ticketKey).catch(() => {}), - redis.hdel(ENTRY_TS_HASH_KEY, ticketKey).catch(() => {}), - ]); -} - -export interface FailedTicketMeta { - runId: string; - error: string; - failedAt: string; -} - -export async function markFailed( - ticketKey: string, - meta: FailedTicketMeta, -): Promise { - await redis.hset(FAILED_HASH_KEY, { [ticketKey]: JSON.stringify(meta) }); -} - -export async function isTicketFailed(ticketKey: string): Promise { - const value = await redis.hget(FAILED_HASH_KEY, ticketKey); - return value != null; -} - -export async function cleanupFailed(ticketKey: string): Promise { - await redis.hdel(FAILED_HASH_KEY, ticketKey).catch(() => {}); -} diff --git a/apps/worker/e2e/helpers/registry.ts b/apps/worker/e2e/helpers/registry.ts new file mode 100644 index 0000000..166d662 --- /dev/null +++ b/apps/worker/e2e/helpers/registry.ts @@ -0,0 +1,77 @@ +import { neon } from "@neondatabase/serverless"; +import { e2eEnv } from "../env.js"; + +/** + * Direct DB access for e2e seeding/cleanup. Must point at the SAME Neon + * branch as the deployment under test (vercel env pull for the matching + * environment). + */ +const sql = neon(e2eEnv.DATABASE_URL); + +export async function getRunId(ticketKey: string): Promise { + const rows = await sql`SELECT run_id FROM active_runs WHERE ticket_key = ${ticketKey}`; + return (rows[0]?.run_id as string | undefined) ?? null; +} + +export async function listAll(): Promise< + Array<{ ticketKey: string; runId: string }> +> { + const rows = await sql`SELECT ticket_key, run_id FROM active_runs`; + return rows.map((r) => ({ + ticketKey: r.ticket_key as string, + runId: r.run_id as string, + })); +} + +export async function setEntry( + ticketKey: string, + runId: string, + opts?: { ageMs?: number }, +): Promise { + // Mirror the production adapter: created_at backs reconcile's orphan + // grace window (src/lib/reconcile.ts:ORPHAN_GRACE_MS). Callers + // exercising the orphan-cancel path (US-15) pass `ageMs` to backdate + // past the grace window so reconcile acts on the first tick. + const ageMs = opts?.ageMs ?? 0; + await sql` + INSERT INTO active_runs (ticket_key, run_id, created_at) + VALUES (${ticketKey}, ${runId}, now() - make_interval(secs => ${ageMs / 1000})) + ON CONFLICT (ticket_key) DO UPDATE + SET run_id = excluded.run_id, created_at = excluded.created_at + `; +} + +export async function cleanup(ticketKey: string): Promise { + await sql`DELETE FROM active_runs WHERE ticket_key = ${ticketKey}`.catch( + () => {}, + ); +} + +export interface FailedTicketMeta { + runId: string; + error: string; + failedAt: string; +} + +export async function markFailed( + ticketKey: string, + meta: FailedTicketMeta, +): Promise { + await sql` + INSERT INTO failed_tickets (ticket_key, run_id, error, failed_at) + VALUES (${ticketKey}, ${meta.runId}, ${meta.error}, ${meta.failedAt}) + ON CONFLICT (ticket_key) DO UPDATE + SET run_id = excluded.run_id, error = excluded.error, failed_at = excluded.failed_at + `; +} + +export async function isTicketFailed(ticketKey: string): Promise { + const rows = await sql`SELECT 1 FROM failed_tickets WHERE ticket_key = ${ticketKey}`; + return rows.length > 0; +} + +export async function cleanupFailed(ticketKey: string): Promise { + await sql`DELETE FROM failed_tickets WHERE ticket_key = ${ticketKey}`.catch( + () => {}, + ); +} diff --git a/apps/worker/e2e/tier2/us01-clear-ticket-pr.test.ts b/apps/worker/e2e/tier2/us01-clear-ticket-pr.test.ts index 3bb7547..4168236 100644 --- a/apps/worker/e2e/tier2/us01-clear-ticket-pr.test.ts +++ b/apps/worker/e2e/tier2/us01-clear-ticket-pr.test.ts @@ -13,7 +13,7 @@ import { closePR, deleteBranch, } from "../helpers/github.js"; -import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; +import { getRunId, cleanup as redisCleanup } from "../helpers/registry.js"; import { stopSandboxesForTicket } from "../helpers/sandbox.js"; import { callCronPoll } from "../helpers/cron.js"; import { waitFor } from "../helpers/wait.js"; diff --git a/apps/worker/e2e/tier2/us03-review-fix-cycle.test.ts b/apps/worker/e2e/tier2/us03-review-fix-cycle.test.ts index a47b5fb..a49b664 100644 --- a/apps/worker/e2e/tier2/us03-review-fix-cycle.test.ts +++ b/apps/worker/e2e/tier2/us03-review-fix-cycle.test.ts @@ -17,7 +17,7 @@ import { closePR, deleteBranch, } from "../helpers/github.js"; -import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; +import { getRunId, cleanup as registryCleanup } from "../helpers/registry.js"; import { stopSandboxesForTicket } from "../helpers/sandbox.js"; import { callCronPoll } from "../helpers/cron.js"; import { waitFor } from "../helpers/wait.js"; @@ -43,7 +43,7 @@ describe("US-03: Review feedback triggers a fix cycle", () => { if (prNumber) await closePR(prNumber); if (branchName) await deleteBranch(branchName); if (ticketKey) { - await redisCleanup(ticketKey); + await registryCleanup(ticketKey); await deleteTicket(ticketKey); } }); @@ -162,13 +162,13 @@ describe("US-03: Review feedback triggers a fix cycle", () => { const filenames = prFiles.map((f) => f.filename); expect(filenames.some((f) => f.includes("healthcheck"))).toBe(true); - // Redis cleaned up + // Registry cleaned up await waitFor( async () => { const runId = await getRunId(ticketKey); return runId === null ? true : null; }, - { description: `Redis clean for ${ticketKey}`, timeoutMs: 30_000 }, + { description: `Registry clean for ${ticketKey}`, timeoutMs: 30_000 }, ); }); }); diff --git a/apps/worker/e2e/tier2/us04-merge-conflict-rebase.test.ts b/apps/worker/e2e/tier2/us04-merge-conflict-rebase.test.ts index fc6eb93..0119276 100644 --- a/apps/worker/e2e/tier2/us04-merge-conflict-rebase.test.ts +++ b/apps/worker/e2e/tier2/us04-merge-conflict-rebase.test.ts @@ -16,7 +16,7 @@ import { deleteBranch, deleteFile, } from "../helpers/github.js"; -import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; +import { getRunId, cleanup as registryCleanup } from "../helpers/registry.js"; import { stopSandboxesForTicket } from "../helpers/sandbox.js"; import { callCronPoll } from "../helpers/cron.js"; import { waitFor } from "../helpers/wait.js"; @@ -49,7 +49,7 @@ describe("US-04: PR with merge conflicts — agent rebases", () => { "[E2E] cleanup conflict test file", ).catch(() => {}); if (ticketKey) { - await redisCleanup(ticketKey); + await registryCleanup(ticketKey); await deleteTicket(ticketKey); } }); @@ -161,13 +161,13 @@ describe("US-04: PR with merge conflicts — agent rebases", () => { const finalStatus = await getTicketStatus(ticketKey); expect(finalStatus).toBe(e2eEnv.COLUMN_AI_REVIEW); - // Redis cleaned up + // Registry cleaned up await waitFor( async () => { const runId = await getRunId(ticketKey); return runId === null ? true : null; }, - { description: `Redis clean for ${ticketKey}`, timeoutMs: 30_000 }, + { description: `Registry clean for ${ticketKey}`, timeoutMs: 30_000 }, ); }); }); diff --git a/apps/worker/e2e/tier2/us05-unclear-ticket-clarification.test.ts b/apps/worker/e2e/tier2/us05-unclear-ticket-clarification.test.ts index b7735f1..cf59f70 100644 --- a/apps/worker/e2e/tier2/us05-unclear-ticket-clarification.test.ts +++ b/apps/worker/e2e/tier2/us05-unclear-ticket-clarification.test.ts @@ -7,7 +7,7 @@ import { deleteTicket, } from "../helpers/jira.js"; import { findPR, deleteBranch } from "../helpers/github.js"; -import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; +import { getRunId, cleanup as registryCleanup } from "../helpers/registry.js"; import { stopSandboxesForTicket } from "../helpers/sandbox.js"; import { callCronPoll } from "../helpers/cron.js"; import { waitFor } from "../helpers/wait.js"; @@ -18,7 +18,7 @@ import { e2eEnv } from "../env.js"; * * When a ticket is too vague/subjective to implement, the agent should * return status: "clarification_needed", post numbered questions as a Jira - * comment, move the ticket to Backlog, and clean up Redis/sandbox. + * comment, move the ticket to Backlog, and clean up registry/sandbox. */ describe("US-05: Unclear ticket triggers clarification", () => { let ticketKey: string; @@ -28,7 +28,7 @@ describe("US-05: Unclear ticket triggers clarification", () => { if (ticketKey) await stopSandboxesForTicket(ticketKey).catch(() => {}); if (branchName) await deleteBranch(branchName).catch(() => {}); if (ticketKey) { - await redisCleanup(ticketKey); + await registryCleanup(ticketKey); await deleteTicket(ticketKey); } }); @@ -80,13 +80,13 @@ describe("US-05: Unclear ticket triggers clarification", () => { const pr = await findPR(branchName); expect(pr).toBeNull(); - // 6. Redis entry cleaned up + // 6. Registry entry cleaned up await waitFor( async () => { const runId = await getRunId(ticketKey); return runId === null ? true : null; }, - { description: `Redis clean for ${ticketKey}`, timeoutMs: 30_000 }, + { description: `Registry clean for ${ticketKey}`, timeoutMs: 30_000 }, ); // 7. No sandbox still running for this ticket diff --git a/apps/worker/e2e/tier2/us06-clarification-answered.test.ts b/apps/worker/e2e/tier2/us06-clarification-answered.test.ts index 132b526..20bc538 100644 --- a/apps/worker/e2e/tier2/us06-clarification-answered.test.ts +++ b/apps/worker/e2e/tier2/us06-clarification-answered.test.ts @@ -14,7 +14,7 @@ import { closePR, deleteBranch, } from "../helpers/github.js"; -import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; +import { getRunId, cleanup as registryCleanup } from "../helpers/registry.js"; import { stopSandboxesForTicket } from "../helpers/sandbox.js"; import { callCronPoll } from "../helpers/cron.js"; import { waitFor } from "../helpers/wait.js"; @@ -43,7 +43,7 @@ describe("US-06: Clarification answered → ticket completes", () => { if (prNumber) await closePR(prNumber); if (branchName) await deleteBranch(branchName).catch(() => {}); if (ticketKey) { - await redisCleanup(ticketKey); + await registryCleanup(ticketKey); await deleteTicket(ticketKey); } }); @@ -95,14 +95,14 @@ describe("US-06: Clarification answered → ticket completes", () => { ); expect(clarificationComment).toBeDefined(); - // Redis cleaned up after clarification before we restart + // Registry cleaned up after clarification before we restart await waitFor( async () => { const runId = await getRunId(ticketKey); return runId === null ? true : null; }, { - description: `Redis clean after clarification for ${ticketKey}`, + description: `Registry clean after clarification for ${ticketKey}`, timeoutMs: 30_000, }, ); @@ -150,14 +150,14 @@ describe("US-06: Clarification answered → ticket completes", () => { expect(routeContent).toMatch(/export\s+(async\s+)?function\s+GET/); expect(routeContent).toContain(uniqueGreeting); - // Redis cleaned up after the implementation run + // Registry cleaned up after the implementation run await waitFor( async () => { const runId = await getRunId(ticketKey); return runId === null ? true : null; }, { - description: `Redis clean after implementation for ${ticketKey}`, + description: `Registry clean after implementation for ${ticketKey}`, timeoutMs: 30_000, }, ); diff --git a/apps/worker/e2e/tier2/us07-agent-failure-backlog.test.ts b/apps/worker/e2e/tier2/us07-agent-failure-backlog.test.ts index 6a0ac54..bec7f95 100644 --- a/apps/worker/e2e/tier2/us07-agent-failure-backlog.test.ts +++ b/apps/worker/e2e/tier2/us07-agent-failure-backlog.test.ts @@ -6,7 +6,7 @@ import { deleteTicket, } from "../helpers/jira.js"; import { findPR, deleteBranch } from "../helpers/github.js"; -import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; +import { getRunId, cleanup as redisCleanup } from "../helpers/registry.js"; import { stopSandboxesForTicket, killClaudeForTicket, diff --git a/apps/worker/e2e/tier2/us08-previously-failed-skip.test.ts b/apps/worker/e2e/tier2/us08-previously-failed-skip.test.ts index 344bee6..9eeb8e7 100644 --- a/apps/worker/e2e/tier2/us08-previously-failed-skip.test.ts +++ b/apps/worker/e2e/tier2/us08-previously-failed-skip.test.ts @@ -13,7 +13,7 @@ import { markFailed, isTicketFailed, cleanupFailed, -} from "../helpers/redis.js"; +} from "../helpers/registry.js"; import { stopSandboxesForTicket } from "../helpers/sandbox.js"; import { callCronPoll } from "../helpers/cron.js"; import { waitFor } from "../helpers/wait.js"; diff --git a/apps/worker/e2e/tier2/us09-failed-marker-cleared.test.ts b/apps/worker/e2e/tier2/us09-failed-marker-cleared.test.ts index f661e66..99692c2 100644 --- a/apps/worker/e2e/tier2/us09-failed-marker-cleared.test.ts +++ b/apps/worker/e2e/tier2/us09-failed-marker-cleared.test.ts @@ -11,7 +11,7 @@ import { markFailed, isTicketFailed, cleanupFailed, -} from "../helpers/redis.js"; +} from "../helpers/registry.js"; import { stopSandboxesForTicket } from "../helpers/sandbox.js"; import { callCronPoll } from "../helpers/cron.js"; import { waitFor } from "../helpers/wait.js"; diff --git a/apps/worker/e2e/tier2/us10-duplicate-dispatch-prevented.test.ts b/apps/worker/e2e/tier2/us10-duplicate-dispatch-prevented.test.ts index fb2c832..52e462b 100644 --- a/apps/worker/e2e/tier2/us10-duplicate-dispatch-prevented.test.ts +++ b/apps/worker/e2e/tier2/us10-duplicate-dispatch-prevented.test.ts @@ -10,7 +10,7 @@ import { getRunId, setEntry, cleanup as redisCleanup, -} from "../helpers/redis.js"; +} from "../helpers/registry.js"; import { stopSandboxesForTicket } from "../helpers/sandbox.js"; import { callCronPoll } from "../helpers/cron.js"; import { e2eEnv } from "../env.js"; diff --git a/apps/worker/e2e/tier2/us11-capacity-limit-respected.test.ts b/apps/worker/e2e/tier2/us11-capacity-limit-respected.test.ts index 0d2f500..3821608 100644 --- a/apps/worker/e2e/tier2/us11-capacity-limit-respected.test.ts +++ b/apps/worker/e2e/tier2/us11-capacity-limit-respected.test.ts @@ -10,7 +10,7 @@ import { setEntry, listAll as listAllRuns, cleanup as redisCleanup, -} from "../helpers/redis.js"; +} from "../helpers/registry.js"; import { callCronPoll } from "../helpers/cron.js"; import { waitFor } from "../helpers/wait.js"; import { e2eEnv } from "../env.js"; diff --git a/apps/worker/e2e/tier2/us12-ticket-moved-out-during-dispatch.test.ts b/apps/worker/e2e/tier2/us12-ticket-moved-out-during-dispatch.test.ts index baac45d..b7393ed 100644 --- a/apps/worker/e2e/tier2/us12-ticket-moved-out-during-dispatch.test.ts +++ b/apps/worker/e2e/tier2/us12-ticket-moved-out-during-dispatch.test.ts @@ -6,7 +6,7 @@ import { deleteTicket, } from "../helpers/jira.js"; import { findPR, deleteBranch } from "../helpers/github.js"; -import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; +import { getRunId, cleanup as redisCleanup } from "../helpers/registry.js"; import { stopSandboxesForTicket } from "../helpers/sandbox.js"; import { postJiraWebhook } from "../helpers/webhook.js"; import { e2eEnv } from "../env.js"; diff --git a/apps/worker/e2e/tier2/us13-webhook-immediate-dispatch.test.ts b/apps/worker/e2e/tier2/us13-webhook-immediate-dispatch.test.ts index 6170419..9203c9e 100644 --- a/apps/worker/e2e/tier2/us13-webhook-immediate-dispatch.test.ts +++ b/apps/worker/e2e/tier2/us13-webhook-immediate-dispatch.test.ts @@ -6,7 +6,7 @@ import { deleteTicket, } from "../helpers/jira.js"; import { findPR, closePR, deleteBranch } from "../helpers/github.js"; -import { getRunId, cleanup as redisCleanup } from "../helpers/redis.js"; +import { getRunId, cleanup as redisCleanup } from "../helpers/registry.js"; import { stopSandboxesForTicket } from "../helpers/sandbox.js"; import { waitFor } from "../helpers/wait.js"; import { e2eEnv } from "../env.js"; diff --git a/apps/worker/e2e/tier2/us14-stale-claim-cleanup.test.ts b/apps/worker/e2e/tier2/us14-stale-claim-cleanup.test.ts index dd7ab5d..8da5240 100644 --- a/apps/worker/e2e/tier2/us14-stale-claim-cleanup.test.ts +++ b/apps/worker/e2e/tier2/us14-stale-claim-cleanup.test.ts @@ -9,7 +9,7 @@ import { getRunId, setEntry, cleanup as redisCleanup, -} from "../helpers/redis.js"; +} from "../helpers/registry.js"; import { stopSandboxesForTicket } from "../helpers/sandbox.js"; import { callCronPoll } from "../helpers/cron.js"; import { waitFor } from "../helpers/wait.js"; diff --git a/apps/worker/e2e/tier2/us15-orphaned-run-cancelled.test.ts b/apps/worker/e2e/tier2/us15-orphaned-run-cancelled.test.ts index d627d44..6574b40 100644 --- a/apps/worker/e2e/tier2/us15-orphaned-run-cancelled.test.ts +++ b/apps/worker/e2e/tier2/us15-orphaned-run-cancelled.test.ts @@ -9,7 +9,7 @@ import { getRunId, setEntry, cleanup as redisCleanup, -} from "../helpers/redis.js"; +} from "../helpers/registry.js"; import { stopSandboxesForTicket } from "../helpers/sandbox.js"; import { callCronPoll } from "../helpers/cron.js"; import { waitFor } from "../helpers/wait.js"; diff --git a/apps/worker/env.test.ts b/apps/worker/env.test.ts index cb9e208..a962f91 100644 --- a/apps/worker/env.test.ts +++ b/apps/worker/env.test.ts @@ -25,8 +25,7 @@ describe("env", () => { CLAUDE_MODEL: "claude-opus-4-6", MAX_CONCURRENT_AGENTS: "3", JOB_TIMEOUT_MS: "1800000", - AI_WORKFLOW_KV_REST_API_URL: "https://fake.upstash.io", - AI_WORKFLOW_KV_REST_API_TOKEN: "fake-token", + DATABASE_URL: "postgresql://user:pass@ep-fake.neon.tech/neondb", GITHUB_WEBHOOK_SECRET: "github-webhook-secret", WORKER_API_TOKEN: "a".repeat(64), }; diff --git a/apps/worker/env.ts b/apps/worker/env.ts index 524f83f..c505a07 100644 --- a/apps/worker/env.ts +++ b/apps/worker/env.ts @@ -119,9 +119,9 @@ export const env = createEnv({ // GitHub Webhook GITHUB_WEBHOOK_SECRET: z.string().min(1), - // Redis (run registry) - AI_WORKFLOW_KV_REST_API_URL: z.string().url(), - AI_WORKFLOW_KV_REST_API_TOKEN: z.string().min(1), + // Neon Postgres (run registry + post-PR gate store) — auto-injected by + // the Neon Vercel Marketplace integration, one branch per environment. + DATABASE_URL: z.string().url(), // Dashboard API — shared bearer secret gating the read-only /api/v1/* // endpoints (verified in src/middleware/api-auth.ts). The dashboard (a diff --git a/apps/worker/package.json b/apps/worker/package.json index b081b08..bb761a8 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "dev": "rm -rf .nitro/workflow && nitro dev", - "build": "pnpm validate:pre-sandbox && rm -rf .nitro/workflow && NODE_OPTIONS=--max-old-space-size=8192 nitro build", + "build": "pnpm validate:pre-sandbox && pnpm db:migrate && rm -rf .nitro/workflow && NODE_OPTIONS=--max-old-space-size=8192 nitro build", "preview": "nitro preview", "test": "vitest run", "test:watch": "vitest", @@ -15,20 +15,23 @@ "test:e2e": "pnpm test:e2e:agent && pnpm test:e2e:orchestration && pnpm test:e2e:capacity", "test:e2e:agent": "vitest run --config e2e/vitest.e2e.config.ts --project agent", "test:e2e:orchestration": "vitest run --config e2e/vitest.e2e.config.ts --project orchestration", - "test:e2e:capacity": "vitest run --config e2e/vitest.e2e.config.ts --project capacity" + "test:e2e:capacity": "vitest run --config e2e/vitest.e2e.config.ts --project capacity", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx scripts/db-migrate.ts" }, "dependencies": { "@ai-sdk/anthropic": "^3.0.78", "@chat-adapter/slack": "^4.20.2", "@gitbeaker/rest": "^43.8.0", + "@neondatabase/serverless": "^1.1.0", "@octokit/auth-app": "^8.2.0", "@octokit/rest": "^22.0.1", "@t3-oss/env-core": "^0.13.10", - "@upstash/redis": "^1.37.0", "@vercel/functions": "^3.5.0", "@vercel/sandbox": "^1.8.1", "ai": "^6.0.188", "chat": "^4.20.2", + "drizzle-orm": "^0.45.2", "h3": "^1", "nitropack": "^2", "pino": "^10.3.1", @@ -37,8 +40,11 @@ "zod": "^3.25.76" }, "devDependencies": { + "@electric-sql/pglite": "^0.5.1", "@workflow/vitest": "latest", "@workflow/world-postgres": "latest", + "dotenv": "^17.4.2", + "drizzle-kit": "^0.31.10", "tsx": "^4.21.0", "typescript": "^5.8", "vitest": "^3" diff --git a/apps/worker/scripts/clear-run-registry.ts b/apps/worker/scripts/clear-run-registry.ts index 0e52251..755c126 100644 --- a/apps/worker/scripts/clear-run-registry.ts +++ b/apps/worker/scripts/clear-run-registry.ts @@ -1,75 +1,83 @@ /** - * Clear run-registry entries in Upstash. + * Clear run-registry entries in Neon Postgres. * * pnpm exec tsx scripts/clear-run-registry.ts # show state, no writes * pnpm exec tsx scripts/clear-run-registry.ts AWT-42 # clear one ticket - * pnpm exec tsx scripts/clear-run-registry.ts --all # clear every ticket in this env + * pnpm exec tsx scripts/clear-run-registry.ts --all --yes # clear every ticket */ -import "dotenv/config"; -import { Redis } from "@upstash/redis"; +import { config } from "dotenv"; -const ENV_PREFIX = process.env.VERCEL_ENV ?? "development"; -const keys = { - active: `blazebot:active-runs:${ENV_PREFIX}`, - sandbox: `blazebot:sandboxes:${ENV_PREFIX}`, - entryTs: `blazebot:entry-timestamps:${ENV_PREFIX}`, - failed: `blazebot:failed-tickets:${ENV_PREFIX}`, -}; +// Load .env.local (where `vercel env pull` writes) before .env; dotenv never +// overrides vars already set, so real env (Vercel build) always wins. +config({ path: [".env.local", ".env"], quiet: true }); -const url = process.env.AI_WORKFLOW_KV_REST_API_URL; -const token = process.env.AI_WORKFLOW_KV_REST_API_TOKEN; -if (!url || !token) { - console.error("Missing AI_WORKFLOW_KV_REST_API_URL / AI_WORKFLOW_KV_REST_API_TOKEN"); +import { neon } from "@neondatabase/serverless"; + +const url = process.env.DATABASE_URL; +if (!url) { + console.error("Missing DATABASE_URL"); process.exit(1); } -const redis = new Redis({ url, token }); +const sql = neon(url); + +const tables = { + active: "active_runs", + failed: "failed_tickets", + threads: "thread_parents", +} as const; async function dump() { - for (const [label, key] of Object.entries(keys)) { - const all = await redis.hgetall>(key); - console.log(`\n[${label}] ${key}`); - if (!all || Object.keys(all).length === 0) console.log(" (empty)"); - else for (const [t, v] of Object.entries(all)) console.log(` ${t} -> ${v}`); + for (const [label, table] of Object.entries(tables)) { + const rows = await sql.query(`SELECT * FROM ${table}`); + console.log(`\n[${label}] ${table}`); + if (rows.length === 0) console.log(" (empty)"); + else for (const r of rows) console.log(` ${JSON.stringify(r)}`); } } async function clearTicket(t: string) { - for (const [label, key] of Object.entries(keys)) { - const n = await redis.hdel(key, t); - console.log(` hdel ${label} ${t} -> ${n}`); + for (const [label, table] of Object.entries(tables)) { + const rows = await sql.query( + `DELETE FROM ${table} WHERE ticket_key = $1 RETURNING ticket_key`, + [t], + ); + console.log(` delete ${label} ${t} -> ${rows.length}`); } } async function clearAll() { - for (const [label, key] of Object.entries(keys)) { - const n = await redis.del(key); - console.log(` del ${label} ${key} -> ${n}`); + for (const [label, table] of Object.entries(tables)) { + const rows = await sql.query(`DELETE FROM ${table} RETURNING ticket_key`); + console.log(` delete all ${label} -> ${rows.length}`); } } const args = process.argv.slice(2); (async () => { if (args.length === 0) { - console.log(`env=${ENV_PREFIX} — dumping current state (no writes)`); + console.log("dumping current state (no writes)"); await dump(); return; } if (args[0] === "--all") { if (args.length !== 2 || args[1] !== "--yes") { console.error( - `env=${ENV_PREFIX} — refusing to clear ALL run-registry keys without confirmation.\n` + - ` re-run with: pnpm exec tsx scripts/clear-run-registry.ts --all --yes`, + "refusing to clear ALL run-registry tables without confirmation.\n" + + " re-run with: pnpm exec tsx scripts/clear-run-registry.ts --all --yes", ); process.exit(1); } - console.log(`env=${ENV_PREFIX} — clearing ALL run-registry keys`); + console.log("clearing ALL run-registry tables"); await clearAll(); return; } if (args.length !== 1) { - console.error(`env=${ENV_PREFIX} — unexpected extra args: ${args.slice(1).join(" ")}`); + console.error(`unexpected extra args: ${args.slice(1).join(" ")}`); process.exit(1); } - console.log(`env=${ENV_PREFIX} — clearing ticket ${args[0]}`); + console.log(`clearing ticket ${args[0]}`); await clearTicket(args[0]); -})().catch((e) => { console.error(e); process.exit(1); }); +})().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/apps/worker/scripts/db-migrate.ts b/apps/worker/scripts/db-migrate.ts new file mode 100644 index 0000000..572f870 --- /dev/null +++ b/apps/worker/scripts/db-migrate.ts @@ -0,0 +1,64 @@ +/** + * Build-time migration runner + environment-isolation guard. + * + * Runs as part of `pnpm build` on Vercel, where the Neon Marketplace + * integration injects DATABASE_URL per environment (branch-per-env). + * Keeps deployment one-click: every deploy is schema-self-healing. + * + * Guard: the env_marker row pins this database branch to one VERCEL_ENV. + * - Same endpoint host, different env → FAIL the build. Preview and + * production are sharing a branch; the run registries would collide + * (preview claiming production tickets, deleting its Slack threads). + * - Different endpoint host → the branch was copied (Neon + * branches copy data, marker included) — re-claim it for this env. + * + * Locally (no DATABASE_URL) this is a warn-and-skip no-op so `pnpm build` + * still works without a database. + */ +import { config } from "dotenv"; + +// Load .env.local (where `vercel env pull` writes) before .env; dotenv never +// overrides vars already set, so real env (Vercel build) always wins. +config({ path: [".env.local", ".env"], quiet: true }); + +import { execSync } from "node:child_process"; +import { neon } from "@neondatabase/serverless"; + +const url = process.env.DATABASE_URL; +if (!url) { + console.warn("[db-migrate] DATABASE_URL not set — skipping migrations."); + process.exit(0); +} + +execSync("pnpm exec drizzle-kit migrate", { stdio: "inherit" }); + +const sql = neon(url); +const vercelEnv = process.env.VERCEL_ENV ?? "development"; +// Normalize: strip Neon's -pooler suffix and any port so pooled vs direct +// URLs for the same branch compare equal (a host mismatch takes the +// permissive re-claim path, which must mean a genuinely different endpoint). +const host = new URL(url).hostname.toLowerCase().replace(/-pooler(?=\.)/, ""); + +await sql` + INSERT INTO env_marker (id, env, endpoint_host) + VALUES (1, ${vercelEnv}, ${host}) + ON CONFLICT (id) DO NOTHING +`; +const rows = await sql`SELECT env, endpoint_host FROM env_marker WHERE id = 1`; +const marker = rows[0] as { env: string; endpoint_host: string }; + +if (marker.endpoint_host !== host) { + console.warn( + `[db-migrate] branch copied from '${marker.env}' (${marker.endpoint_host}) — re-claiming for '${vercelEnv}'.`, + ); + await sql`UPDATE env_marker SET env = ${vercelEnv}, endpoint_host = ${host} WHERE id = 1`; +} else if (marker.env !== vercelEnv) { + console.error( + `[db-migrate] FATAL: this Neon branch is already claimed by VERCEL_ENV='${marker.env}', ` + + `but this build is VERCEL_ENV='${vercelEnv}'. Environments must not share a branch — ` + + `enable branch-per-environment in the Neon Vercel integration (see SETUP.md §4).`, + ); + process.exitCode = 1; +} else { + console.log(`[db-migrate] OK — branch claimed by '${vercelEnv}'.`); +} diff --git a/apps/worker/src/adapters/run-registry/postgres.test.ts b/apps/worker/src/adapters/run-registry/postgres.test.ts new file mode 100644 index 0000000..1788793 --- /dev/null +++ b/apps/worker/src/adapters/run-registry/postgres.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { sql } from "drizzle-orm"; +import { PostgresRunRegistry } from "./postgres.js"; +import { createTestDb } from "../../db/test-db.js"; +import type { Db } from "../../db/client.js"; + +let db: Db; +let registry: PostgresRunRegistry; + +beforeEach(async () => { + db = await createTestDb(); + registry = new PostgresRunRegistry(db); +}); + +describe("claim", () => { + it("returns true when the ticket is unclaimed", async () => { + expect(await registry.claim("PROJ-1", "claiming")).toBe(true); + expect(await registry.getRunId("PROJ-1")).toBe("claiming"); + }); + + it("returns false when the ticket is already claimed", async () => { + await registry.claim("PROJ-1", "claiming"); + expect(await registry.claim("PROJ-1", "other")).toBe(false); + expect(await registry.getRunId("PROJ-1")).toBe("claiming"); + }); + + it("stamps a creation timestamp", async () => { + const before = Date.now(); + await registry.claim("PROJ-1", "claiming"); + const ts = await registry.getEntryCreatedAt("PROJ-1"); + expect(ts).toBeGreaterThanOrEqual(before - 1000); + expect(ts).toBeLessThanOrEqual(Date.now() + 1000); + }); + + it("returns true again after unregister frees the slot", async () => { + await registry.claim("PROJ-1", "run_a"); + await registry.unregister("PROJ-1"); + expect(await registry.claim("PROJ-1", "run_b")).toBe(true); + expect(await registry.getRunId("PROJ-1")).toBe("run_b"); + }); +}); + +describe("register", () => { + it("overwrites the runId after a claim", async () => { + await registry.claim("PROJ-1", "claiming"); + await registry.register("PROJ-1", "run_abc"); + expect(await registry.getRunId("PROJ-1")).toBe("run_abc"); + }); + + it("inserts when no claim exists (external seeders)", async () => { + await registry.register("PROJ-2", "run_xyz"); + expect(await registry.getRunId("PROJ-2")).toBe("run_xyz"); + }); + + it("REFRESHES the creation timestamp (authoritative write point — reconcile orphan grace period)", async () => { + await registry.claim("PROJ-1", "claiming"); + // Backdate the entry past any grace window, as if claimed long ago. + await db.execute( + sql`UPDATE active_runs SET created_at = now() - interval '10 minutes' WHERE ticket_key = 'PROJ-1'`, + ); + const stale = await registry.getEntryCreatedAt("PROJ-1"); + expect(Date.now() - stale!).toBeGreaterThan(9 * 60 * 1000); + + await registry.register("PROJ-1", "run_abc"); + const fresh = await registry.getEntryCreatedAt("PROJ-1"); + expect(Date.now() - fresh!).toBeLessThan(60 * 1000); + }); + + it("does not clobber a registered sandboxId", async () => { + await registry.claim("PROJ-1", "claiming"); + await registry.registerSandbox("PROJ-1", "sbox_1"); + await registry.register("PROJ-1", "run_abc"); + expect(await registry.getSandboxId("PROJ-1")).toBe("sbox_1"); + }); +}); + +describe("getRunId", () => { + it("returns null when not registered", async () => { + expect(await registry.getRunId("PROJ-99")).toBeNull(); + }); +}); + +describe("unregister", () => { + it("removes run, sandbox, and timestamp together", async () => { + await registry.claim("PROJ-1", "run_abc"); + await registry.registerSandbox("PROJ-1", "sbox_1"); + await registry.unregister("PROJ-1"); + expect(await registry.getRunId("PROJ-1")).toBeNull(); + expect(await registry.getSandboxId("PROJ-1")).toBeNull(); + expect(await registry.getEntryCreatedAt("PROJ-1")).toBeNull(); + }); + + it("does NOT touch thread parents (they outlive runs)", async () => { + await registry.claim("PROJ-1", "run_abc"); + await registry.setParent("PROJ-1", "1700000000.000123"); + await registry.unregister("PROJ-1"); + expect(await registry.getParent("PROJ-1")).toBe("1700000000.000123"); + }); +}); + +describe("listAll", () => { + it("returns all ticket -> runId pairs", async () => { + await registry.claim("PROJ-1", "run_abc"); + await registry.claim("PROJ-2", "run_def"); + const all = await registry.listAll(); + expect(all).toHaveLength(2); + expect(all).toContainEqual({ ticketKey: "PROJ-1", runId: "run_abc" }); + expect(all).toContainEqual({ ticketKey: "PROJ-2", runId: "run_def" }); + }); + + it("returns empty array when none registered", async () => { + expect(await registry.listAll()).toEqual([]); + }); +}); + +describe("sandbox", () => { + it("registerSandbox/getSandboxId round-trips", async () => { + await registry.claim("PROJ-1", "run_abc"); + await registry.registerSandbox("PROJ-1", "sbox_12345"); + expect(await registry.getSandboxId("PROJ-1")).toBe("sbox_12345"); + }); + + it("getSandboxId returns null when never registered", async () => { + await registry.claim("PROJ-1", "run_abc"); + expect(await registry.getSandboxId("PROJ-1")).toBeNull(); + }); + + it("registerSandbox throws when there is no active run row", async () => { + // A zero-row update means the run was unregistered out from under us; + // fail fast so the sandbox isn't silently orphaned (no row links it). + await expect( + registry.registerSandbox("PROJ-77", "sbox_orphan"), + ).rejects.toThrow("no active run for PROJ-77"); + expect(await registry.getSandboxId("PROJ-77")).toBeNull(); + expect(await registry.getRunId("PROJ-77")).toBeNull(); + }); +}); + +describe("failed tickets", () => { + const meta = { + runId: "run_abc", + error: "Failed to move ticket to backlog: 403 Forbidden", + failedAt: "2026-04-02T12:34:56.000Z", + }; + + it("markFailed/isTicketFailed/listAllFailed round-trips meta exactly", async () => { + await registry.markFailed("AWT-42", meta); + expect(await registry.isTicketFailed("AWT-42")).toBe(true); + expect(await registry.listAllFailed()).toEqual([ + { ticketKey: "AWT-42", meta }, + ]); + }); + + it("markFailed twice updates rather than throwing", async () => { + await registry.markFailed("AWT-42", meta); + await registry.markFailed("AWT-42", { ...meta, error: "second" }); + const [entry] = await registry.listAllFailed(); + expect(entry.meta.error).toBe("second"); + }); + + it("isTicketFailed returns false / listAllFailed empty when none", async () => { + expect(await registry.isTicketFailed("AWT-99")).toBe(false); + expect(await registry.listAllFailed()).toEqual([]); + }); + + it("clearFailedMark removes the marker", async () => { + await registry.markFailed("AWT-42", meta); + await registry.clearFailedMark("AWT-42"); + expect(await registry.isTicketFailed("AWT-42")).toBe(false); + }); +}); + +describe("ThreadStore", () => { + it("setParent/getParent round-trips a Slack ts as a STRING", async () => { + await registry.setParent("AWT-42", "1777542341.966359"); + const result = await registry.getParent("AWT-42"); + expect(result).toBe("1777542341.966359"); + expect(typeof result).toBe("string"); + }); + + it("setParent overwrites a prior value", async () => { + await registry.setParent("AWT-42", "111.000"); + await registry.setParent("AWT-42", "222.000"); + expect(await registry.getParent("AWT-42")).toBe("222.000"); + }); + + it("getParent returns null when no entry", async () => { + expect(await registry.getParent("AWT-99")).toBeNull(); + }); + + it("clearParent deletes the entry", async () => { + await registry.setParent("AWT-42", "1700000000.000123"); + await registry.clearParent("AWT-42"); + expect(await registry.getParent("AWT-42")).toBeNull(); + }); +}); diff --git a/apps/worker/src/adapters/run-registry/postgres.ts b/apps/worker/src/adapters/run-registry/postgres.ts new file mode 100644 index 0000000..3904bc3 --- /dev/null +++ b/apps/worker/src/adapters/run-registry/postgres.ts @@ -0,0 +1,153 @@ +import { eq, sql } from "drizzle-orm"; +import type { Db } from "../../db/client.js"; +import { + activeRuns, + failedTickets, + threadParents, +} from "../../db/schema.js"; +import type { + FailedTicketMeta, + RunRegistryAdapter, + ThreadStore, +} from "./types.js"; + +export class PostgresRunRegistry implements RunRegistryAdapter, ThreadStore { + constructor(private db: Db) {} + + async claim(ticketKey: string, runId: string): Promise { + // INSERT ... ON CONFLICT DO NOTHING is the HSETNX equivalent: exactly + // one concurrent claimer gets a row back. created_at defaults to now(), + // which doubles as the entry timestamp for reconcile's grace period. + const rows = await this.db + .insert(activeRuns) + .values({ ticketKey, runId }) + .onConflictDoNothing({ target: activeRuns.ticketKey }) + .returning({ ticketKey: activeRuns.ticketKey }); + return rows.length > 0; + } + + async register(ticketKey: string, runId: string): Promise { + // Refresh created_at: register() is called both on the claim → runId + // swap and by external seeders, so it's the authoritative write point + // for the orphan grace period. sandbox_id is intentionally untouched. + await this.db + .insert(activeRuns) + .values({ ticketKey, runId }) + .onConflictDoUpdate({ + target: activeRuns.ticketKey, + set: { runId, createdAt: sql`now()` }, + }); + } + + async getRunId(ticketKey: string): Promise { + const rows = await this.db + .select({ runId: activeRuns.runId }) + .from(activeRuns) + .where(eq(activeRuns.ticketKey, ticketKey)); + return rows[0]?.runId ?? null; + } + + async unregister(ticketKey: string): Promise { + // One row holds run, sandbox, and timestamp — deleting it fully + // detaches the ticket. Thread parents live in their own table and + // survive (see ThreadStore docs in types.ts). + await this.db.delete(activeRuns).where(eq(activeRuns.ticketKey, ticketKey)); + } + + async listAll(): Promise> { + return this.db + .select({ ticketKey: activeRuns.ticketKey, runId: activeRuns.runId }) + .from(activeRuns); + } + + async registerSandbox(ticketKey: string, sandboxId: string): Promise { + // Sandboxes are only registered after claim()/register(), so the row + // exists; a bare UPDATE keeps run_id NOT NULL without an upsert dance. + // A zero-row update means the run was unregistered out from under us (a + // cancel webhook racing this step): fail fast so the workflow's error + // path tears the sandbox down, rather than silently orphaning it where + // cleanup can't find the missing sandbox_id linkage. + const rows = await this.db + .update(activeRuns) + .set({ sandboxId }) + .where(eq(activeRuns.ticketKey, ticketKey)) + .returning({ ticketKey: activeRuns.ticketKey }); + if (rows.length === 0) { + throw new Error(`registerSandbox: no active run for ${ticketKey}`); + } + } + + async getSandboxId(ticketKey: string): Promise { + const rows = await this.db + .select({ sandboxId: activeRuns.sandboxId }) + .from(activeRuns) + .where(eq(activeRuns.ticketKey, ticketKey)); + return rows[0]?.sandboxId ?? null; + } + + async getEntryCreatedAt(ticketKey: string): Promise { + const rows = await this.db + .select({ createdAt: activeRuns.createdAt }) + .from(activeRuns) + .where(eq(activeRuns.ticketKey, ticketKey)); + return rows[0]?.createdAt?.getTime() ?? null; + } + + async markFailed(ticketKey: string, meta: FailedTicketMeta): Promise { + await this.db + .insert(failedTickets) + .values({ ticketKey, ...meta }) + .onConflictDoUpdate({ + target: failedTickets.ticketKey, + set: { runId: meta.runId, error: meta.error, failedAt: meta.failedAt }, + }); + } + + async isTicketFailed(ticketKey: string): Promise { + const rows = await this.db + .select({ ticketKey: failedTickets.ticketKey }) + .from(failedTickets) + .where(eq(failedTickets.ticketKey, ticketKey)); + return rows.length > 0; + } + + async listAllFailed(): Promise< + Array<{ ticketKey: string; meta: FailedTicketMeta }> + > { + const rows = await this.db.select().from(failedTickets); + return rows.map(({ ticketKey, runId, error, failedAt }) => ({ + ticketKey, + meta: { runId, error, failedAt }, + })); + } + + async clearFailedMark(ticketKey: string): Promise { + await this.db + .delete(failedTickets) + .where(eq(failedTickets.ticketKey, ticketKey)); + } + + async getParent(ticketKey: string): Promise { + const rows = await this.db + .select({ messageId: threadParents.messageId }) + .from(threadParents) + .where(eq(threadParents.ticketKey, ticketKey)); + return rows[0]?.messageId ?? null; + } + + async setParent(ticketKey: string, messageId: string): Promise { + await this.db + .insert(threadParents) + .values({ ticketKey, messageId }) + .onConflictDoUpdate({ + target: threadParents.ticketKey, + set: { messageId }, + }); + } + + async clearParent(ticketKey: string): Promise { + await this.db + .delete(threadParents) + .where(eq(threadParents.ticketKey, ticketKey)); + } +} diff --git a/apps/worker/src/adapters/run-registry/types.ts b/apps/worker/src/adapters/run-registry/types.ts index cad396f..0b560e5 100644 --- a/apps/worker/src/adapters/run-registry/types.ts +++ b/apps/worker/src/adapters/run-registry/types.ts @@ -48,7 +48,7 @@ export interface RunRegistryAdapter { /** * Per-ticket Slack thread parent store. Implemented alongside RunRegistryAdapter - * by UpstashRunRegistry, but exposed as a separate interface so the messaging + * by PostgresRunRegistry, but exposed as a separate interface so the messaging * adapter only depends on the slice it needs. * * Lifetime: an entry survives across multiple workflow runs for the same diff --git a/apps/worker/src/adapters/run-registry/upstash.test.ts b/apps/worker/src/adapters/run-registry/upstash.test.ts deleted file mode 100644 index 322182d..0000000 --- a/apps/worker/src/adapters/run-registry/upstash.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { UpstashRunRegistry } from "./upstash.js"; - -const HASH_KEY = `blazebot:active-runs:${process.env.VERCEL_ENV ?? "development"}`; -const THREAD_HASH_KEY = `blazebot:thread-parents:${process.env.VERCEL_ENV ?? "development"}`; - -const mockRedis = { - hsetnx: vi.fn(), - hset: vi.fn(), - hget: vi.fn(), - hdel: vi.fn(), - hgetall: vi.fn(), - persist: vi.fn(), -}; - -vi.mock("@upstash/redis", () => ({ - Redis: vi.fn(() => mockRedis), -})); - -function createRegistry() { - return new UpstashRunRegistry({ - url: "https://fake.upstash.io", - token: "fake-token", - }); -} - -describe("UpstashRunRegistry", () => { - beforeEach(() => { - vi.clearAllMocks(); - // Default hset to resolve so the adapter's best-effort timestamp - // writes (to ENTRY_TS_HASH_KEY / SANDBOX_HASH_KEY) don't blow up with - // "cannot read .catch of undefined" in tests that only cared about - // the primary HASH_KEY call. - mockRedis.hset.mockResolvedValue(1); - mockRedis.hdel.mockResolvedValue(1); - mockRedis.persist.mockResolvedValue(1); - }); - - describe("claim", () => { - it("returns true when key was not already set", async () => { - mockRedis.hsetnx.mockResolvedValueOnce(1); - const registry = createRegistry(); - const result = await registry.claim("PROJ-1", "claiming"); - expect(result).toBe(true); - expect(mockRedis.hsetnx).toHaveBeenCalledWith(HASH_KEY, "PROJ-1", "claiming"); - }); - - it("returns false when key already exists", async () => { - mockRedis.hsetnx.mockResolvedValueOnce(0); - const registry = createRegistry(); - const result = await registry.claim("PROJ-1", "claiming"); - expect(result).toBe(false); - }); - }); - - describe("register", () => { - it("stores ticketKey -> runId in the hash", async () => { - const registry = createRegistry(); - await registry.register("PROJ-1", "run_abc"); - expect(mockRedis.hset).toHaveBeenCalledWith(HASH_KEY, { "PROJ-1": "run_abc" }); - }); - }); - - describe("getRunId", () => { - it("returns runId when ticket is registered", async () => { - mockRedis.hget.mockResolvedValueOnce("run_abc"); - const registry = createRegistry(); - const result = await registry.getRunId("PROJ-1"); - expect(result).toBe("run_abc"); - expect(mockRedis.hget).toHaveBeenCalledWith(HASH_KEY, "PROJ-1"); - }); - - it("returns null when ticket is not registered", async () => { - mockRedis.hget.mockResolvedValueOnce(null); - const registry = createRegistry(); - const result = await registry.getRunId("PROJ-99"); - expect(result).toBeNull(); - }); - }); - - describe("unregister", () => { - it("removes the ticketKey from the hash", async () => { - const registry = createRegistry(); - await registry.unregister("PROJ-1"); - expect(mockRedis.hdel).toHaveBeenCalledWith(HASH_KEY, "PROJ-1"); - }); - }); - - describe("listAll", () => { - it("returns all registered ticket -> runId pairs", async () => { - mockRedis.hgetall.mockResolvedValueOnce({ - "PROJ-1": "run_abc", - "PROJ-2": "run_def", - }); - const registry = createRegistry(); - const result = await registry.listAll(); - expect(result).toEqual([ - { ticketKey: "PROJ-1", runId: "run_abc" }, - { ticketKey: "PROJ-2", runId: "run_def" }, - ]); - }); - - it("returns empty array when no runs are registered", async () => { - mockRedis.hgetall.mockResolvedValueOnce(null); - const registry = createRegistry(); - const result = await registry.listAll(); - expect(result).toEqual([]); - }); - }); - - const FAILED_HASH_KEY = `blazebot:failed-tickets:${process.env.VERCEL_ENV ?? "development"}`; - - describe("markFailed", () => { - it("stores failure metadata in the failed-tickets hash", async () => { - const registry = createRegistry(); - const meta = { - runId: "run_abc", - error: "Failed to move ticket to backlog: 403 Forbidden", - failedAt: "2026-04-02T12:34:56.000Z", - }; - await registry.markFailed("AWT-42", meta); - expect(mockRedis.hset).toHaveBeenCalledWith(FAILED_HASH_KEY, { - "AWT-42": JSON.stringify(meta), - }); - }); - }); - - describe("isTicketFailed", () => { - it("returns true when a failure marker exists", async () => { - mockRedis.hget.mockResolvedValueOnce('{"runId":"run_abc","error":"err","failedAt":"2026-04-02T12:34:56.000Z"}'); - const registry = createRegistry(); - const result = await registry.isTicketFailed("AWT-42"); - expect(result).toBe(true); - expect(mockRedis.hget).toHaveBeenCalledWith(FAILED_HASH_KEY, "AWT-42"); - }); - - it("returns false when no failure marker exists", async () => { - mockRedis.hget.mockResolvedValueOnce(null); - const registry = createRegistry(); - const result = await registry.isTicketFailed("AWT-99"); - expect(result).toBe(false); - }); - }); - - describe("listAllFailed", () => { - it("returns all failed ticket markers", async () => { - mockRedis.hgetall.mockResolvedValueOnce({ - "AWT-1": '{"runId":"run_a","error":"err1","failedAt":"2026-04-02T10:00:00.000Z"}', - "AWT-2": '{"runId":"run_b","error":"err2","failedAt":"2026-04-02T11:00:00.000Z"}', - }); - const registry = createRegistry(); - const result = await registry.listAllFailed(); - expect(result).toEqual([ - { ticketKey: "AWT-1", meta: { runId: "run_a", error: "err1", failedAt: "2026-04-02T10:00:00.000Z" } }, - { ticketKey: "AWT-2", meta: { runId: "run_b", error: "err2", failedAt: "2026-04-02T11:00:00.000Z" } }, - ]); - expect(mockRedis.hgetall).toHaveBeenCalledWith(FAILED_HASH_KEY); - }); - - it("returns empty array when no failed tickets", async () => { - mockRedis.hgetall.mockResolvedValueOnce(null); - const registry = createRegistry(); - const result = await registry.listAllFailed(); - expect(result).toEqual([]); - }); - }); - - describe("clearFailedMark", () => { - it("removes the failure marker from the hash", async () => { - const registry = createRegistry(); - await registry.clearFailedMark("AWT-42"); - expect(mockRedis.hdel).toHaveBeenCalledWith(FAILED_HASH_KEY, "AWT-42"); - }); - }); - - describe("ThreadStore methods", () => { - it("setParent then getParent round-trips the message id", async () => { - // Phase 1: setParent writes - const registry = createRegistry(); - await registry.setParent("AWT-42", "1700000000.000123"); - expect(mockRedis.hset).toHaveBeenCalledWith(THREAD_HASH_KEY, { - "AWT-42": "1700000000.000123", - }); - expect(mockRedis.persist).toHaveBeenCalledWith(THREAD_HASH_KEY); - - // Phase 2: getParent reads - mockRedis.hget.mockResolvedValueOnce("1700000000.000123"); - const result = await registry.getParent("AWT-42"); - expect(result).toBe("1700000000.000123"); - expect(mockRedis.hget).toHaveBeenCalledWith(THREAD_HASH_KEY, "AWT-42"); - }); - - it("getParent returns null when no entry exists", async () => { - mockRedis.hget.mockResolvedValueOnce(null); - const registry = createRegistry(); - const result = await registry.getParent("AWT-99"); - expect(result).toBeNull(); - }); - - it("getParent coerces a number-typed result back to a string (Upstash JSON-parses Slack ts)", async () => { - // Slack ts "1777542341.966359" is a string in our setParent call, but - // the @upstash/redis client auto-JSON-parses values, turning numeric- - // looking strings into JS numbers on retrieval. The Slack SDK calls - // .startsWith on the returned messageId, so a number would crash it. - mockRedis.hget.mockResolvedValueOnce(1777542341.966359); - const registry = createRegistry(); - const result = await registry.getParent("AWT-42"); - expect(result).toBe("1777542341.966359"); - expect(typeof result).toBe("string"); - }); - - it("clearParent deletes the entry from the thread hash", async () => { - const registry = createRegistry(); - await registry.clearParent("AWT-42"); - expect(mockRedis.hdel).toHaveBeenCalledWith(THREAD_HASH_KEY, "AWT-42"); - }); - - it("unregister does not touch the thread hash", async () => { - const registry = createRegistry(); - await registry.unregister("AWT-42"); - // unregister deletes from HASH_KEY, SANDBOX_HASH_KEY, ENTRY_TS_HASH_KEY only. - const hdelCalls = mockRedis.hdel.mock.calls.map((c) => c[0]); - expect(hdelCalls).not.toContain(THREAD_HASH_KEY); - }); - }); -}); diff --git a/apps/worker/src/adapters/run-registry/upstash.ts b/apps/worker/src/adapters/run-registry/upstash.ts deleted file mode 100644 index 0de7e7c..0000000 --- a/apps/worker/src/adapters/run-registry/upstash.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Redis } from "@upstash/redis"; -import type { RunRegistryAdapter, FailedTicketMeta, ThreadStore } from "./types.js"; - -const ENV_PREFIX = process.env.VERCEL_ENV ?? "development"; -const HASH_KEY = `blazebot:active-runs:${ENV_PREFIX}`; -const FAILED_HASH_KEY = `blazebot:failed-tickets:${ENV_PREFIX}`; -const SANDBOX_HASH_KEY = `blazebot:sandboxes:${ENV_PREFIX}`; -const ENTRY_TS_HASH_KEY = `blazebot:entry-timestamps:${ENV_PREFIX}`; -const THREAD_HASH_KEY = `blazebot:thread-parents:${ENV_PREFIX}`; - -export class UpstashRunRegistry implements RunRegistryAdapter, ThreadStore { - private redis: Redis; - - constructor(opts: { url: string; token: string }) { - this.redis = new Redis({ url: opts.url, token: opts.token }); - } - - async claim(ticketKey: string, runId: string): Promise { - const result = await this.redis.hsetnx(HASH_KEY, ticketKey, runId); - if (result !== 1) return false; - // Stamp creation time so reconcile can tell a just-written entry from - // a genuine orphan. Best-effort — if this write fails, reconcile just - // falls back to treating the entry as ageless (cleanup-eligible). - await this.redis - .hset(ENTRY_TS_HASH_KEY, { [ticketKey]: String(Date.now()) }) - .catch(() => {}); - return true; - } - - async register(ticketKey: string, runId: string): Promise { - await this.redis.hset(HASH_KEY, { [ticketKey]: runId }); - // Ensure the hash has no expiry — defend against external TTL being set - await this.redis.persist(HASH_KEY); - // Refresh the creation timestamp: register() is called both on the - // initial claim → runId swap and by external seeders, so it's the - // authoritative write point. - await this.redis - .hset(ENTRY_TS_HASH_KEY, { [ticketKey]: String(Date.now()) }) - .catch(() => {}); - } - - async getRunId(ticketKey: string): Promise { - return this.redis.hget(HASH_KEY, ticketKey); - } - - async unregister(ticketKey: string): Promise { - // Clear all three hashes in one round-trip. Each is useless without - // the others, and callers expect unregister() to fully detach. - await Promise.all([ - this.redis.hdel(HASH_KEY, ticketKey), - this.redis.hdel(SANDBOX_HASH_KEY, ticketKey), - this.redis.hdel(ENTRY_TS_HASH_KEY, ticketKey), - ]); - } - - async listAll(): Promise> { - const all = await this.redis.hgetall>(HASH_KEY); - if (!all) return []; - return Object.entries(all).map(([ticketKey, runId]) => ({ ticketKey, runId })); - } - - async registerSandbox(ticketKey: string, sandboxId: string): Promise { - await this.redis.hset(SANDBOX_HASH_KEY, { [ticketKey]: sandboxId }); - await this.redis.persist(SANDBOX_HASH_KEY); - } - - async getSandboxId(ticketKey: string): Promise { - return this.redis.hget(SANDBOX_HASH_KEY, ticketKey); - } - - async getEntryCreatedAt(ticketKey: string): Promise { - const raw = await this.redis.hget( - ENTRY_TS_HASH_KEY, - ticketKey, - ); - if (raw == null) return null; - const parsed = typeof raw === "number" ? raw : parseInt(raw, 10); - return Number.isFinite(parsed) ? parsed : null; - } - - async markFailed(ticketKey: string, meta: FailedTicketMeta): Promise { - await this.redis.hset(FAILED_HASH_KEY, { [ticketKey]: JSON.stringify(meta) }); - } - - async isTicketFailed(ticketKey: string): Promise { - const value = await this.redis.hget(FAILED_HASH_KEY, ticketKey); - return value != null; - } - - async listAllFailed(): Promise> { - const all = await this.redis.hgetall>(FAILED_HASH_KEY); - if (!all) return []; - return Object.entries(all).map(([ticketKey, raw]) => ({ - ticketKey, - meta: (typeof raw === "string" ? JSON.parse(raw) : raw) as FailedTicketMeta, - })); - } - - async clearFailedMark(ticketKey: string): Promise { - await this.redis.hdel(FAILED_HASH_KEY, ticketKey); - } - - async getParent(ticketKey: string): Promise { - // Slack ts values like "1777542341.966359" look numeric, and the Upstash - // client auto-JSON-parses string-encoded numbers back into JS numbers. - // Coerce to string so the Slack SDK (which calls .startsWith on it) works. - const raw = await this.redis.hget(THREAD_HASH_KEY, ticketKey); - if (raw == null) return null; - return String(raw); - } - - async setParent(ticketKey: string, messageId: string): Promise { - await this.redis.hset(THREAD_HASH_KEY, { [ticketKey]: messageId }); - // Defend against any external TTL — the thread mapping must outlive runs. - await this.redis.persist(THREAD_HASH_KEY); - } - - async clearParent(ticketKey: string): Promise { - await this.redis.hdel(THREAD_HASH_KEY, ticketKey); - } -} diff --git a/apps/worker/src/db/client.ts b/apps/worker/src/db/client.ts new file mode 100644 index 0000000..9abf021 --- /dev/null +++ b/apps/worker/src/db/client.ts @@ -0,0 +1,29 @@ +import { neon } from "@neondatabase/serverless"; +import { drizzle } from "drizzle-orm/neon-http"; +import type { PgDatabase } from "drizzle-orm/pg-core"; +import { env } from "../../env.js"; +import * as schema from "./schema.js"; + +/** + * Driver-agnostic database handle. `any` for the query-result HKT so both + * the neon-http production driver and the pglite test driver are + * assignable — adapters only use the query-builder surface, which is + * identical across drivers. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Db = PgDatabase; + +let _db: Db | null = null; + +/** + * Lazily-created singleton. neon() is fetch-based (no sockets, no pools), + * so a module-level singleton is safe in serverless functions AND inside + * Workflow DevKit step bundles (same constraint the Upstash REST client + * satisfied). + */ +export function getDb(): Db { + if (!_db) { + _db = drizzle({ client: neon(env.DATABASE_URL), schema }); + } + return _db; +} diff --git a/apps/worker/src/db/schema.ts b/apps/worker/src/db/schema.ts new file mode 100644 index 0000000..355d917 --- /dev/null +++ b/apps/worker/src/db/schema.ts @@ -0,0 +1,175 @@ +import { sql } from "drizzle-orm"; +import { + bigint, + boolean, + index, + integer, + jsonb, + numeric, + pgTable, + primaryKey, + text, + timestamp, +} from "drizzle-orm/pg-core"; + +/** + * Run registry — replaces the blazebot:active-runs / blazebot:sandboxes / + * blazebot:entry-timestamps Redis hashes. One row per in-flight ticket; + * the three hashes shared a lifecycle (unregister cleared all three), so + * they are one table. createdAt backs reconcile's orphan grace period and + * is REFRESHED on register(), not just set on claim(). + */ +export const activeRuns = pgTable("active_runs", { + ticketKey: text("ticket_key").primaryKey(), + runId: text("run_id").notNull(), + sandboxId: text("sandbox_id"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +/** Replaces blazebot:failed-tickets — FailedTicketMeta as typed columns. */ +export const failedTickets = pgTable("failed_tickets", { + ticketKey: text("ticket_key").primaryKey(), + runId: text("run_id").notNull(), + error: text("error").notNull(), + /** ISO-8601 string, exactly as FailedTicketMeta.failedAt round-trips today. */ + failedAt: text("failed_at").notNull(), +}); + +/** + * Replaces blazebot:thread-parents. Separate table on purpose: thread + * parents survive across runs for the same ticket (unregister must not + * clear them). text column = no more Upstash number-coercion of Slack ts. + */ +export const threadParents = pgTable("thread_parents", { + ticketKey: text("ticket_key").primaryKey(), + messageId: text("message_id").notNull(), +}); + +/** + * Post-PR gate lock — replaces gate:lock:{repo}#{pr} (SET NX EX 30). + * An expired row counts as released; acquire atomically steals it. + */ +export const gateLocks = pgTable( + "gate_locks", + { + repo: text("repo").notNull(), + pr: integer("pr").notNull(), + token: text("token").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + }, + (t) => [primaryKey({ columns: [t.repo, t.pr] })], +); + +/** Replaces gate:dedupe:{repo}#{pr}@{sha} (SET NX EX 14d). */ +export const gateDedupe = pgTable( + "gate_dedupe", + { + repo: text("repo").notNull(), + pr: integer("pr").notNull(), + headSha: text("head_sha").notNull(), + runId: text("run_id").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + }, + (t) => [primaryKey({ columns: [t.repo, t.pr, t.headSha] })], +); + +/** + * Replaces gate:current:{repo}#{pr} (JSON pointer, EX 14d). + * bigint[]: GitHub check-run IDs exceed int4 range. + */ +export const gateCurrent = pgTable( + "gate_current", + { + repo: text("repo").notNull(), + pr: integer("pr").notNull(), + runId: text("run_id").notNull(), + headSha: text("head_sha").notNull(), + checkRunIds: bigint("check_run_ids", { mode: "number" }) + .array() + .notNull() + .default(sql`'{}'::bigint[]`), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + }, + (t) => [primaryKey({ columns: [t.repo, t.pr] })], +); + +/** + * Environment-isolation guard. Exactly one row (id=1). Claimed at build + * time by scripts/db-migrate.ts: if a branch is already claimed by a + * different VERCEL_ENV on the SAME endpoint host, the build fails — + * preview must never share production's Neon branch. A differing endpoint + * host means the branch was copied (Neon branches copy data), so the + * marker is re-claimed instead of failing. + */ +export const envMarker = pgTable("env_marker", { + id: integer("id").primaryKey(), + env: text("env").notNull(), + endpointHost: text("endpoint_host").notNull(), +}); + +/** + * Durable run telemetry — one row per workflow run, keyed by runId. Survives + * far longer than Vercel's ~24h observability window so run history, active + * counts, and per-run cost stay queryable with plain SQL. + * + * Written by two upserters that own disjoint columns: + * - The poll cron snapshots lifecycle/status/ticket/PR(gate) from the + * Workflow world + the run registry (see lib/telemetry/collect-snapshots). + * - The agent workflow records cost/tokens/per-phase usage + the agent PR on + * completion — data that only exists inside the run (see recordRunUsage). + * + * Both use ON CONFLICT (run_id) DO UPDATE setting only their own columns, so + * whichever writes first inserts the row and the other fills in the rest, + * regardless of order. + */ +export const workflowRuns = pgTable("workflow_runs", { + runId: text("run_id").primaryKey(), + + // Lifecycle — cron-owned (from the Workflow world). + workflowId: text("workflow_id"), + workflowName: text("workflow_name"), + status: text("status"), + ticketKey: text("ticket_key"), + ticketTitle: text("ticket_title"), + ticketUrl: text("ticket_url"), + model: text("model"), + sandboxId: text("sandbox_id"), + createdAt: timestamp("created_at", { withTimezone: true }), + startedAt: timestamp("started_at", { withTimezone: true }), + completedAt: timestamp("completed_at", { withTimezone: true }), + durationSec: integer("duration_sec"), + + // Pull request — gate runs from gate_current (cron); agent runs from the + // workflow output (workflow write). + prUrl: text("pr_url"), + prNumber: integer("pr_number"), + prRepo: text("pr_repo"), + + // Cost & usage — workflow-owned (accumulated PhaseUsage). costKnown is false + // when any phase cost couldn't be priced (e.g. Codex with no price lookup). + // numeric(19,4): fixed-precision currency so SQL cost rollups don't drift + // like float (real). mode:"number" keeps the JS type a plain number. + costUsd: numeric("cost_usd", { precision: 19, scale: 4, mode: "number" }), + costKnown: boolean("cost_known"), + tokensInput: integer("tokens_input"), + tokensCached: integer("tokens_cached"), + tokensOutput: integer("tokens_output"), + /** Per-phase breakdown: { [phase]: { costUsd, tokens, durationMs, numTurns } }. */ + phases: jsonb("phases"), + + // Bookkeeping. + firstSeenAt: timestamp("first_seen_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}, (t) => [ + // Built for querying: active-count by status, time-window stats by startedAt, + // per-ticket run history by ticketKey. + index("workflow_runs_status_idx").on(t.status), + index("workflow_runs_started_at_idx").on(t.startedAt), + index("workflow_runs_ticket_key_idx").on(t.ticketKey), +]); diff --git a/apps/worker/src/db/test-db.ts b/apps/worker/src/db/test-db.ts new file mode 100644 index 0000000..d1d28b8 --- /dev/null +++ b/apps/worker/src/db/test-db.ts @@ -0,0 +1,24 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import * as schema from "./schema.js"; +import type { Db } from "./client.js"; + +/** + * In-memory Postgres for unit tests. Applies the committed drizzle/ + * migration SQL so tests run against the exact production schema — + * uniqueness conflicts, array ops, and expiry filters behave for real + * instead of being mocked. + */ +export async function createTestDb(): Promise { + const client = new PGlite(); + const dir = fileURLToPath(new URL("../../drizzle/", import.meta.url)); + const files = readdirSync(dir) + .filter((f) => f.endsWith(".sql")) + .sort(); + for (const f of files) { + await client.exec(readFileSync(`${dir}${f}`, "utf8")); + } + return drizzle({ client, schema }) as unknown as Db; +} diff --git a/apps/worker/src/lib/adapters.ts b/apps/worker/src/lib/adapters.ts index 4ed301b..5e56ad4 100644 --- a/apps/worker/src/lib/adapters.ts +++ b/apps/worker/src/lib/adapters.ts @@ -2,7 +2,8 @@ import { env } from "../../env.js"; import { JiraAdapter } from "../adapters/issue-tracker/jira.js"; import { ChatSDKAdapter } from "../adapters/messaging/chatsdk.js"; import { NoopMessagingAdapter } from "../adapters/messaging/noop.js"; -import { UpstashRunRegistry } from "../adapters/run-registry/upstash.js"; +import { PostgresRunRegistry } from "../adapters/run-registry/postgres.js"; +import { getDb } from "../db/client.js"; import { createVCS } from "./create-vcs.js"; import type { IssueTrackerAdapter } from "../adapters/issue-tracker/types.js"; import type { VCSAdapter } from "../adapters/vcs/types.js"; @@ -20,10 +21,7 @@ export interface Adapters { } export function createAdapters(): Adapters { - const runRegistry = new UpstashRunRegistry({ - url: env.AI_WORKFLOW_KV_REST_API_URL, - token: env.AI_WORKFLOW_KV_REST_API_TOKEN, - }); + const runRegistry = new PostgresRunRegistry(getDb()); const messaging: MessagingAdapter = env.CHAT_SDK_SLACK_TOKEN && env.CHAT_SDK_CHANNEL_ID ? new ChatSDKAdapter({ diff --git a/apps/worker/src/lib/dispatch.ts b/apps/worker/src/lib/dispatch.ts index e8518a9..19f0b05 100644 --- a/apps/worker/src/lib/dispatch.ts +++ b/apps/worker/src/lib/dispatch.ts @@ -70,15 +70,15 @@ export async function dispatchTicket( // Post-claim capacity verify. The precheck above is not atomic with // claim(), so N concurrent dispatches for *different* tickets can all - // pass the precheck and then all claim successfully — pushing Redis - // over the cap. Re-read the registry with our own claim visible and - // decide fairly who stays. + // pass the precheck and then all claim successfully — pushing the run + // registry over the cap. Re-read the registry with our own claim + // visible and decide fairly who stays. // // Fairness rule: sort by (claim timestamp ascending, ticketKey // ascending as tie-breaker); the first `max` entries win. Existing // non-sentinel entries (already-running workflows) are treated as // timestamp 0 so they always win over new claims. Every racer - // eventually converges on the same ordering once Redis writes are + // eventually converges on the same ordering once registry writes are // visible to all, so exactly the excess bail. stage = "postclaim_capacity"; const racers = await runRegistry.listAll(); @@ -190,7 +190,7 @@ export async function dispatchTicket( } /** - * Capacity check counts active runs in the Redis registry — this is the + * Capacity check counts active runs in the Postgres registry — this is the * per-app concurrency limit for blazebot, not a per-team sandbox quota. * * We deliberately exclude claiming sentinels older than STALE_CLAIM_MS so diff --git a/apps/worker/src/lib/overview/collect-runs.ts b/apps/worker/src/lib/overview/collect-runs.ts index c98eb0c..d373c49 100644 --- a/apps/worker/src/lib/overview/collect-runs.ts +++ b/apps/worker/src/lib/overview/collect-runs.ts @@ -30,7 +30,7 @@ export interface WorkflowRunRecord { completedAt?: Date; } -const STATUS_MAP: Record = { +export const STATUS_MAP: Record = { completed: "success", failed: "failed", running: "running", @@ -43,7 +43,7 @@ const WORKFLOW_MAP: Record = { postPrGateWorkflow: { id: "wf_post_pr_gate", name: "Post-PR gate" }, }; -function mapWorkflow(workflowName: string): { id: string; name: string } { +export function mapWorkflow(workflowName: string): { id: string; name: string } { let fn = workflowName; try { fn = parseWorkflowName(workflowName)?.functionName ?? workflowName; diff --git a/apps/worker/src/lib/reconcile.ts b/apps/worker/src/lib/reconcile.ts index 86d2200..967a3c1 100644 --- a/apps/worker/src/lib/reconcile.ts +++ b/apps/worker/src/lib/reconcile.ts @@ -189,9 +189,9 @@ async function reconcileInflightClaim( if (claimIsStale) { // Dispatch starts the workflow (which can spin up a sandbox in the // research phase) *before* overwriting the sentinel with the real - // runId. A crash in that narrow window leaves a sentinel in Redis + // runId. A crash in that narrow window leaves a sentinel in Postgres // alongside a running sandbox we have no way to cancel via the - // workflow handle. Try the fast path (sandboxId from Redis); fall + // workflow handle. Try the fast path (sandboxId from Postgres); fall // back to the parallel branch scan if the workflow crashed before // writing its sandboxId. const sandboxId = await runRegistry diff --git a/apps/worker/src/lib/step-adapters.ts b/apps/worker/src/lib/step-adapters.ts index 95d43a8..c71779b 100644 --- a/apps/worker/src/lib/step-adapters.ts +++ b/apps/worker/src/lib/step-adapters.ts @@ -2,7 +2,8 @@ import { env } from "../../env.js"; import { JiraAdapter } from "../adapters/issue-tracker/jira.js"; import { ChatSDKAdapter } from "../adapters/messaging/chatsdk.js"; import { NoopMessagingAdapter } from "../adapters/messaging/noop.js"; -import { UpstashRunRegistry } from "../adapters/run-registry/upstash.js"; +import { PostgresRunRegistry } from "../adapters/run-registry/postgres.js"; +import { getDb } from "../db/client.js"; import { createVCS } from "./create-vcs.js"; import type { IssueTrackerAdapter } from "../adapters/issue-tracker/types.js"; import type { VCSAdapter } from "../adapters/vcs/types.js"; @@ -17,10 +18,7 @@ export interface StepAdapters { } export function createStepAdapters(): StepAdapters { - const runRegistry = new UpstashRunRegistry({ - url: env.AI_WORKFLOW_KV_REST_API_URL, - token: env.AI_WORKFLOW_KV_REST_API_TOKEN, - }); + const runRegistry = new PostgresRunRegistry(getDb()); const messaging: MessagingAdapter = env.CHAT_SDK_SLACK_TOKEN && env.CHAT_SDK_CHANNEL_ID ? new ChatSDKAdapter({ diff --git a/apps/worker/src/lib/telemetry/collect-snapshots.test.ts b/apps/worker/src/lib/telemetry/collect-snapshots.test.ts new file mode 100644 index 0000000..d37dcf2 --- /dev/null +++ b/apps/worker/src/lib/telemetry/collect-snapshots.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createTestDb } from "../../db/test-db.js"; +import type { Db } from "../../db/client.js"; +import { activeRuns, gateCurrent } from "../../db/schema.js"; +import type { + RunsLister, + WorkflowRunRecord, +} from "../overview/collect-runs.js"; +import { collectSnapshots } from "./collect-snapshots.js"; + +let db: Db; + +beforeEach(async () => { + db = await createTestDb(); +}); + +function listerOf(runs: WorkflowRunRecord[]): RunsLister { + return { list: async () => ({ data: runs }) }; +} + +const run = (over: Partial = {}): WorkflowRunRecord => ({ + runId: "wrun_1", + status: "running", + workflowName: "agentWorkflow", + createdAt: new Date("2026-06-15T10:00:00Z"), + startedAt: new Date("2026-06-15T10:00:05Z"), + ...over, +}); + +describe("collectSnapshots", () => { + it("returns [] when the world has no runs", async () => { + expect(await collectSnapshots({ runsLister: listerOf([]), db })).toEqual([]); + }); + + it("maps world fields and the domain status", async () => { + const [s] = await collectSnapshots({ + runsLister: listerOf([run({ status: "completed", completedAt: new Date("2026-06-15T10:05:05Z") })]), + db, + }); + expect(s.runId).toBe("wrun_1"); + expect(s.workflowId).toBe("wf_agent"); + expect(s.workflowName).toBe("Agent"); + expect(s.status).toBe("success"); + expect(s.durationSec).toBe(300); + }); + + it("leaves durationSec null while running", async () => { + const [s] = await collectSnapshots({ runsLister: listerOf([run()]), db }); + expect(s.durationSec).toBeNull(); + }); + + it("resolves ticketKey + sandboxId from active_runs (no Jira call)", async () => { + await db.insert(activeRuns).values({ ticketKey: "PROJ-9", runId: "wrun_1", sandboxId: "sbx_9" }); + const [s] = await collectSnapshots({ runsLister: listerOf([run()]), db }); + expect(s.ticketKey).toBe("PROJ-9"); + expect(s.sandboxId).toBe("sbx_9"); + expect(s.ticketTitle).toBeNull(); // workflow-owned + }); + + it("resolves the gate PR from gate_current", async () => { + await db.insert(gateCurrent).values({ + repo: "o/r", + pr: 42, + runId: "wrun_g", + headSha: "abc", + expiresAt: new Date("2026-07-01T00:00:00Z"), + }); + const [s] = await collectSnapshots({ + runsLister: listerOf([run({ runId: "wrun_g", workflowName: "postPrGateWorkflow" })]), + db, + }); + expect(s.workflowId).toBe("wf_post_pr_gate"); + expect(s.prRepo).toBe("o/r"); + expect(s.prNumber).toBe(42); + }); + + it("leaves registry fields null for a completed (unregistered) run", async () => { + const [s] = await collectSnapshots({ + runsLister: listerOf([run({ status: "completed", completedAt: new Date("2026-06-15T10:01:05Z") })]), + db, + }); + expect(s.ticketKey).toBeNull(); + expect(s.sandboxId).toBeNull(); + }); +}); diff --git a/apps/worker/src/lib/telemetry/collect-snapshots.ts b/apps/worker/src/lib/telemetry/collect-snapshots.ts new file mode 100644 index 0000000..a2b2a73 --- /dev/null +++ b/apps/worker/src/lib/telemetry/collect-snapshots.ts @@ -0,0 +1,91 @@ +import { inArray } from "drizzle-orm"; +import type { Db } from "../../db/client.js"; +import { activeRuns, gateCurrent } from "../../db/schema.js"; +import { + STATUS_MAP, + mapWorkflow, + type RunsLister, +} from "../overview/collect-runs.js"; +import type { RunSnapshot } from "./run-telemetry.js"; + +export interface CollectSnapshotsOptions { + /** The Workflow world run store — `getWorld().runs`. */ + runsLister: RunsLister; + db: Db; + /** Max recent runs to snapshot per cycle. */ + limit?: number; +} + +/** + * Builds lifecycle snapshot rows from the Workflow world for the poll cron. + * Deliberately makes NO external (Jira) calls: ticketKey + sandboxId come from + * the run registry and gate PRs from gate_current, both already in Neon. Ticket + * titles are filled by the agent workflow's own write, which has them for free. + */ +export async function collectSnapshots( + opts: CollectSnapshotsOptions, +): Promise { + const { runsLister, db } = opts; + const limit = opts.limit ?? 100; + + // resolveData: "none" mirrors collect-runs — avoids the expired-run schema + // rejection that would throw away the whole page. + const { data } = await runsLister.list({ + resolveData: "none", + pagination: { limit }, + }); + if (data.length === 0) return []; + + const runIds = data.map((r) => r.runId); + + const active = await db + .select({ + runId: activeRuns.runId, + ticketKey: activeRuns.ticketKey, + sandboxId: activeRuns.sandboxId, + }) + .from(activeRuns) + .where(inArray(activeRuns.runId, runIds)); + const activeByRun = new Map(active.map((a) => [a.runId, a])); + + const gates = await db + .select({ + runId: gateCurrent.runId, + repo: gateCurrent.repo, + pr: gateCurrent.pr, + }) + .from(gateCurrent) + .where(inArray(gateCurrent.runId, runIds)); + const gateByRun = new Map(gates.map((g) => [g.runId, g])); + + return data.map((run): RunSnapshot => { + const { id, name } = mapWorkflow(run.workflowName); + const startedAt = run.startedAt ?? run.createdAt; + const durationSec = + run.completedAt != null + ? Math.max( + 0, + Math.round((run.completedAt.getTime() - startedAt.getTime()) / 1000), + ) + : null; + const a = activeByRun.get(run.runId); + const g = gateByRun.get(run.runId); + + return { + runId: run.runId, + workflowId: id, + workflowName: name, + status: STATUS_MAP[run.status], + ticketKey: a?.ticketKey ?? null, + ticketTitle: null, // workflow-owned + ticketUrl: null, // workflow-owned + sandboxId: a?.sandboxId ?? null, + createdAt: run.createdAt, + startedAt: run.startedAt ?? null, + completedAt: run.completedAt ?? null, + durationSec, + prRepo: g?.repo ?? null, + prNumber: g?.pr ?? null, + }; + }); +} diff --git a/apps/worker/src/lib/telemetry/run-telemetry.test.ts b/apps/worker/src/lib/telemetry/run-telemetry.test.ts new file mode 100644 index 0000000..218cfb2 --- /dev/null +++ b/apps/worker/src/lib/telemetry/run-telemetry.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { eq } from "drizzle-orm"; +import { createTestDb } from "../../db/test-db.js"; +import type { Db } from "../../db/client.js"; +import { workflowRuns } from "../../db/schema.js"; +import { + upsertRunSnapshots, + recordRunUsage, + type RunSnapshot, + type RunUsage, +} from "./run-telemetry.js"; + +let db: Db; + +beforeEach(async () => { + db = await createTestDb(); +}); + +function row(runId: string) { + return db + .select() + .from(workflowRuns) + .where(eq(workflowRuns.runId, runId)) + .then((r) => r[0]); +} + +const snapshot = (over: Partial = {}): RunSnapshot => ({ + runId: "wrun_1", + workflowId: "wf_agent", + workflowName: "Agent", + status: "running", + ticketKey: "PROJ-1", + ticketTitle: "Add login", + ticketUrl: "https://jira/browse/PROJ-1", + sandboxId: "sbx_1", + createdAt: new Date("2026-06-15T10:00:00Z"), + startedAt: new Date("2026-06-15T10:00:05Z"), + completedAt: null, + durationSec: null, + prRepo: null, + prNumber: null, + ...over, +}); + +const usage = (over: Partial = {}): RunUsage => ({ + runId: "wrun_1", + ticketKey: "PROJ-1", + ticketTitle: "Add login", + ticketUrl: "https://jira/browse/PROJ-1", + model: "claude-opus-4-6", + costUsd: 1.23, + costKnown: true, + tokensInput: 1000, + tokensCached: 200, + tokensOutput: 500, + phases: { Research: { costUsd: 0.5, tokens: null, durationMs: 60000, numTurns: 3 } }, + prUrl: "https://github.com/o/r/pull/7", + prNumber: 7, + ...over, +}); + +describe("upsertRunSnapshots", () => { + it("inserts a row", async () => { + await upsertRunSnapshots(db, [snapshot()]); + const r = await row("wrun_1"); + expect(r.status).toBe("running"); + expect(r.ticketKey).toBe("PROJ-1"); + expect(r.ticketTitle).toBe("Add login"); + }); + + it("is a no-op for an empty batch", async () => { + await upsertRunSnapshots(db, []); + expect(await db.select().from(workflowRuns)).toHaveLength(0); + }); + + it("updates status/timing on re-snapshot", async () => { + await upsertRunSnapshots(db, [snapshot()]); + await upsertRunSnapshots(db, [ + snapshot({ + status: "success", + completedAt: new Date("2026-06-15T10:05:00Z"), + durationSec: 295, + }), + ]); + const r = await row("wrun_1"); + expect(r.status).toBe("success"); + expect(r.durationSec).toBe(295); + }); + + it("does not erase a known ticket title when a later snapshot lacks it", async () => { + await upsertRunSnapshots(db, [snapshot()]); + await upsertRunSnapshots(db, [snapshot({ ticketTitle: null, ticketKey: null })]); + const r = await row("wrun_1"); + expect(r.ticketTitle).toBe("Add login"); + expect(r.ticketKey).toBe("PROJ-1"); + }); +}); + +describe("recordRunUsage", () => { + it("inserts cost when no snapshot exists yet", async () => { + await recordRunUsage(db, usage()); + const r = await row("wrun_1"); + expect(r.costUsd).toBeCloseTo(1.23); + expect(r.tokensOutput).toBe(500); + expect(r.prNumber).toBe(7); + expect(r.status).toBeNull(); // cron hasn't run yet + }); +}); + +describe("two writers converge on one row", () => { + it("merges snapshot then usage", async () => { + await upsertRunSnapshots(db, [snapshot()]); + await recordRunUsage(db, usage()); + const r = await row("wrun_1"); + expect(r.status).toBe("running"); // from cron + expect(r.ticketTitle).toBe("Add login"); // from cron + expect(r.costUsd).toBeCloseTo(1.23); // from workflow + expect(r.prNumber).toBe(7); // from workflow + }); + + it("merges usage then snapshot (order independent)", async () => { + await recordRunUsage(db, usage()); + await upsertRunSnapshots(db, [snapshot()]); + const r = await row("wrun_1"); + expect(r.status).toBe("running"); + expect(r.costUsd).toBeCloseTo(1.23); + expect(r.prNumber).toBe(7); + }); + + it("a later cron snapshot does not clobber the agent PR", async () => { + await recordRunUsage(db, usage()); + await upsertRunSnapshots(db, [snapshot()]); // snapshot has no PR + await upsertRunSnapshots(db, [snapshot({ status: "success" })]); + const r = await row("wrun_1"); + expect(r.prNumber).toBe(7); // preserved + expect(r.prUrl).toBe("https://github.com/o/r/pull/7"); + expect(r.status).toBe("success"); + }); + + it("keeps a gate PR from the cron when the workflow has none", async () => { + await upsertRunSnapshots(db, [snapshot({ prRepo: "o/r", prNumber: 42 })]); + await recordRunUsage(db, usage({ prUrl: null, prNumber: null })); + const r = await row("wrun_1"); + expect(r.prNumber).toBe(42); + expect(r.prRepo).toBe("o/r"); + }); +}); diff --git a/apps/worker/src/lib/telemetry/run-telemetry.ts b/apps/worker/src/lib/telemetry/run-telemetry.ts new file mode 100644 index 0000000..5e3f2eb --- /dev/null +++ b/apps/worker/src/lib/telemetry/run-telemetry.ts @@ -0,0 +1,133 @@ +import { sql } from "drizzle-orm"; +import type { Db } from "../../db/client.js"; +import { workflowRuns } from "../../db/schema.js"; + +/** + * Lifecycle/status fields the poll cron snapshots from the Workflow world and + * the run registry. Authoritative for status & timing; ticket/PR fields are + * best-effort (a flaky Jira/gate lookup one cycle must not erase a good value + * from a previous cycle — see the COALESCE set below). + */ +export interface RunSnapshot { + runId: string; + workflowId: string; + workflowName: string; + status: string; + ticketKey: string | null; + ticketTitle: string | null; + ticketUrl: string | null; + sandboxId: string | null; + createdAt: Date | null; + startedAt: Date | null; + completedAt: Date | null; + durationSec: number | null; + /** Gate runs only — agent runs get their PR from recordRunUsage. */ + prRepo: string | null; + prNumber: number | null; +} + +/** + * Cost/usage fields the agent workflow records on completion. This is the only + * source for per-run cost — it exists transiently inside the run and is gone + * once the workflow returns. + */ +export interface RunUsage { + runId: string; + ticketKey: string | null; + ticketTitle: string | null; + ticketUrl: string | null; + model: string | null; + costUsd: number | null; + costKnown: boolean; + tokensInput: number | null; + tokensCached: number | null; + tokensOutput: number | null; + /** Per-phase breakdown ({ [phase]: { costUsd, tokens, durationMs, numTurns } }). */ + phases: unknown; + prUrl: string | null; + prNumber: number | null; +} + +/** `coalesce(excluded., "workflow_runs"."")` — take the incoming + * value when present, otherwise keep what's already stored. */ +function keepIfNull(column: { name: string }, existing: unknown) { + return sql`coalesce(excluded.${sql.raw(column.name)}, ${existing})`; +} + +/** + * Cron writer. Upserts one row per run, setting only lifecycle columns. + * status/workflowName come straight from the world (always known); ticket and + * PR fields use COALESCE so a transient lookup miss doesn't wipe a good value + * or the workflow's own PR. + */ +export async function upsertRunSnapshots( + db: Db, + rows: RunSnapshot[], +): Promise { + if (rows.length === 0) return; + await db + .insert(workflowRuns) + .values(rows) + .onConflictDoUpdate({ + target: workflowRuns.runId, + set: { + workflowId: sql`excluded.workflow_id`, + workflowName: sql`excluded.workflow_name`, + status: sql`excluded.status`, + ticketKey: keepIfNull(workflowRuns.ticketKey, workflowRuns.ticketKey), + ticketTitle: keepIfNull(workflowRuns.ticketTitle, workflowRuns.ticketTitle), + ticketUrl: keepIfNull(workflowRuns.ticketUrl, workflowRuns.ticketUrl), + sandboxId: keepIfNull(workflowRuns.sandboxId, workflowRuns.sandboxId), + createdAt: keepIfNull(workflowRuns.createdAt, workflowRuns.createdAt), + startedAt: keepIfNull(workflowRuns.startedAt, workflowRuns.startedAt), + completedAt: keepIfNull(workflowRuns.completedAt, workflowRuns.completedAt), + durationSec: keepIfNull(workflowRuns.durationSec, workflowRuns.durationSec), + prRepo: keepIfNull(workflowRuns.prRepo, workflowRuns.prRepo), + prNumber: keepIfNull(workflowRuns.prNumber, workflowRuns.prNumber), + updatedAt: sql`now()`, + }, + }); +} + +/** + * Workflow writer. Upserts the cost/usage (and agent PR) for one run, setting + * only its own columns. PR uses COALESCE so it never erases a gate PR a cron + * snapshot may have recorded for the same row. + */ +export async function recordRunUsage(db: Db, usage: RunUsage): Promise { + await db + .insert(workflowRuns) + .values({ + runId: usage.runId, + ticketKey: usage.ticketKey, + ticketTitle: usage.ticketTitle, + ticketUrl: usage.ticketUrl, + model: usage.model, + costUsd: usage.costUsd, + costKnown: usage.costKnown, + tokensInput: usage.tokensInput, + tokensCached: usage.tokensCached, + tokensOutput: usage.tokensOutput, + phases: usage.phases, + prUrl: usage.prUrl, + prNumber: usage.prNumber, + }) + .onConflictDoUpdate({ + target: workflowRuns.runId, + set: { + ticketKey: keepIfNull(workflowRuns.ticketKey, workflowRuns.ticketKey), + ticketTitle: sql`excluded.ticket_title`, + ticketUrl: sql`excluded.ticket_url`, + model: sql`excluded.model`, + costUsd: sql`excluded.cost_usd`, + costKnown: sql`excluded.cost_known`, + tokensInput: sql`excluded.tokens_input`, + tokensCached: sql`excluded.tokens_cached`, + tokensOutput: sql`excluded.tokens_output`, + phases: sql`excluded.phases`, + prUrl: keepIfNull(workflowRuns.prUrl, workflowRuns.prUrl), + prNumber: keepIfNull(workflowRuns.prNumber, workflowRuns.prNumber), + updatedAt: sql`now()`, + }, + }); +} diff --git a/apps/worker/src/post-pr-gate/gate-store.test.ts b/apps/worker/src/post-pr-gate/gate-store.test.ts new file mode 100644 index 0000000..fcb3067 --- /dev/null +++ b/apps/worker/src/post-pr-gate/gate-store.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { sql } from "drizzle-orm"; +import { GateStore } from "./gate-store.js"; +import { createTestDb } from "../db/test-db.js"; +import type { Db } from "../db/client.js"; + +let db: Db; +let store: GateStore; + +beforeEach(async () => { + db = await createTestDb(); + store = new GateStore(db); +}); + +/** Backdate a lock so it reads as TTL-expired. */ +async function expireLock(repo: string, pr: number) { + await db.execute( + sql`UPDATE gate_locks SET expires_at = now() - interval '1 second' WHERE repo = ${repo} AND pr = ${pr}`, + ); +} + +describe("locks", () => { + it("acquireLock returns a token when free", async () => { + const token = await store.acquireLock("o/r", 1); + expect(token).toBeTruthy(); + }); + + it("acquireLock returns null while held", async () => { + await store.acquireLock("o/r", 1); + expect(await store.acquireLock("o/r", 1)).toBeNull(); + }); + + it("acquireLock steals an expired lock (TTL crash-safety)", async () => { + await store.acquireLock("o/r", 1); + await expireLock("o/r", 1); + expect(await store.acquireLock("o/r", 1)).toBeTruthy(); + }); + + it("locks are independent per repo+pr", async () => { + await store.acquireLock("o/r", 1); + expect(await store.acquireLock("o/r", 2)).toBeTruthy(); + expect(await store.acquireLock("o/other", 1)).toBeTruthy(); + }); + + it("releaseLock with the owning token frees the lock", async () => { + const token = (await store.acquireLock("o/r", 1))!; + await store.releaseLock("o/r", 1, token); + expect(await store.acquireLock("o/r", 1)).toBeTruthy(); + }); + + it("releaseLock with a stale token is a no-op (compare-and-delete)", async () => { + await store.acquireLock("o/r", 1); + await expireLock("o/r", 1); + const newToken = (await store.acquireLock("o/r", 1))!; + await store.releaseLock("o/r", 1, "stale-token"); + // still held by newToken + expect(await store.acquireLock("o/r", 1)).toBeNull(); + await store.releaseLock("o/r", 1, newToken); + expect(await store.acquireLock("o/r", 1)).toBeTruthy(); + }); +}); + +describe("claimRun (dedupe)", () => { + it("returns null when we win the claim", async () => { + expect(await store.claimRun("o/r", 1, "sha1", "run_a")).toBeNull(); + }); + + it("returns the existing runId when already claimed", async () => { + await store.claimRun("o/r", 1, "sha1", "run_a"); + expect(await store.claimRun("o/r", 1, "sha1", "run_b")).toBe("run_a"); + }); + + it("different SHA is a fresh claim", async () => { + await store.claimRun("o/r", 1, "sha1", "run_a"); + expect(await store.claimRun("o/r", 1, "sha2", "run_b")).toBeNull(); + }); + + it("an expired claim behaves as absent (re-claimable)", async () => { + await store.claimRun("o/r", 1, "sha1", "run_a"); + await db.execute( + sql`UPDATE gate_dedupe SET expires_at = now() - interval '1 second'`, + ); + expect(await store.claimRun("o/r", 1, "sha1", "run_b")).toBeNull(); + expect(await store.getDedupe("o/r", 1, "sha1")).toBe("run_b"); + }); + + it("getDedupe returns null for unknown or expired claims", async () => { + expect(await store.getDedupe("o/r", 1, "nope")).toBeNull(); + await store.claimRun("o/r", 1, "sha1", "run_a"); + await db.execute( + sql`UPDATE gate_dedupe SET expires_at = now() - interval '1 second'`, + ); + expect(await store.getDedupe("o/r", 1, "sha1")).toBeNull(); + }); +}); + +describe("current pointer", () => { + const current = { runId: "run_a", headSha: "sha1", checkRunIds: [] as number[] }; + + it("setCurrent/getCurrent round-trips", async () => { + await store.setCurrent("o/r", 1, current); + expect(await store.getCurrent("o/r", 1)).toEqual(current); + }); + + it("getCurrent returns null when absent or expired", async () => { + expect(await store.getCurrent("o/r", 1)).toBeNull(); + await store.setCurrent("o/r", 1, current); + await db.execute( + sql`UPDATE gate_current SET expires_at = now() - interval '1 second'`, + ); + expect(await store.getCurrent("o/r", 1)).toBeNull(); + }); + + it("setCurrent overwrites on force-push (same PR, new SHA)", async () => { + await store.setCurrent("o/r", 1, current); + await store.setCurrent("o/r", 1, { runId: "run_b", headSha: "sha2", checkRunIds: [7] }); + expect(await store.getCurrent("o/r", 1)).toEqual({ + runId: "run_b", + headSha: "sha2", + checkRunIds: [7], + }); + }); + + it("appendCheckRunIdsForSha appends when SHA matches, accumulating", async () => { + await store.setCurrent("o/r", 1, current); + // GitHub check-run IDs exceed int4 — proves bigint[]. + expect(await store.appendCheckRunIdsForSha("o/r", 1, "sha1", [30000000001])).toBe(true); + expect(await store.appendCheckRunIdsForSha("o/r", 1, "sha1", [30000000002, 5])).toBe(true); + expect((await store.getCurrent("o/r", 1))!.checkRunIds).toEqual([ + 30000000001, 30000000002, 5, + ]); + }); + + it("appendCheckRunIdsForSha returns false on SHA mismatch or missing pointer", async () => { + expect(await store.appendCheckRunIdsForSha("o/r", 1, "sha1", [1])).toBe(false); + await store.setCurrent("o/r", 1, current); + expect(await store.appendCheckRunIdsForSha("o/r", 1, "superseded", [1])).toBe(false); + expect((await store.getCurrent("o/r", 1))!.checkRunIds).toEqual([]); + }); + + it("appendCheckRunIdsForSha with empty ids is a no-op true", async () => { + expect(await store.appendCheckRunIdsForSha("o/r", 1, "sha1", [])).toBe(true); + }); + + it("appendCheckRunIdsForSha returns false on an expired pointer", async () => { + await store.setCurrent("o/r", 1, current); + await db.execute( + sql`UPDATE gate_current SET expires_at = now() - interval '1 second'`, + ); + expect(await store.appendCheckRunIdsForSha("o/r", 1, "sha1", [1])).toBe(false); + }); + + it("updateRunIdIfHeadSha returns false on an expired pointer", async () => { + await store.setCurrent("o/r", 1, current); + await db.execute( + sql`UPDATE gate_current SET expires_at = now() - interval '1 second'`, + ); + expect(await store.updateRunIdIfHeadSha("o/r", 1, "sha1", "run_x")).toBe(false); + }); + + it("updateRunIdIfHeadSha updates only on SHA match, preserving checkRunIds", async () => { + await store.setCurrent("o/r", 1, current); + await store.appendCheckRunIdsForSha("o/r", 1, "sha1", [42]); + expect(await store.updateRunIdIfHeadSha("o/r", 1, "sha1", "run_real")).toBe(true); + expect(await store.getCurrent("o/r", 1)).toEqual({ + runId: "run_real", + headSha: "sha1", + checkRunIds: [42], + }); + expect(await store.updateRunIdIfHeadSha("o/r", 1, "superseded", "run_x")).toBe(false); + }); + + it("clearCurrent removes the pointer", async () => { + await store.setCurrent("o/r", 1, current); + await store.clearCurrent("o/r", 1); + expect(await store.getCurrent("o/r", 1)).toBeNull(); + }); +}); + +describe("purgeExpired", () => { + it("deletes only expired rows across all three gate tables", async () => { + await store.acquireLock("o/r", 1); + await store.claimRun("o/r", 1, "sha1", "run_a"); + await store.setCurrent("o/r", 1, { runId: "run_a", headSha: "sha1", checkRunIds: [] }); + await store.claimRun("o/r", 2, "sha9", "run_keep"); + // Expire everything for PR 1 only. + await db.execute(sql`UPDATE gate_locks SET expires_at = now() - interval '1 second' WHERE pr = 1`); + await db.execute(sql`UPDATE gate_dedupe SET expires_at = now() - interval '1 second' WHERE pr = 1`); + await db.execute(sql`UPDATE gate_current SET expires_at = now() - interval '1 second' WHERE pr = 1`); + + await store.purgeExpired(); + + const locks = await db.execute(sql`SELECT count(*)::int AS n FROM gate_locks`); + const dedupe = await db.execute(sql`SELECT count(*)::int AS n FROM gate_dedupe`); + const cur = await db.execute(sql`SELECT count(*)::int AS n FROM gate_current`); + expect(locks.rows[0].n).toBe(0); + expect(dedupe.rows[0].n).toBe(1); // run_keep survives + expect(cur.rows[0].n).toBe(0); + }); +}); diff --git a/apps/worker/src/post-pr-gate/gate-store.ts b/apps/worker/src/post-pr-gate/gate-store.ts index 8881b71..119b77a 100644 --- a/apps/worker/src/post-pr-gate/gate-store.ts +++ b/apps/worker/src/post-pr-gate/gate-store.ts @@ -1,29 +1,32 @@ import { randomUUID } from "node:crypto"; -import { Redis } from "@upstash/redis"; +import { and, eq, sql } from "drizzle-orm"; +import type { Db } from "../db/client.js"; +import { gateCurrent, gateDedupe, gateLocks } from "../db/schema.js"; /** * Application-level dedupe, force-push tracking, and per-PR locking for - * post-pr-gate runs. + * post-pr-gate runs — Postgres edition. * - * Three keys per PR: - * gate:lock:{repo}#{pr} — short-TTL mutex around the webhook critical - * section. Released in `finally`; if the route - * process dies, the TTL releases it. - * gate:dedupe:{repo}#{pr}@{sha} — SET NX with the real `handle.runId`. - * Absent value means "never claimed for this SHA". - * gate:current:{repo}#{pr} — JSON pointer to the latest run. - * Used to cancel the previous run on force-push. + * Three tables (see src/db/schema.ts): + * gate_locks — short-TTL mutex around the webhook critical section. + * Released in `finally`; if the route process dies, the + * expires_at timestamp lets the next acquirer steal it. + * gate_dedupe — one row per {repo, pr, headSha}; INSERT-on-conflict is + * the SET NX equivalent. Absent/expired row means "never + * claimed for this SHA". + * gate_current — pointer to the latest run, used to cancel the previous + * run on force-push. * - * Lifetime: 14 days. PRs older than that fall back to "fresh" behavior on - * re-delivery; acceptable for our use case. + * TTL semantics: a row past its expires_at is treated as ABSENT by every + * read (correctness); physical deletion happens via purgeExpired() in the + * poll cron (housekeeping). Lifetime: 14 days, matching the Redis EX. * - * The `envPrefix` is passed in (not read from `process.env` at module load), - * so namespacing is explicit and unit-testable. Production callers pass - * `env.VERCEL_ENV` from the validated env schema. + * Each former Lua script is now a single SQL statement, so it stays atomic + * over the sessionless neon-http driver — no transactions required. */ -const TTL_SECONDS = 60 * 60 * 24 * 14; -const LOCK_TTL_SECONDS = 30; +const TTL = sql`now() + interval '14 days'`; +const LOCK_TTL = sql`now() + interval '30 seconds'`; export interface CurrentGateRun { runId: string; @@ -32,53 +35,54 @@ export interface CurrentGateRun { } export class GateStore { - private redis: Redis; - private envPrefix: string; - - constructor(opts: { url: string; token: string; envPrefix: string }) { - this.redis = new Redis({ url: opts.url, token: opts.token }); - this.envPrefix = opts.envPrefix; - } - - private lockKey(repo: string, pr: number): string { - return `blazebot:gate:lock:${this.envPrefix}:${repo}#${pr}`; - } - - private currentKey(repo: string, pr: number): string { - return `blazebot:gate:current:${this.envPrefix}:${repo}#${pr}`; - } - - private dedupeKey(repo: string, pr: number, headSha: string): string { - return `blazebot:gate:dedupe:${this.envPrefix}:${repo}#${pr}@${headSha}`; - } + constructor(private db: Db) {} /** * Acquire the per-PR lock. Returns a token if acquired, null if busy. * Caller MUST call `releaseLock` with the same token in a `finally`. + * Single statement: insert wins a free lock; the conflict-update with + * setWhere steals an expired one; otherwise no row returns → busy. */ async acquireLock(repo: string, pr: number): Promise { const token = randomUUID(); - const res = await this.redis.set(this.lockKey(repo, pr), token, { - nx: true, - ex: LOCK_TTL_SECONDS, - }); - return res === "OK" ? token : null; + const rows = await this.db + .insert(gateLocks) + .values({ repo, pr, token, expiresAt: LOCK_TTL }) + .onConflictDoUpdate({ + target: [gateLocks.repo, gateLocks.pr], + set: { + token: sql`excluded.token`, + expiresAt: sql`excluded.expires_at`, + }, + setWhere: sql`${gateLocks.expiresAt} < now()`, + }) + .returning({ token: gateLocks.token }); + return rows.length > 0 ? token : null; } /** - * Release the per-PR lock — only if our token still owns it. A no-op if the - * lock TTL'd out and another holder took over. + * Release the per-PR lock — only if our token still owns it. A no-op if + * the lock expired and another holder took over (token-guarded DELETE, + * the SQL twin of the old compare-and-delete Lua script). */ async releaseLock(repo: string, pr: number, token: string): Promise { - const script = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`; - await this.redis.eval(script, [this.lockKey(repo, pr)], [token]); + await this.db + .delete(gateLocks) + .where( + and( + eq(gateLocks.repo, repo), + eq(gateLocks.pr, pr), + eq(gateLocks.token, token), + ), + ); } /** * Atomically claim a {repo, pr, headSha} as a unique gate run. * Returns the existing runId if already claimed, null if we won the race. - * Designed to be called *inside* `acquireLock`, but the SET NX is a - * defense-in-depth in case the lock TTL'd out mid-critical-section. + * Designed to be called *inside* `acquireLock`, but the conflict guard is + * defense-in-depth in case the lock expired mid-critical-section. + * An expired claim is re-claimable (Redis SET NX EX semantics). */ async claimRun( repo: string, @@ -86,13 +90,20 @@ export class GateStore { headSha: string, runId: string, ): Promise { - const res = await this.redis.set( - this.dedupeKey(repo, pr, headSha), - runId, - { nx: true, ex: TTL_SECONDS }, - ); - if (res === "OK") return null; - return (await this.redis.get(this.dedupeKey(repo, pr, headSha))) ?? null; + const rows = await this.db + .insert(gateDedupe) + .values({ repo, pr, headSha, runId, expiresAt: TTL }) + .onConflictDoUpdate({ + target: [gateDedupe.repo, gateDedupe.pr, gateDedupe.headSha], + set: { + runId: sql`excluded.run_id`, + expiresAt: sql`excluded.expires_at`, + }, + setWhere: sql`${gateDedupe.expiresAt} < now()`, + }) + .returning({ runId: gateDedupe.runId }); + if (rows.length > 0) return null; // inserted fresh or reclaimed expired + return this.getDedupe(repo, pr, headSha); } async getDedupe( @@ -100,11 +111,36 @@ export class GateStore { pr: number, headSha: string, ): Promise { - return (await this.redis.get(this.dedupeKey(repo, pr, headSha))) ?? null; + const rows = await this.db + .select({ runId: gateDedupe.runId }) + .from(gateDedupe) + .where( + and( + eq(gateDedupe.repo, repo), + eq(gateDedupe.pr, pr), + eq(gateDedupe.headSha, headSha), + sql`${gateDedupe.expiresAt} > now()`, + ), + ); + return rows[0]?.runId ?? null; } async getCurrent(repo: string, pr: number): Promise { - return this.redis.get(this.currentKey(repo, pr)); + const rows = await this.db + .select({ + runId: gateCurrent.runId, + headSha: gateCurrent.headSha, + checkRunIds: gateCurrent.checkRunIds, + }) + .from(gateCurrent) + .where( + and( + eq(gateCurrent.repo, repo), + eq(gateCurrent.pr, pr), + sql`${gateCurrent.expiresAt} > now()`, + ), + ); + return rows[0] ?? null; } async setCurrent( @@ -112,19 +148,26 @@ export class GateStore { pr: number, value: CurrentGateRun, ): Promise { - await this.redis.set(this.currentKey(repo, pr), value, { ex: TTL_SECONDS }); + await this.db + .insert(gateCurrent) + .values({ repo, pr, ...value, expiresAt: TTL }) + .onConflictDoUpdate({ + target: [gateCurrent.repo, gateCurrent.pr], + set: { + runId: value.runId, + headSha: value.headSha, + checkRunIds: value.checkRunIds, + expiresAt: TTL, + }, + }); } /** * Atomically append check-run IDs to the current pointer, but only if the * pointer's headSha still matches `expectedHeadSha`. Returns true if the - * append happened, false if the key is missing, malformed, or superseded by - * a force-push. - * - * headSha (not runId) is the guard: the webhook may not have written the - * real runId yet when the workflow appends. The headSha is set BEFORE - * `start()` in the webhook, so it's always present by the time the workflow - * reaches this call. KEEPTTL preserves the 14-day TTL set by `setCurrent`. + * append happened, false if the row is missing, expired, or superseded by + * a force-push. Single conditional UPDATE = the old SHA-guarded Lua + * append; not touching expires_at = KEEPTTL. */ async appendCheckRunIdsForSha( repo: string, @@ -133,32 +176,36 @@ export class GateStore { ids: number[], ): Promise { if (ids.length === 0) return true; - const script = ` -local cur = redis.call("get", KEYS[1]) -if cur == false then return 0 end -local ok, parsed = pcall(cjson.decode, cur) -if not ok then return 0 end -if parsed.headSha ~= ARGV[1] then return 0 end -parsed.checkRunIds = parsed.checkRunIds or {} -for i = 2, #ARGV do - parsed.checkRunIds[#parsed.checkRunIds + 1] = tonumber(ARGV[i]) -end -redis.call("set", KEYS[1], cjson.encode(parsed), "KEEPTTL") -return 1 -`; - const args = [expectedHeadSha, ...ids.map((id) => String(id))]; - const res = await this.redis.eval(script, [this.currentKey(repo, pr)], args); - return res === 1; + // IDs are validated integers; inlined as an array literal because the + // append expression needs a typed bigint[] on the right-hand side. + if (!ids.every((id) => Number.isSafeInteger(id))) { + throw new Error(`non-integer check-run ids: ${ids.join(",")}`); + } + const literal = sql.raw(`'{${ids.join(",")}}'::bigint[]`); + const rows = await this.db + .update(gateCurrent) + .set({ checkRunIds: sql`${gateCurrent.checkRunIds} || ${literal}` }) + .where( + and( + eq(gateCurrent.repo, repo), + eq(gateCurrent.pr, pr), + eq(gateCurrent.headSha, expectedHeadSha), + sql`${gateCurrent.expiresAt} > now()`, + ), + ) + .returning({ pr: gateCurrent.pr }); + return rows.length > 0; } /** - * Atomically set the `runId` field of the current pointer, but only if the - * pointer's headSha still matches `expectedHeadSha`. Returns true if the - * update happened, false if the key is missing or superseded. + * Atomically set the `runId` field of the current pointer, but only if + * the pointer's headSha still matches `expectedHeadSha`. Returns true if + * the update happened, false if the row is missing or superseded. * * Used by the webhook to fill in the real runId AFTER `start()` returns, * without stomping `checkRunIds` that the workflow may have already - * appended. KEEPTTL preserves the TTL from the prior `setCurrent`. + * appended — a column-targeted UPDATE only touches run_id, so that + * property now holds structurally. */ async updateRunIdIfHeadSha( repo: string, @@ -166,25 +213,39 @@ return 1 expectedHeadSha: string, runId: string, ): Promise { - const script = ` -local cur = redis.call("get", KEYS[1]) -if cur == false then return 0 end -local ok, parsed = pcall(cjson.decode, cur) -if not ok then return 0 end -if parsed.headSha ~= ARGV[1] then return 0 end -parsed.runId = ARGV[2] -redis.call("set", KEYS[1], cjson.encode(parsed), "KEEPTTL") -return 1 -`; - const res = await this.redis.eval( - script, - [this.currentKey(repo, pr)], - [expectedHeadSha, runId], - ); - return res === 1; + const rows = await this.db + .update(gateCurrent) + .set({ runId }) + .where( + and( + eq(gateCurrent.repo, repo), + eq(gateCurrent.pr, pr), + eq(gateCurrent.headSha, expectedHeadSha), + sql`${gateCurrent.expiresAt} > now()`, + ), + ) + .returning({ pr: gateCurrent.pr }); + return rows.length > 0; } async clearCurrent(repo: string, pr: number): Promise { - await this.redis.del(this.currentKey(repo, pr)); + await this.db + .delete(gateCurrent) + .where(and(eq(gateCurrent.repo, repo), eq(gateCurrent.pr, pr))); + } + + /** + * Physically delete expired rows. Reads already treat them as absent; + * this is housekeeping so tables don't grow forever. Called from the + * poll cron (src/routes/cron/poll.get.ts), best-effort. + */ + async purgeExpired(): Promise { + await this.db.delete(gateLocks).where(sql`${gateLocks.expiresAt} < now()`); + await this.db + .delete(gateDedupe) + .where(sql`${gateDedupe.expiresAt} < now()`); + await this.db + .delete(gateCurrent) + .where(sql`${gateCurrent.expiresAt} < now()`); } } diff --git a/apps/worker/src/routes/cron/poll.get.ts b/apps/worker/src/routes/cron/poll.get.ts index 0ea01de..32891b9 100644 --- a/apps/worker/src/routes/cron/poll.get.ts +++ b/apps/worker/src/routes/cron/poll.get.ts @@ -1,9 +1,15 @@ import { defineEventHandler, getHeader, createError } from "h3"; +import { getWorld } from "workflow/runtime"; import { env } from "../../../env.js"; import { createAdapters } from "../../lib/adapters.js"; import { dispatchTicket } from "../../lib/dispatch.js"; import { reconcileRuns } from "../../lib/reconcile.js"; import { logger } from "../../lib/logger.js"; +import { GateStore } from "../../post-pr-gate/gate-store.js"; +import { getDb } from "../../db/client.js"; +import { collectSnapshots } from "../../lib/telemetry/collect-snapshots.js"; +import { upsertRunSnapshots } from "../../lib/telemetry/run-telemetry.js"; +import type { RunsLister } from "../../lib/overview/collect-runs.js"; export default defineEventHandler(async (event) => { verifyCronAuth(getHeader(event, "authorization")); @@ -27,6 +33,27 @@ export default defineEventHandler(async (event) => { }, ); + // Housekeeping: physically drop expired gate rows (reads already treat + // them as absent). Best-effort — a failed purge must not fail the poll. + await new GateStore(getDb()) + .purgeExpired() + .catch((err) => logger.warn({ err: (err as Error).message }, "poll_gate_purge_failed")); + + // Telemetry: snapshot run lifecycle from the Workflow world into Neon so run + // history, active counts and durations stay SQL-queryable beyond Vercel's + // ~24h observability window. Per-run cost is filled separately by the agent + // workflow. Best-effort — a failed snapshot must not fail the poll. + try { + const db = getDb(); + const snapshots = await collectSnapshots({ + runsLister: getWorld().runs as RunsLister, + db, + }); + await upsertRunSnapshots(db, snapshots); + } catch (err) { + logger.warn({ err: (err as Error).message }, "poll_snapshot_failed"); + } + return { status: "ok", discovered: ticketKeys.length, diff --git a/apps/worker/src/routes/webhooks/github.post.ts b/apps/worker/src/routes/webhooks/github.post.ts index 3dce673..21341e7 100644 --- a/apps/worker/src/routes/webhooks/github.post.ts +++ b/apps/worker/src/routes/webhooks/github.post.ts @@ -3,6 +3,7 @@ import { start, getRun } from "workflow/api"; import { env } from "../../../env.js"; import { verifyGitHubWebhookSignature } from "../../lib/github-webhook-sig.js"; import { GateStore, type CurrentGateRun } from "../../post-pr-gate/gate-store.js"; +import { getDb } from "../../db/client.js"; import { postPrGateWorkflow } from "../../workflows/post-pr-gate.js"; import { logger } from "../../lib/logger.js"; import { createAdapters } from "../../lib/adapters.js"; @@ -53,11 +54,7 @@ export default defineEventHandler(async (event) => { const headSha = pr.head.sha; const headRef = pr.head.ref; - const gateStore = new GateStore({ - url: env.AI_WORKFLOW_KV_REST_API_URL, - token: env.AI_WORKFLOW_KV_REST_API_TOKEN, - envPrefix: env.VERCEL_ENV ?? "development", - }); + const gateStore = new GateStore(getDb()); const lockToken = await gateStore.acquireLock(ownerRepo, prNumber); if (!lockToken) { diff --git a/apps/worker/src/sandbox/stop-ticket-sandboxes.ts b/apps/worker/src/sandbox/stop-ticket-sandboxes.ts index 661fac0..f67a873 100644 --- a/apps/worker/src/sandbox/stop-ticket-sandboxes.ts +++ b/apps/worker/src/sandbox/stop-ticket-sandboxes.ts @@ -5,12 +5,12 @@ import { getSandboxCredentials } from "./credentials.js"; /** * Best-effort cleanup for leaked sandboxes after ticket cancellation. * - * Fast path: if the caller knows the sandboxId (looked up from Redis via + * Fast path: if the caller knows the sandboxId (looked up from Postgres via * `runRegistry.getSandboxId`), we issue a single `Sandbox.stop()` — no * discovery pass at all. * * Fallback path: scan all running sandboxes and inspect each one's checked- - * out branch. Used when the caller doesn't have a sandboxId (older Redis + * out branch. Used when the caller doesn't have a sandboxId (older registry * state, or a crash between `provisionSandbox` and the sandboxId being * written). The scan runs in parallel — serial iteration over N sandboxes * previously dominated cron's 300s budget when the environment was busy. diff --git a/apps/worker/src/sandbox/usage.ts b/apps/worker/src/sandbox/usage.ts index c93798c..0a149f6 100644 --- a/apps/worker/src/sandbox/usage.ts +++ b/apps/worker/src/sandbox/usage.ts @@ -56,3 +56,83 @@ export function formatUsageReport( const total = anyUnknown ? `$${totalCost.toFixed(2)}+ total` : `$${totalCost.toFixed(2)} total`; return `Usage: ${total} | ${parts.join(" | ")}`; } + +/** Cost of a single phase in USD, or null when it can't be priced. Mirrors the + * selection rule in formatUsageReport: Claude reports cost_usd directly; Codex + * is priced from tokens when a lookup is available; otherwise unknown. */ +export function phaseCostUsd( + usage: PhaseUsage, + priceLookup?: PriceLookup, + model?: string, +): { costUsd: number | null; known: boolean } { + if (usage.cost_usd != null) return { costUsd: usage.cost_usd, known: true }; + if (usage.tokens && priceLookup && model) { + const price = priceLookup(model); + if (price) { + const cost = + usage.tokens.input * price.input + + usage.tokens.cached_input * price.cached_input + + usage.tokens.output * price.output; + return { costUsd: cost, known: true }; + } + } + return { costUsd: null, known: false }; +} + +export interface PhaseTotal { + costUsd: number | null; + tokens: PhaseUsage["tokens"]; + durationMs: number; + numTurns: number; +} + +export interface UsageTotals { + /** Sum of every priced phase. */ + costUsd: number; + /** False if any present phase couldn't be priced — costUsd is then a lower bound. */ + costKnown: boolean; + tokensInput: number; + tokensCached: number; + tokensOutput: number; + /** Per-phase breakdown, persisted as the run's `phases` jsonb. */ + phases: Record; +} + +/** Numeric sibling of formatUsageReport: aggregates accumulated PhaseUsage into + * the totals + per-phase breakdown the telemetry table stores. */ +export function computeUsageTotals( + phases: Record, + priceLookup?: PriceLookup, + model?: string, +): UsageTotals { + let costUsd = 0; + let tokensInput = 0; + let tokensCached = 0; + let tokensOutput = 0; + let costKnown = true; + const breakdown: Record = {}; + + for (const [name, usage] of Object.entries(phases)) { + if (!usage) { + breakdown[name] = { costUsd: null, tokens: null, durationMs: 0, numTurns: 0 }; + costKnown = false; + continue; + } + const { costUsd: c, known } = phaseCostUsd(usage, priceLookup, model); + if (c != null) costUsd += c; + if (!known) costKnown = false; + if (usage.tokens) { + tokensInput += usage.tokens.input; + tokensCached += usage.tokens.cached_input; + tokensOutput += usage.tokens.output; + } + breakdown[name] = { + costUsd: c, + tokens: usage.tokens, + durationMs: usage.duration_ms, + numTurns: usage.num_turns, + }; + } + + return { costUsd, costKnown, tokensInput, tokensCached, tokensOutput, phases: breakdown }; +} diff --git a/apps/worker/src/workflows/agent.ts b/apps/worker/src/workflows/agent.ts index c1314c9..7dd0065 100644 --- a/apps/worker/src/workflows/agent.ts +++ b/apps/worker/src/workflows/agent.ts @@ -1,5 +1,6 @@ -import { sleep } from "workflow"; +import { sleep, getWorkflowMetadata } from "workflow"; import { branchForTicket } from "../lib/branch-prefix.js"; +import { computeUsageTotals, type UsageTotals } from "../sandbox/usage.js"; import type { AgentOutput, PhaseUsage, PhaseKind, PhaseArtifactPaths, ResearchResult, ReviewOutput, } from "../sandbox/agents/types.js"; @@ -526,6 +527,43 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +/** + * Persist the run's cost/usage (+ agent PR + ticket) to the durable telemetry + * table. Called from the workflow's outer finally so cost is recorded on every + * exit — success, clarification, or failure. maxRetries = 0 and the caller + * swallows errors: telemetry must never retry or fail the run. + */ +async function recordRunTelemetryStep(payload: { + runId: string; + ticketKey: string; + ticketTitle: string; + ticketUrl: string; + model: string | null; + totals: UsageTotals; + pr: { url: string; number: number } | null; +}) { + "use step"; + const { getDb } = await import("../db/client.js"); + const { recordRunUsage } = await import("../lib/telemetry/run-telemetry.js"); + const { totals } = payload; + await recordRunUsage(getDb(), { + runId: payload.runId, + ticketKey: payload.ticketKey, + ticketTitle: payload.ticketTitle, + ticketUrl: payload.ticketUrl, + model: payload.model, + costUsd: totals.costUsd, + costKnown: totals.costKnown, + tokensInput: totals.tokensInput, + tokensCached: totals.tokensCached, + tokensOutput: totals.tokensOutput, + phases: totals.phases, + prUrl: payload.pr?.url ?? null, + prNumber: payload.pr?.number ?? null, + }); +} +recordRunTelemetryStep.maxRetries = 0; + // --- Polling helper (not a step — called within the workflow) --- async function pollUntilDone( @@ -553,6 +591,8 @@ async function pollUntilDone( export async function agentWorkflow(ticketId: string) { "use workflow"; + const { workflowRunId } = getWorkflowMetadata(); + const { env, getVcsConfig } = await import("../../env.js"); const { assembleResearchPlanContext, assembleImplementationContext, assembleReviewContext } = await import("../sandbox/context.js"); @@ -576,6 +616,13 @@ export async function agentWorkflow(ticketId: string) { const prompts = await loadPrompts(); const phaseUsages: Record = {}; + // Phases whose agent was launched. A phase that times out or exits before + // its usage is parsed never gets a phaseUsages entry; the finally reconciles + // any such launched-but-missing phase to null so computeUsageTotals flags + // costKnown=false instead of reporting a misleading costUsd=0 / costKnown=true. + const launchedPhases = new Set(); + // Captured on the success path; written as run telemetry in the finally. + let prForTelemetry: { url: string; number: number } | null = null; // Set after provisionSandbox once agentKind is known. let activeModel: string | undefined; let priceLookup: ((m: string) => { input: number; cached_input: number; output: number } | null) | undefined; @@ -700,6 +747,7 @@ export async function agentWorkflow(ticketId: string) { researchPaths.input, researchInput, researchPaths.wrapper, researchScript, ); + launchedPhases.add("Research"); const researchDone = await pollUntilDone(sandboxId, researchPaths.sentinel, 20); if (!researchDone) { @@ -774,6 +822,7 @@ export async function agentWorkflow(ticketId: string) { implPaths.input, implInput, implPaths.wrapper, implScript, ); + launchedPhases.add("Impl"); const implDone = await pollUntilDone(sandboxId, implPaths.sentinel, 35); let implOutput: AgentOutput; @@ -833,6 +882,7 @@ export async function agentWorkflow(ticketId: string) { reviewPaths.input, reviewInput, reviewPaths.wrapper, reviewScript, ); + launchedPhases.add("Review"); const reviewDone = await pollUntilDone(sandboxId, reviewPaths.sentinel, 15); let reviewOutput: ReviewOutput; @@ -894,6 +944,7 @@ export async function agentWorkflow(ticketId: string) { const pr = !prContext ? await createPullRequest(branchName, ticket.title, "") : await findPRForBranch(branchName); + prForTelemetry = { url: pr.url, number: pr.id }; const usageReport = formatUsageReport(phaseUsages, priceLookup, activeModel); await notifyTicket(ticket.identifier, { @@ -922,5 +973,24 @@ export async function agentWorkflow(ticketId: string) { await markTicketFailed(ticket.identifier, `Failed to move ticket to backlog: ${(err as Error).message ?? "unknown"}`).catch(() => {}); } throw err; + } finally { + // A launched phase with no parsed usage (timed out / errored before + // collect) records as unknown, so computeUsageTotals reports + // costKnown=false instead of a misleading costUsd=0 / costKnown=true. + for (const phase of launchedPhases) { + if (!(phase in phaseUsages)) phaseUsages[phase] = null; + } + // Durable cost/usage telemetry, recorded on every exit path (success, + // clarification, or failure). Best-effort: the step never retries and we + // swallow errors so telemetry can't break or delay the run. + await recordRunTelemetryStep({ + runId: workflowRunId, + ticketKey: ticket.identifier, + ticketTitle: ticket.title, + ticketUrl: `${env.JIRA_BASE_URL.replace(/\/+$/, "")}/browse/${ticket.identifier}`, + model: activeModel ?? null, + totals: computeUsageTotals(phaseUsages, priceLookup, activeModel), + pr: prForTelemetry, + }).catch(() => {}); } } diff --git a/apps/worker/src/workflows/post-pr-gate.ts b/apps/worker/src/workflows/post-pr-gate.ts index 70c27fe..61c5d04 100644 --- a/apps/worker/src/workflows/post-pr-gate.ts +++ b/apps/worker/src/workflows/post-pr-gate.ts @@ -34,19 +34,15 @@ async function runGate(input: PostPrGateWorkflowInput) { const { postPrGateStepRegistry } = await import("../post-pr-gate/steps/index.js"); const { executePostPrGatePhase } = await import("../post-pr-gate/runner.js"); const { GateStore } = await import("../post-pr-gate/gate-store.js"); + const { getDb } = await import("../db/client.js"); const { ticketKeyFromBranch } = await import("../lib/branch-prefix.js"); const { createAdapters } = await import("../lib/adapters.js"); const { logger } = await import("../lib/logger.js"); - const { env } = await import("../../env.js"); const { hasCheckRunCapability } = await import("../adapters/vcs/types.js"); const config = loadPostPrGateConfig(); const adapters = createAdapters(); - const gateStore = new GateStore({ - url: env.AI_WORKFLOW_KV_REST_API_URL, - token: env.AI_WORKFLOW_KV_REST_API_TOKEN, - envPrefix: env.VERCEL_ENV ?? "development", - }); + const gateStore = new GateStore(getDb()); if (config.postPrGate.runOn.botPrsOnly && !input.headRef.startsWith("blazebot/")) { logger.info({ headRef: input.headRef }, "post_pr_gate_skipped_not_bot_branch"); diff --git a/docs/superpowers/plans/2026-06-10-redis-to-neon-postgres.md b/docs/superpowers/plans/2026-06-10-redis-to-neon-postgres.md new file mode 100644 index 0000000..0cbb4c0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-redis-to-neon-postgres.md @@ -0,0 +1,1771 @@ +# Redis → Neon Postgres Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace Upstash Redis entirely (run registry + post-PR gate store) with Neon Postgres via Drizzle ORM, preserving one-click marketplace deployment. + +**Architecture:** The adapter interfaces (`RunRegistryAdapter`, `ThreadStore`, `GateStore` public API) stay unchanged — only the implementations behind them move to Postgres. Five Redis hashes collapse into 3 relational tables (`active_runs` merges runs + sandboxes + entry timestamps because `unregister()` already treats them as one lifecycle); the gate store's 3 Lua scripts become single atomic SQL statements (no transactions needed — the Neon HTTP driver has no sessions). Schema migrations run during the Vercel build (`DATABASE_URL` is available there), keeping deploys one-click. Environment isolation comes from Neon branch-per-environment, defended by a build-time guard (`env_marker` table) that fails the build if two `VERCEL_ENV`s share one branch. + +**Tech Stack:** `drizzle-orm` (neon-http driver) + `@neondatabase/serverless`, `drizzle-kit` for migrations, `@electric-sql/pglite` (dev-only) for real-SQL unit tests, vitest. + +**Decisions locked in during design review (2026-06-10):** +- Real relational schema (future dashboard features), not a KV shim. +- Both stores migrate; zero Redis references remain. +- Migrations run in the Vercel build step (one-click preserved). +- No `env` column — isolation via Neon branching + build-time guard + init-neon skill verification. +- Expired gate rows: filtered by `expires_at > now()` on every read (correctness), physically purged in the existing poll cron (housekeeping). +- Cutover: drain and switch — no data migration. Deploy when the registry is empty; thread-parent / gate-dedupe loss is benign. +- Local dev & e2e hit the Neon development branch via `vercel env pull`. +- Delivery/verification beyond unit tests: handled by the operator (Kacper) at the end. + +**Critical semantic invariants (do not lose these in translation):** +1. `register()` must REFRESH the entry timestamp (it's the "authoritative write point" — reconcile's 30s orphan grace period depends on it). Not just `claim()`. +2. `unregister()` deletes run + sandbox + timestamp together but must NOT touch thread parents (they outlive runs). +3. The 30s lock TTL is crash-safety: an expired lock row must be re-acquirable; release only deletes if the token matches. +4. Expired dedupe/current rows behave as ABSENT (Redis TTL semantics): reads filter `expires_at > now()`; `claimRun` on an expired row re-claims it. +5. Slack thread `ts` ("1700000000.000123") stays a string end-to-end — Postgres `text` kills the Upstash JSON-number-coercion hack, but tests must still prove it. +6. GitHub check-run IDs exceed int4 — `check_run_ids` must be `bigint[]`. + +**All call sites that change** (everything else goes through the adapter interfaces): +- `apps/worker/src/lib/adapters.ts:22-26` and `src/lib/step-adapters.ts:19-23` — registry instantiation +- `apps/worker/src/routes/webhooks/github.post.ts:56-60` and `src/workflows/post-pr-gate.ts:45-49` — GateStore instantiation +- `apps/worker/src/routes/cron/poll.get.ts` — add expired-row purge +- `apps/worker/env.ts:122-124`, `env.test.ts:28-29`, `e2e/env.ts` — env vars +- `apps/worker/e2e/helpers/redis.ts` + its 14 importers, `scripts/clear-run-registry.ts` +- `SETUP.md`, `README.md`, `.claude/skills/init-upstash/` → `init-neon/`, `.claude/skills/init-env/SKILL.md` + +--- + +### Task 1: Dependencies, Drizzle schema, generated migration + +**Files:** +- Modify: `apps/worker/package.json` +- Create: `apps/worker/drizzle.config.ts` +- Create: `apps/worker/src/db/schema.ts` +- Create: `apps/worker/drizzle/0000_*.sql` (generated, committed) + +- [ ] **Step 1: Add dependencies** + +```bash +cd apps/worker +pnpm add drizzle-orm @neondatabase/serverless +pnpm add -D drizzle-kit @electric-sql/pglite +``` + +Do NOT remove `@upstash/redis` yet — old code keeps compiling until Task 8. + +- [ ] **Step 2: Add db scripts to `apps/worker/package.json`** + +In `"scripts"`, add (leave `"build"` alone for now — Task 6 wires it): + +```json +"db:generate": "drizzle-kit generate", +"db:migrate": "tsx scripts/db-migrate.ts", +``` + +- [ ] **Step 3: Create `apps/worker/drizzle.config.ts`** + +```ts +import "dotenv/config"; +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "postgresql", + schema: "./src/db/schema.ts", + out: "./drizzle", + dbCredentials: { + // Only needed by `drizzle-kit migrate`; `generate` works without it. + url: process.env.DATABASE_URL ?? "", + }, +}); +``` + +- [ ] **Step 4: Create `apps/worker/src/db/schema.ts`** + +```ts +import { sql } from "drizzle-orm"; +import { + bigint, + integer, + pgTable, + primaryKey, + text, + timestamp, +} from "drizzle-orm/pg-core"; + +/** + * Run registry — replaces the blazebot:active-runs / blazebot:sandboxes / + * blazebot:entry-timestamps Redis hashes. One row per in-flight ticket; + * the three hashes shared a lifecycle (unregister cleared all three), so + * they are one table. createdAt backs reconcile's orphan grace period and + * is REFRESHED on register(), not just set on claim(). + */ +export const activeRuns = pgTable("active_runs", { + ticketKey: text("ticket_key").primaryKey(), + runId: text("run_id").notNull(), + sandboxId: text("sandbox_id"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +/** Replaces blazebot:failed-tickets — FailedTicketMeta as typed columns. */ +export const failedTickets = pgTable("failed_tickets", { + ticketKey: text("ticket_key").primaryKey(), + runId: text("run_id").notNull(), + error: text("error").notNull(), + /** ISO-8601 string, exactly as FailedTicketMeta.failedAt round-trips today. */ + failedAt: text("failed_at").notNull(), +}); + +/** + * Replaces blazebot:thread-parents. Separate table on purpose: thread + * parents survive across runs for the same ticket (unregister must not + * clear them). text column = no more Upstash number-coercion of Slack ts. + */ +export const threadParents = pgTable("thread_parents", { + ticketKey: text("ticket_key").primaryKey(), + messageId: text("message_id").notNull(), +}); + +/** + * Post-PR gate lock — replaces gate:lock:{repo}#{pr} (SET NX EX 30). + * An expired row counts as released; acquire atomically steals it. + */ +export const gateLocks = pgTable( + "gate_locks", + { + repo: text("repo").notNull(), + pr: integer("pr").notNull(), + token: text("token").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + }, + (t) => [primaryKey({ columns: [t.repo, t.pr] })], +); + +/** Replaces gate:dedupe:{repo}#{pr}@{sha} (SET NX EX 14d). */ +export const gateDedupe = pgTable( + "gate_dedupe", + { + repo: text("repo").notNull(), + pr: integer("pr").notNull(), + headSha: text("head_sha").notNull(), + runId: text("run_id").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + }, + (t) => [primaryKey({ columns: [t.repo, t.pr, t.headSha] })], +); + +/** + * Replaces gate:current:{repo}#{pr} (JSON pointer, EX 14d). + * bigint[]: GitHub check-run IDs exceed int4 range. + */ +export const gateCurrent = pgTable( + "gate_current", + { + repo: text("repo").notNull(), + pr: integer("pr").notNull(), + runId: text("run_id").notNull(), + headSha: text("head_sha").notNull(), + checkRunIds: bigint("check_run_ids", { mode: "number" }) + .array() + .notNull() + .default(sql`'{}'::bigint[]`), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + }, + (t) => [primaryKey({ columns: [t.repo, t.pr] })], +); + +/** + * Environment-isolation guard. Exactly one row (id=1). Claimed at build + * time by scripts/db-migrate.ts: if a branch is already claimed by a + * different VERCEL_ENV on the SAME endpoint host, the build fails — + * preview must never share production's Neon branch. A differing endpoint + * host means the branch was copied (Neon branches copy data), so the + * marker is re-claimed instead of failing. + */ +export const envMarker = pgTable("env_marker", { + id: integer("id").primaryKey(), + env: text("env").notNull(), + endpointHost: text("endpoint_host").notNull(), +}); +``` + +- [ ] **Step 5: Generate the migration** + +```bash +cd apps/worker && pnpm db:generate +``` + +Expected: creates `apps/worker/drizzle/0000__.sql` plus `drizzle/meta/`. Open the SQL and verify it contains `CREATE TABLE "active_runs"`, `"failed_tickets"`, `"thread_parents"`, `"gate_locks"`, `"gate_dedupe"`, `"gate_current"`, `"env_marker"`, and that `check_run_ids` is `bigint[]`. + +- [ ] **Step 6: Typecheck** + +```bash +cd apps/worker && pnpm typecheck +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/worker/package.json apps/worker/drizzle.config.ts apps/worker/src/db/schema.ts apps/worker/drizzle pnpm-lock.yaml +git commit -m "feat(db): add Drizzle schema and migration for Neon run registry + gate store" +``` + +--- + +### Task 2: DB client and pglite test harness + +**Files:** +- Create: `apps/worker/src/db/client.ts` +- Create: `apps/worker/src/db/test-db.ts` + +- [ ] **Step 1: Create `apps/worker/src/db/client.ts`** + +```ts +import { neon } from "@neondatabase/serverless"; +import { drizzle } from "drizzle-orm/neon-http"; +import type { PgDatabase } from "drizzle-orm/pg-core"; +import { env } from "../../env.js"; +import * as schema from "./schema.js"; + +/** + * Driver-agnostic database handle. `any` for the query-result HKT so both + * the neon-http production driver and the pglite test driver are + * assignable — adapters only use the query-builder surface, which is + * identical across drivers. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Db = PgDatabase; + +let _db: Db | null = null; + +/** + * Lazily-created singleton. neon() is fetch-based (no sockets, no pools), + * so a module-level singleton is safe in serverless functions AND inside + * Workflow DevKit step bundles (same constraint the Upstash REST client + * satisfied). + */ +export function getDb(): Db { + if (!_db) { + _db = drizzle({ client: neon(env.DATABASE_URL), schema }); + } + return _db; +} +``` + +Note: `env.DATABASE_URL` doesn't exist yet — Task 5 adds it. To keep this task self-contained and compiling, Task 5 ordering is fine because nothing imports `client.ts` until Task 5 either. If `pnpm typecheck` complains now, add the env var in this task instead (move Task 5 Step 1 here) — both orderings are acceptable. + +- [ ] **Step 2: Create `apps/worker/src/db/test-db.ts`** + +```ts +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import * as schema from "./schema.js"; +import type { Db } from "./client.js"; + +/** + * In-memory Postgres for unit tests. Applies the committed drizzle/ + * migration SQL so tests run against the exact production schema — + * uniqueness conflicts, array ops, and expiry filters behave for real + * instead of being mocked. + */ +export async function createTestDb(): Promise { + const client = new PGlite(); + const dir = fileURLToPath(new URL("../../drizzle/", import.meta.url)); + const files = readdirSync(dir) + .filter((f) => f.endsWith(".sql")) + .sort(); + for (const f of files) { + await client.exec(readFileSync(`${dir}${f}`, "utf8")); + } + return drizzle({ client, schema }) as unknown as Db; +} +``` + +- [ ] **Step 3: Smoke-test the harness** — create a throwaway check inside the next task's test file instead of a separate file (Task 3 Step 1's first test exercises `createTestDb()`). For now just typecheck: + +```bash +cd apps/worker && pnpm typecheck +``` + +Expected: PASS (if `env.DATABASE_URL` errors, see Step 1 note). + +- [ ] **Step 4: Commit** + +```bash +git add apps/worker/src/db/client.ts apps/worker/src/db/test-db.ts +git commit -m "feat(db): add neon-http client and pglite test harness" +``` + +--- + +### Task 3: PostgresRunRegistry (TDD) + +**Files:** +- Create: `apps/worker/src/adapters/run-registry/postgres.ts` +- Test: `apps/worker/src/adapters/run-registry/postgres.test.ts` + +- [ ] **Step 1: Write the failing tests** — `postgres.test.ts`. These port every behavior from `upstash.test.ts` plus the two invariants the old mock-based tests couldn't cover (claim atomicity against real uniqueness; register refreshing the timestamp): + +```ts +import { describe, it, expect, beforeEach } from "vitest"; +import { sql } from "drizzle-orm"; +import { PostgresRunRegistry } from "./postgres.js"; +import { createTestDb } from "../../db/test-db.js"; +import type { Db } from "../../db/client.js"; + +let db: Db; +let registry: PostgresRunRegistry; + +beforeEach(async () => { + db = await createTestDb(); + registry = new PostgresRunRegistry(db); +}); + +describe("claim", () => { + it("returns true when the ticket is unclaimed", async () => { + expect(await registry.claim("PROJ-1", "claiming")).toBe(true); + expect(await registry.getRunId("PROJ-1")).toBe("claiming"); + }); + + it("returns false when the ticket is already claimed", async () => { + await registry.claim("PROJ-1", "claiming"); + expect(await registry.claim("PROJ-1", "other")).toBe(false); + expect(await registry.getRunId("PROJ-1")).toBe("claiming"); + }); + + it("stamps a creation timestamp", async () => { + const before = Date.now(); + await registry.claim("PROJ-1", "claiming"); + const ts = await registry.getEntryCreatedAt("PROJ-1"); + expect(ts).toBeGreaterThanOrEqual(before - 1000); + expect(ts).toBeLessThanOrEqual(Date.now() + 1000); + }); +}); + +describe("register", () => { + it("overwrites the runId after a claim", async () => { + await registry.claim("PROJ-1", "claiming"); + await registry.register("PROJ-1", "run_abc"); + expect(await registry.getRunId("PROJ-1")).toBe("run_abc"); + }); + + it("inserts when no claim exists (external seeders)", async () => { + await registry.register("PROJ-2", "run_xyz"); + expect(await registry.getRunId("PROJ-2")).toBe("run_xyz"); + }); + + it("REFRESHES the creation timestamp (authoritative write point — reconcile orphan grace period)", async () => { + await registry.claim("PROJ-1", "claiming"); + // Backdate the entry past any grace window, as if claimed long ago. + await db.execute( + sql`UPDATE active_runs SET created_at = now() - interval '10 minutes' WHERE ticket_key = 'PROJ-1'`, + ); + const stale = await registry.getEntryCreatedAt("PROJ-1"); + expect(Date.now() - stale!).toBeGreaterThan(9 * 60 * 1000); + + await registry.register("PROJ-1", "run_abc"); + const fresh = await registry.getEntryCreatedAt("PROJ-1"); + expect(Date.now() - fresh!).toBeLessThan(60 * 1000); + }); + + it("does not clobber a registered sandboxId", async () => { + await registry.claim("PROJ-1", "claiming"); + await registry.registerSandbox("PROJ-1", "sbox_1"); + await registry.register("PROJ-1", "run_abc"); + expect(await registry.getSandboxId("PROJ-1")).toBe("sbox_1"); + }); +}); + +describe("getRunId", () => { + it("returns null when not registered", async () => { + expect(await registry.getRunId("PROJ-99")).toBeNull(); + }); +}); + +describe("unregister", () => { + it("removes run, sandbox, and timestamp together", async () => { + await registry.claim("PROJ-1", "run_abc"); + await registry.registerSandbox("PROJ-1", "sbox_1"); + await registry.unregister("PROJ-1"); + expect(await registry.getRunId("PROJ-1")).toBeNull(); + expect(await registry.getSandboxId("PROJ-1")).toBeNull(); + expect(await registry.getEntryCreatedAt("PROJ-1")).toBeNull(); + }); + + it("does NOT touch thread parents (they outlive runs)", async () => { + await registry.claim("PROJ-1", "run_abc"); + await registry.setParent("PROJ-1", "1700000000.000123"); + await registry.unregister("PROJ-1"); + expect(await registry.getParent("PROJ-1")).toBe("1700000000.000123"); + }); +}); + +describe("listAll", () => { + it("returns all ticket -> runId pairs", async () => { + await registry.claim("PROJ-1", "run_abc"); + await registry.claim("PROJ-2", "run_def"); + const all = await registry.listAll(); + expect(all).toHaveLength(2); + expect(all).toContainEqual({ ticketKey: "PROJ-1", runId: "run_abc" }); + expect(all).toContainEqual({ ticketKey: "PROJ-2", runId: "run_def" }); + }); + + it("returns empty array when none registered", async () => { + expect(await registry.listAll()).toEqual([]); + }); +}); + +describe("sandbox", () => { + it("registerSandbox/getSandboxId round-trips", async () => { + await registry.claim("PROJ-1", "run_abc"); + await registry.registerSandbox("PROJ-1", "sbox_12345"); + expect(await registry.getSandboxId("PROJ-1")).toBe("sbox_12345"); + }); + + it("getSandboxId returns null when never registered", async () => { + await registry.claim("PROJ-1", "run_abc"); + expect(await registry.getSandboxId("PROJ-1")).toBeNull(); + }); +}); + +describe("failed tickets", () => { + const meta = { + runId: "run_abc", + error: "Failed to move ticket to backlog: 403 Forbidden", + failedAt: "2026-04-02T12:34:56.000Z", + }; + + it("markFailed/isTicketFailed/listAllFailed round-trips meta exactly", async () => { + await registry.markFailed("AWT-42", meta); + expect(await registry.isTicketFailed("AWT-42")).toBe(true); + expect(await registry.listAllFailed()).toEqual([ + { ticketKey: "AWT-42", meta }, + ]); + }); + + it("markFailed twice updates rather than throwing", async () => { + await registry.markFailed("AWT-42", meta); + await registry.markFailed("AWT-42", { ...meta, error: "second" }); + const [entry] = await registry.listAllFailed(); + expect(entry.meta.error).toBe("second"); + }); + + it("isTicketFailed returns false / listAllFailed empty when none", async () => { + expect(await registry.isTicketFailed("AWT-99")).toBe(false); + expect(await registry.listAllFailed()).toEqual([]); + }); + + it("clearFailedMark removes the marker", async () => { + await registry.markFailed("AWT-42", meta); + await registry.clearFailedMark("AWT-42"); + expect(await registry.isTicketFailed("AWT-42")).toBe(false); + }); +}); + +describe("ThreadStore", () => { + it("setParent/getParent round-trips a Slack ts as a STRING", async () => { + await registry.setParent("AWT-42", "1777542341.966359"); + const result = await registry.getParent("AWT-42"); + expect(result).toBe("1777542341.966359"); + expect(typeof result).toBe("string"); + }); + + it("setParent overwrites a prior value", async () => { + await registry.setParent("AWT-42", "111.000"); + await registry.setParent("AWT-42", "222.000"); + expect(await registry.getParent("AWT-42")).toBe("222.000"); + }); + + it("getParent returns null when no entry", async () => { + expect(await registry.getParent("AWT-99")).toBeNull(); + }); + + it("clearParent deletes the entry", async () => { + await registry.setParent("AWT-42", "1700000000.000123"); + await registry.clearParent("AWT-42"); + expect(await registry.getParent("AWT-42")).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd apps/worker && pnpm exec vitest run src/adapters/run-registry/postgres.test.ts +``` + +Expected: FAIL — `Cannot find module './postgres.js'`. + +- [ ] **Step 3: Implement `apps/worker/src/adapters/run-registry/postgres.ts`** + +```ts +import { eq, sql } from "drizzle-orm"; +import type { Db } from "../../db/client.js"; +import { + activeRuns, + failedTickets, + threadParents, +} from "../../db/schema.js"; +import type { + FailedTicketMeta, + RunRegistryAdapter, + ThreadStore, +} from "./types.js"; + +export class PostgresRunRegistry implements RunRegistryAdapter, ThreadStore { + constructor(private db: Db) {} + + async claim(ticketKey: string, runId: string): Promise { + // INSERT ... ON CONFLICT DO NOTHING is the HSETNX equivalent: exactly + // one concurrent claimer gets a row back. created_at defaults to now(), + // which doubles as the entry timestamp for reconcile's grace period. + const rows = await this.db + .insert(activeRuns) + .values({ ticketKey, runId }) + .onConflictDoNothing({ target: activeRuns.ticketKey }) + .returning({ ticketKey: activeRuns.ticketKey }); + return rows.length > 0; + } + + async register(ticketKey: string, runId: string): Promise { + // Refresh created_at: register() is called both on the claim → runId + // swap and by external seeders, so it's the authoritative write point + // for the orphan grace period. sandbox_id is intentionally untouched. + await this.db + .insert(activeRuns) + .values({ ticketKey, runId }) + .onConflictDoUpdate({ + target: activeRuns.ticketKey, + set: { runId, createdAt: sql`now()` }, + }); + } + + async getRunId(ticketKey: string): Promise { + const rows = await this.db + .select({ runId: activeRuns.runId }) + .from(activeRuns) + .where(eq(activeRuns.ticketKey, ticketKey)); + return rows[0]?.runId ?? null; + } + + async unregister(ticketKey: string): Promise { + // One row holds run, sandbox, and timestamp — deleting it fully + // detaches the ticket. Thread parents live in their own table and + // survive (see ThreadStore docs in types.ts). + await this.db.delete(activeRuns).where(eq(activeRuns.ticketKey, ticketKey)); + } + + async listAll(): Promise> { + return this.db + .select({ ticketKey: activeRuns.ticketKey, runId: activeRuns.runId }) + .from(activeRuns); + } + + async registerSandbox(ticketKey: string, sandboxId: string): Promise { + // Sandboxes are only registered after claim()/register(), so the row + // exists; a bare UPDATE keeps run_id NOT NULL without an upsert dance. + await this.db + .update(activeRuns) + .set({ sandboxId }) + .where(eq(activeRuns.ticketKey, ticketKey)); + } + + async getSandboxId(ticketKey: string): Promise { + const rows = await this.db + .select({ sandboxId: activeRuns.sandboxId }) + .from(activeRuns) + .where(eq(activeRuns.ticketKey, ticketKey)); + return rows[0]?.sandboxId ?? null; + } + + async getEntryCreatedAt(ticketKey: string): Promise { + const rows = await this.db + .select({ createdAt: activeRuns.createdAt }) + .from(activeRuns) + .where(eq(activeRuns.ticketKey, ticketKey)); + return rows[0]?.createdAt?.getTime() ?? null; + } + + async markFailed(ticketKey: string, meta: FailedTicketMeta): Promise { + await this.db + .insert(failedTickets) + .values({ ticketKey, ...meta }) + .onConflictDoUpdate({ + target: failedTickets.ticketKey, + set: { runId: meta.runId, error: meta.error, failedAt: meta.failedAt }, + }); + } + + async isTicketFailed(ticketKey: string): Promise { + const rows = await this.db + .select({ ticketKey: failedTickets.ticketKey }) + .from(failedTickets) + .where(eq(failedTickets.ticketKey, ticketKey)); + return rows.length > 0; + } + + async listAllFailed(): Promise< + Array<{ ticketKey: string; meta: FailedTicketMeta }> + > { + const rows = await this.db.select().from(failedTickets); + return rows.map(({ ticketKey, runId, error, failedAt }) => ({ + ticketKey, + meta: { runId, error, failedAt }, + })); + } + + async clearFailedMark(ticketKey: string): Promise { + await this.db + .delete(failedTickets) + .where(eq(failedTickets.ticketKey, ticketKey)); + } + + async getParent(ticketKey: string): Promise { + const rows = await this.db + .select({ messageId: threadParents.messageId }) + .from(threadParents) + .where(eq(threadParents.ticketKey, ticketKey)); + return rows[0]?.messageId ?? null; + } + + async setParent(ticketKey: string, messageId: string): Promise { + await this.db + .insert(threadParents) + .values({ ticketKey, messageId }) + .onConflictDoUpdate({ + target: threadParents.ticketKey, + set: { messageId }, + }); + } + + async clearParent(ticketKey: string): Promise { + await this.db + .delete(threadParents) + .where(eq(threadParents.ticketKey, ticketKey)); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd apps/worker && pnpm exec vitest run src/adapters/run-registry/postgres.test.ts +``` + +Expected: PASS (all ~20 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/worker/src/adapters/run-registry/postgres.ts apps/worker/src/adapters/run-registry/postgres.test.ts +git commit -m "feat(registry): Postgres run registry adapter with pglite-backed tests" +``` + +--- + +### Task 4: GateStore rewrite (TDD) + +**Files:** +- Rewrite: `apps/worker/src/post-pr-gate/gate-store.ts` +- Test: `apps/worker/src/post-pr-gate/gate-store.test.ts` (new) + +The public API is unchanged except the constructor: `new GateStore(db)` replaces `new GateStore({ url, token, envPrefix })`. Each Lua script becomes one atomic SQL statement. "Expired" ≡ "absent" everywhere. + +- [ ] **Step 1: Write the failing tests** — `gate-store.test.ts`: + +```ts +import { describe, it, expect, beforeEach } from "vitest"; +import { sql } from "drizzle-orm"; +import { GateStore } from "./gate-store.js"; +import { createTestDb } from "../db/test-db.js"; +import type { Db } from "../db/client.js"; + +let db: Db; +let store: GateStore; + +beforeEach(async () => { + db = await createTestDb(); + store = new GateStore(db); +}); + +/** Backdate a lock so it reads as TTL-expired. */ +async function expireLock(repo: string, pr: number) { + await db.execute( + sql`UPDATE gate_locks SET expires_at = now() - interval '1 second' WHERE repo = ${repo} AND pr = ${pr}`, + ); +} + +describe("locks", () => { + it("acquireLock returns a token when free", async () => { + const token = await store.acquireLock("o/r", 1); + expect(token).toBeTruthy(); + }); + + it("acquireLock returns null while held", async () => { + await store.acquireLock("o/r", 1); + expect(await store.acquireLock("o/r", 1)).toBeNull(); + }); + + it("acquireLock steals an expired lock (TTL crash-safety)", async () => { + await store.acquireLock("o/r", 1); + await expireLock("o/r", 1); + expect(await store.acquireLock("o/r", 1)).toBeTruthy(); + }); + + it("locks are independent per repo+pr", async () => { + await store.acquireLock("o/r", 1); + expect(await store.acquireLock("o/r", 2)).toBeTruthy(); + expect(await store.acquireLock("o/other", 1)).toBeTruthy(); + }); + + it("releaseLock with the owning token frees the lock", async () => { + const token = (await store.acquireLock("o/r", 1))!; + await store.releaseLock("o/r", 1, token); + expect(await store.acquireLock("o/r", 1)).toBeTruthy(); + }); + + it("releaseLock with a stale token is a no-op (compare-and-delete)", async () => { + await store.acquireLock("o/r", 1); + await expireLock("o/r", 1); + const newToken = (await store.acquireLock("o/r", 1))!; + await store.releaseLock("o/r", 1, "stale-token"); + // still held by newToken + expect(await store.acquireLock("o/r", 1)).toBeNull(); + await store.releaseLock("o/r", 1, newToken); + expect(await store.acquireLock("o/r", 1)).toBeTruthy(); + }); +}); + +describe("claimRun (dedupe)", () => { + it("returns null when we win the claim", async () => { + expect(await store.claimRun("o/r", 1, "sha1", "run_a")).toBeNull(); + }); + + it("returns the existing runId when already claimed", async () => { + await store.claimRun("o/r", 1, "sha1", "run_a"); + expect(await store.claimRun("o/r", 1, "sha1", "run_b")).toBe("run_a"); + }); + + it("different SHA is a fresh claim", async () => { + await store.claimRun("o/r", 1, "sha1", "run_a"); + expect(await store.claimRun("o/r", 1, "sha2", "run_b")).toBeNull(); + }); + + it("an expired claim behaves as absent (re-claimable)", async () => { + await store.claimRun("o/r", 1, "sha1", "run_a"); + await db.execute( + sql`UPDATE gate_dedupe SET expires_at = now() - interval '1 second'`, + ); + expect(await store.claimRun("o/r", 1, "sha1", "run_b")).toBeNull(); + expect(await store.getDedupe("o/r", 1, "sha1")).toBe("run_b"); + }); + + it("getDedupe returns null for unknown or expired claims", async () => { + expect(await store.getDedupe("o/r", 1, "nope")).toBeNull(); + await store.claimRun("o/r", 1, "sha1", "run_a"); + await db.execute( + sql`UPDATE gate_dedupe SET expires_at = now() - interval '1 second'`, + ); + expect(await store.getDedupe("o/r", 1, "sha1")).toBeNull(); + }); +}); + +describe("current pointer", () => { + const current = { runId: "run_a", headSha: "sha1", checkRunIds: [] as number[] }; + + it("setCurrent/getCurrent round-trips", async () => { + await store.setCurrent("o/r", 1, current); + expect(await store.getCurrent("o/r", 1)).toEqual(current); + }); + + it("getCurrent returns null when absent or expired", async () => { + expect(await store.getCurrent("o/r", 1)).toBeNull(); + await store.setCurrent("o/r", 1, current); + await db.execute( + sql`UPDATE gate_current SET expires_at = now() - interval '1 second'`, + ); + expect(await store.getCurrent("o/r", 1)).toBeNull(); + }); + + it("setCurrent overwrites on force-push (same PR, new SHA)", async () => { + await store.setCurrent("o/r", 1, current); + await store.setCurrent("o/r", 1, { runId: "run_b", headSha: "sha2", checkRunIds: [7] }); + expect(await store.getCurrent("o/r", 1)).toEqual({ + runId: "run_b", + headSha: "sha2", + checkRunIds: [7], + }); + }); + + it("appendCheckRunIdsForSha appends when SHA matches, accumulating", async () => { + await store.setCurrent("o/r", 1, current); + // GitHub check-run IDs exceed int4 — proves bigint[]. + expect(await store.appendCheckRunIdsForSha("o/r", 1, "sha1", [30000000001])).toBe(true); + expect(await store.appendCheckRunIdsForSha("o/r", 1, "sha1", [30000000002, 5])).toBe(true); + expect((await store.getCurrent("o/r", 1))!.checkRunIds).toEqual([ + 30000000001, 30000000002, 5, + ]); + }); + + it("appendCheckRunIdsForSha returns false on SHA mismatch or missing pointer", async () => { + expect(await store.appendCheckRunIdsForSha("o/r", 1, "sha1", [1])).toBe(false); + await store.setCurrent("o/r", 1, current); + expect(await store.appendCheckRunIdsForSha("o/r", 1, "superseded", [1])).toBe(false); + expect((await store.getCurrent("o/r", 1))!.checkRunIds).toEqual([]); + }); + + it("appendCheckRunIdsForSha with empty ids is a no-op true", async () => { + expect(await store.appendCheckRunIdsForSha("o/r", 1, "sha1", [])).toBe(true); + }); + + it("updateRunIdIfHeadSha updates only on SHA match, preserving checkRunIds", async () => { + await store.setCurrent("o/r", 1, current); + await store.appendCheckRunIdsForSha("o/r", 1, "sha1", [42]); + expect(await store.updateRunIdIfHeadSha("o/r", 1, "sha1", "run_real")).toBe(true); + expect(await store.getCurrent("o/r", 1)).toEqual({ + runId: "run_real", + headSha: "sha1", + checkRunIds: [42], + }); + expect(await store.updateRunIdIfHeadSha("o/r", 1, "superseded", "run_x")).toBe(false); + }); + + it("clearCurrent removes the pointer", async () => { + await store.setCurrent("o/r", 1, current); + await store.clearCurrent("o/r", 1); + expect(await store.getCurrent("o/r", 1)).toBeNull(); + }); +}); + +describe("purgeExpired", () => { + it("deletes only expired rows across all three gate tables", async () => { + await store.acquireLock("o/r", 1); + await store.claimRun("o/r", 1, "sha1", "run_a"); + await store.setCurrent("o/r", 1, { runId: "run_a", headSha: "sha1", checkRunIds: [] }); + await store.claimRun("o/r", 2, "sha9", "run_keep"); + // Expire everything for PR 1 only. + await db.execute(sql`UPDATE gate_locks SET expires_at = now() - interval '1 second' WHERE pr = 1`); + await db.execute(sql`UPDATE gate_dedupe SET expires_at = now() - interval '1 second' WHERE pr = 1`); + await db.execute(sql`UPDATE gate_current SET expires_at = now() - interval '1 second' WHERE pr = 1`); + + await store.purgeExpired(); + + const locks = await db.execute(sql`SELECT count(*)::int AS n FROM gate_locks`); + const dedupe = await db.execute(sql`SELECT count(*)::int AS n FROM gate_dedupe`); + const cur = await db.execute(sql`SELECT count(*)::int AS n FROM gate_current`); + expect(locks.rows[0].n).toBe(0); + expect(dedupe.rows[0].n).toBe(1); // run_keep survives + expect(cur.rows[0].n).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd apps/worker && pnpm exec vitest run src/post-pr-gate/gate-store.test.ts +``` + +Expected: FAIL (constructor signature mismatch / missing methods). + +- [ ] **Step 3: Rewrite `apps/worker/src/post-pr-gate/gate-store.ts`** + +```ts +import { randomUUID } from "node:crypto"; +import { and, eq, sql } from "drizzle-orm"; +import type { Db } from "../db/client.js"; +import { gateCurrent, gateDedupe, gateLocks } from "../db/schema.js"; + +/** + * Application-level dedupe, force-push tracking, and per-PR locking for + * post-pr-gate runs — Postgres edition. + * + * Three tables (see src/db/schema.ts): + * gate_locks — short-TTL mutex around the webhook critical section. + * Released in `finally`; if the route process dies, the + * expires_at timestamp lets the next acquirer steal it. + * gate_dedupe — one row per {repo, pr, headSha}; INSERT-on-conflict is + * the SET NX equivalent. Absent/expired row means "never + * claimed for this SHA". + * gate_current — pointer to the latest run, used to cancel the previous + * run on force-push. + * + * TTL semantics: a row past its expires_at is treated as ABSENT by every + * read (correctness); physical deletion happens via purgeExpired() in the + * poll cron (housekeeping). Lifetime: 14 days, matching the Redis EX. + * + * Each former Lua script is now a single SQL statement, so it stays atomic + * over the sessionless neon-http driver — no transactions required. + */ + +const TTL = sql`now() + interval '14 days'`; +const LOCK_TTL = sql`now() + interval '30 seconds'`; + +export interface CurrentGateRun { + runId: string; + headSha: string; + checkRunIds: number[]; +} + +export class GateStore { + constructor(private db: Db) {} + + /** + * Acquire the per-PR lock. Returns a token if acquired, null if busy. + * Caller MUST call `releaseLock` with the same token in a `finally`. + * Single statement: insert wins a free lock; the conflict-update with + * setWhere steals an expired one; otherwise no row returns → busy. + */ + async acquireLock(repo: string, pr: number): Promise { + const token = randomUUID(); + const rows = await this.db + .insert(gateLocks) + .values({ repo, pr, token, expiresAt: LOCK_TTL }) + .onConflictDoUpdate({ + target: [gateLocks.repo, gateLocks.pr], + set: { + token: sql`excluded.token`, + expiresAt: sql`excluded.expires_at`, + }, + setWhere: sql`${gateLocks.expiresAt} < now()`, + }) + .returning({ token: gateLocks.token }); + return rows.length > 0 ? token : null; + } + + /** + * Release the per-PR lock — only if our token still owns it. A no-op if + * the lock expired and another holder took over (token-guarded DELETE, + * the SQL twin of the old compare-and-delete Lua script). + */ + async releaseLock(repo: string, pr: number, token: string): Promise { + await this.db + .delete(gateLocks) + .where( + and( + eq(gateLocks.repo, repo), + eq(gateLocks.pr, pr), + eq(gateLocks.token, token), + ), + ); + } + + /** + * Atomically claim a {repo, pr, headSha} as a unique gate run. + * Returns the existing runId if already claimed, null if we won the race. + * Designed to be called *inside* `acquireLock`, but the conflict guard is + * defense-in-depth in case the lock expired mid-critical-section. + * An expired claim is re-claimable (Redis SET NX EX semantics). + */ + async claimRun( + repo: string, + pr: number, + headSha: string, + runId: string, + ): Promise { + const rows = await this.db + .insert(gateDedupe) + .values({ repo, pr, headSha, runId, expiresAt: TTL }) + .onConflictDoUpdate({ + target: [gateDedupe.repo, gateDedupe.pr, gateDedupe.headSha], + set: { + runId: sql`excluded.run_id`, + expiresAt: sql`excluded.expires_at`, + }, + setWhere: sql`${gateDedupe.expiresAt} < now()`, + }) + .returning({ runId: gateDedupe.runId }); + if (rows.length > 0) return null; // inserted fresh or reclaimed expired + return this.getDedupe(repo, pr, headSha); + } + + async getDedupe( + repo: string, + pr: number, + headSha: string, + ): Promise { + const rows = await this.db + .select({ runId: gateDedupe.runId }) + .from(gateDedupe) + .where( + and( + eq(gateDedupe.repo, repo), + eq(gateDedupe.pr, pr), + eq(gateDedupe.headSha, headSha), + sql`${gateDedupe.expiresAt} > now()`, + ), + ); + return rows[0]?.runId ?? null; + } + + async getCurrent(repo: string, pr: number): Promise { + const rows = await this.db + .select({ + runId: gateCurrent.runId, + headSha: gateCurrent.headSha, + checkRunIds: gateCurrent.checkRunIds, + }) + .from(gateCurrent) + .where( + and( + eq(gateCurrent.repo, repo), + eq(gateCurrent.pr, pr), + sql`${gateCurrent.expiresAt} > now()`, + ), + ); + return rows[0] ?? null; + } + + async setCurrent( + repo: string, + pr: number, + value: CurrentGateRun, + ): Promise { + await this.db + .insert(gateCurrent) + .values({ repo, pr, ...value, expiresAt: TTL }) + .onConflictDoUpdate({ + target: [gateCurrent.repo, gateCurrent.pr], + set: { + runId: value.runId, + headSha: value.headSha, + checkRunIds: value.checkRunIds, + expiresAt: TTL, + }, + }); + } + + /** + * Atomically append check-run IDs to the current pointer, but only if the + * pointer's headSha still matches `expectedHeadSha`. Returns true if the + * append happened, false if the row is missing, expired, or superseded by + * a force-push. Single conditional UPDATE = the old SHA-guarded Lua + * append; not touching expires_at = KEEPTTL. + */ + async appendCheckRunIdsForSha( + repo: string, + pr: number, + expectedHeadSha: string, + ids: number[], + ): Promise { + if (ids.length === 0) return true; + // IDs are validated integers; inlined as an array literal because the + // append expression needs a typed bigint[] on the right-hand side. + if (!ids.every((id) => Number.isSafeInteger(id))) { + throw new Error(`non-integer check-run ids: ${ids.join(",")}`); + } + const literal = sql.raw(`'{${ids.join(",")}}'::bigint[]`); + const rows = await this.db + .update(gateCurrent) + .set({ checkRunIds: sql`${gateCurrent.checkRunIds} || ${literal}` }) + .where( + and( + eq(gateCurrent.repo, repo), + eq(gateCurrent.pr, pr), + eq(gateCurrent.headSha, expectedHeadSha), + sql`${gateCurrent.expiresAt} > now()`, + ), + ) + .returning({ pr: gateCurrent.pr }); + return rows.length > 0; + } + + /** + * Atomically set the `runId` field of the current pointer, but only if + * the pointer's headSha still matches `expectedHeadSha`. Returns true if + * the update happened, false if the row is missing or superseded. + * + * Used by the webhook to fill in the real runId AFTER `start()` returns, + * without stomping `checkRunIds` that the workflow may have already + * appended — a column-targeted UPDATE only touches run_id, so that + * property now holds structurally. + */ + async updateRunIdIfHeadSha( + repo: string, + pr: number, + expectedHeadSha: string, + runId: string, + ): Promise { + const rows = await this.db + .update(gateCurrent) + .set({ runId }) + .where( + and( + eq(gateCurrent.repo, repo), + eq(gateCurrent.pr, pr), + eq(gateCurrent.headSha, expectedHeadSha), + sql`${gateCurrent.expiresAt} > now()`, + ), + ) + .returning({ pr: gateCurrent.pr }); + return rows.length > 0; + } + + async clearCurrent(repo: string, pr: number): Promise { + await this.db + .delete(gateCurrent) + .where(and(eq(gateCurrent.repo, repo), eq(gateCurrent.pr, pr))); + } + + /** + * Physically delete expired rows. Reads already treat them as absent; + * this is housekeeping so tables don't grow forever. Called from the + * poll cron (src/routes/cron/poll.get.ts), best-effort. + */ + async purgeExpired(): Promise { + await this.db.delete(gateLocks).where(sql`${gateLocks.expiresAt} < now()`); + await this.db + .delete(gateDedupe) + .where(sql`${gateDedupe.expiresAt} < now()`); + await this.db + .delete(gateCurrent) + .where(sql`${gateCurrent.expiresAt} < now()`); + } +} +``` + +Note for the implementer: drizzle's `.values()` accepts `sql` expressions for the timestamp columns; if the installed drizzle version's types reject `SQL` for a `timestamp` column in `values()`, type the column value as `sql\`now() + interval '30 seconds'\`` via `.$type()` on the schema column or compute `new Date(Date.now() + ms)` in JS instead — behavior is equivalent (clock source shifts from DB to app; tests don't care). + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd apps/worker && pnpm exec vitest run src/post-pr-gate/gate-store.test.ts +``` + +Expected: PASS. Note: `github.post.ts` / `post-pr-gate.ts` now FAIL typecheck (old constructor args) — fixed next task; don't run `pnpm typecheck` as a gate here. + +- [ ] **Step 5: Commit** + +```bash +git add apps/worker/src/post-pr-gate/gate-store.ts apps/worker/src/post-pr-gate/gate-store.test.ts +git commit -m "feat(gate): rewrite gate store on Postgres, Lua scripts to atomic SQL" +``` + +--- + +### Task 5: Wire-up — env, adapters, webhook, workflow, poll purge + +**Files:** +- Modify: `apps/worker/env.ts:122-124` +- Modify: `apps/worker/env.test.ts:28-29` +- Modify: `apps/worker/src/lib/adapters.ts` +- Modify: `apps/worker/src/lib/step-adapters.ts` +- Modify: `apps/worker/src/routes/webhooks/github.post.ts:56-60` +- Modify: `apps/worker/src/workflows/post-pr-gate.ts:45-49` +- Modify: `apps/worker/src/routes/cron/poll.get.ts` + +- [ ] **Step 1: Swap env vars in `apps/worker/env.ts`** — replace lines 122-124: + +```ts + // Redis (run registry) + AI_WORKFLOW_KV_REST_API_URL: z.string().url(), + AI_WORKFLOW_KV_REST_API_TOKEN: z.string().min(1), +``` + +with: + +```ts + // Neon Postgres (run registry + post-PR gate store) — auto-injected by + // the Neon Vercel Marketplace integration, one branch per environment. + DATABASE_URL: z.string().url(), +``` + +- [ ] **Step 2: Update `apps/worker/env.test.ts:28-29`** — replace: + +```ts + AI_WORKFLOW_KV_REST_API_URL: "https://fake.upstash.io", + AI_WORKFLOW_KV_REST_API_TOKEN: "fake-token", +``` + +with: + +```ts + DATABASE_URL: "postgresql://user:pass@ep-fake.neon.tech/neondb", +``` + +- [ ] **Step 3: Update `apps/worker/src/lib/adapters.ts`** — replace the import of `UpstashRunRegistry` and the instantiation: + +```ts +import { PostgresRunRegistry } from "../adapters/run-registry/postgres.js"; +import { getDb } from "../db/client.js"; +``` + +and in `createAdapters()`: + +```ts + const runRegistry = new PostgresRunRegistry(getDb()); +``` + +(The `url`/`token` args disappear; everything else in the file is unchanged.) + +- [ ] **Step 4: Same change in `apps/worker/src/lib/step-adapters.ts`** — identical import swap and `new PostgresRunRegistry(getDb())` in `createStepAdapters()`. + +- [ ] **Step 5: Update GateStore call sites.** In `apps/worker/src/routes/webhooks/github.post.ts:56-60` and `apps/worker/src/workflows/post-pr-gate.ts:45-49`, replace: + +```ts + const gateStore = new GateStore({ + url: env.AI_WORKFLOW_KV_REST_API_URL, + token: env.AI_WORKFLOW_KV_REST_API_TOKEN, + envPrefix: env.VERCEL_ENV ?? "development", + }); +``` + +with: + +```ts + const gateStore = new GateStore(getDb()); +``` + +adding `import { getDb } from "../db/client.js";` (webhook) / `import { getDb } from "../db/client.js";` with the correct relative path from `src/workflows/` (`"../db/client.js"`). If `env` becomes unused in either file, remove the import only if nothing else uses it (post-pr-gate.ts uses `env` elsewhere — check before removing). + +- [ ] **Step 6: Add the purge to `apps/worker/src/routes/cron/poll.get.ts`.** After the `reconcileRuns(...)` call and before the `return`, add: + +```ts + // Housekeeping: physically drop expired gate rows (reads already treat + // them as absent). Best-effort — a failed purge must not fail the poll. + await new GateStore(getDb()) + .purgeExpired() + .catch((err) => logger.warn({ err: (err as Error).message }, "poll_gate_purge_failed")); +``` + +with imports: + +```ts +import { GateStore } from "../../post-pr-gate/gate-store.js"; +import { getDb } from "../../db/client.js"; +``` + +- [ ] **Step 7: Verify** + +```bash +cd apps/worker && pnpm typecheck && pnpm test +``` + +Expected: typecheck PASS; all unit tests PASS (env.test.ts, postgres.test.ts, gate-store.test.ts, and the pre-existing suites). `upstash.test.ts` still passes — it's removed in Task 8. + +- [ ] **Step 8: Commit** + +```bash +git add apps/worker/env.ts apps/worker/env.test.ts apps/worker/src/lib/adapters.ts apps/worker/src/lib/step-adapters.ts apps/worker/src/routes/webhooks/github.post.ts apps/worker/src/workflows/post-pr-gate.ts apps/worker/src/routes/cron/poll.get.ts +git commit -m "feat(db): wire Postgres registry and gate store into adapters, webhook, workflow, poll" +``` + +--- + +### Task 6: Build-time migration + environment-isolation guard + +**Files:** +- Create: `apps/worker/scripts/db-migrate.ts` +- Modify: `apps/worker/package.json` (build script) + +- [ ] **Step 1: Create `apps/worker/scripts/db-migrate.ts`** + +```ts +/** + * Build-time migration runner + environment-isolation guard. + * + * Runs as part of `pnpm build` on Vercel, where the Neon Marketplace + * integration injects DATABASE_URL per environment (branch-per-env). + * Keeps deployment one-click: every deploy is schema-self-healing. + * + * Guard: the env_marker row pins this database branch to one VERCEL_ENV. + * - Same endpoint host, different env → FAIL the build. Preview and + * production are sharing a branch; the run registries would collide + * (preview claiming production tickets, deleting its Slack threads). + * - Different endpoint host → the branch was copied (Neon + * branches copy data, marker included) — re-claim it for this env. + * + * Locally (no DATABASE_URL) this is a warn-and-skip no-op so `pnpm build` + * still works without a database. + */ +import "dotenv/config"; +import { execSync } from "node:child_process"; +import { neon } from "@neondatabase/serverless"; + +const url = process.env.DATABASE_URL; +if (!url) { + console.warn("[db-migrate] DATABASE_URL not set — skipping migrations."); + process.exit(0); +} + +execSync("pnpm exec drizzle-kit migrate", { stdio: "inherit" }); + +const sql = neon(url); +const vercelEnv = process.env.VERCEL_ENV ?? "development"; +const host = new URL(url).host; + +await sql` + INSERT INTO env_marker (id, env, endpoint_host) + VALUES (1, ${vercelEnv}, ${host}) + ON CONFLICT (id) DO NOTHING +`; +const rows = await sql`SELECT env, endpoint_host FROM env_marker WHERE id = 1`; +const marker = rows[0] as { env: string; endpoint_host: string }; + +if (marker.endpoint_host !== host) { + console.warn( + `[db-migrate] branch copied from '${marker.env}' (${marker.endpoint_host}) — re-claiming for '${vercelEnv}'.`, + ); + await sql`UPDATE env_marker SET env = ${vercelEnv}, endpoint_host = ${host} WHERE id = 1`; +} else if (marker.env !== vercelEnv) { + console.error( + `[db-migrate] FATAL: this Neon branch is already claimed by VERCEL_ENV='${marker.env}', ` + + `but this build is VERCEL_ENV='${vercelEnv}'. Environments must not share a branch — ` + + `enable branch-per-environment in the Neon Vercel integration (see SETUP.md §4).`, + ); + process.exit(1); +} else { + console.log(`[db-migrate] OK — branch claimed by '${vercelEnv}'.`); +} +``` + +- [ ] **Step 2: Wire into the build.** In `apps/worker/package.json`, change: + +```json +"build": "pnpm validate:pre-sandbox && rm -rf .nitro/workflow && NODE_OPTIONS=--max-old-space-size=8192 nitro build", +``` + +to: + +```json +"build": "pnpm validate:pre-sandbox && pnpm db:migrate && rm -rf .nitro/workflow && NODE_OPTIONS=--max-old-space-size=8192 nitro build", +``` + +- [ ] **Step 3: Verify the no-DB path locally** + +```bash +cd apps/worker && env -u DATABASE_URL pnpm db:migrate +``` + +Expected: `[db-migrate] DATABASE_URL not set — skipping migrations.` and exit 0. + +- [ ] **Step 4: Verify against the Neon development branch.** Note: `drizzle-kit`/`tsx` do not auto-load `.env.local`, and the repo's scripts load `.env` via `dotenv/config` — so pull explicitly to `.env`: + +```bash +cd apps/worker && vercel env pull .env +``` + +Then: + +```bash +cd apps/worker && pnpm db:migrate +``` + +Expected: drizzle-kit applies `0000_*.sql`, then `[db-migrate] OK — branch claimed by 'development'.` Re-running is idempotent (no pending migrations, same marker). + +- [ ] **Step 5: Commit** + +```bash +git add apps/worker/scripts/db-migrate.ts apps/worker/package.json +git commit -m "feat(build): run migrations and env-isolation guard during Vercel build" +``` + +--- + +### Task 7: E2E helpers, e2e env, and maintenance script + +**Files:** +- Create: `apps/worker/e2e/helpers/registry.ts` (replaces `e2e/helpers/redis.ts`) +- Delete: `apps/worker/e2e/helpers/redis.ts` +- Modify: 14 importers in `apps/worker/e2e/tier2/*.test.ts` (list below) +- Modify: `apps/worker/e2e/env.ts` +- Rewrite: `apps/worker/scripts/clear-run-registry.ts` + +- [ ] **Step 1: Create `apps/worker/e2e/helpers/registry.ts`** — same exported names/signatures as the old redis helper, raw `neon` client (e2e doesn't need drizzle): + +```ts +import { neon } from "@neondatabase/serverless"; +import { e2eEnv } from "../env.js"; + +/** + * Direct DB access for e2e seeding/cleanup. Must point at the SAME Neon + * branch as the deployment under test (vercel env pull for the matching + * environment). + */ +const sql = neon(e2eEnv.DATABASE_URL); + +export async function getRunId(ticketKey: string): Promise { + const rows = await sql`SELECT run_id FROM active_runs WHERE ticket_key = ${ticketKey}`; + return (rows[0]?.run_id as string | undefined) ?? null; +} + +export async function listAll(): Promise< + Array<{ ticketKey: string; runId: string }> +> { + const rows = await sql`SELECT ticket_key, run_id FROM active_runs`; + return rows.map((r) => ({ + ticketKey: r.ticket_key as string, + runId: r.run_id as string, + })); +} + +export async function setEntry( + ticketKey: string, + runId: string, + opts?: { ageMs?: number }, +): Promise { + // Mirror the production adapter: created_at backs reconcile's orphan + // grace window (src/lib/reconcile.ts:ORPHAN_GRACE_MS). Callers + // exercising the orphan-cancel path (US-15) pass `ageMs` to backdate + // past the grace window so reconcile acts on the first tick. + const ageMs = opts?.ageMs ?? 0; + await sql` + INSERT INTO active_runs (ticket_key, run_id, created_at) + VALUES (${ticketKey}, ${runId}, now() - make_interval(secs => ${ageMs / 1000})) + ON CONFLICT (ticket_key) DO UPDATE + SET run_id = excluded.run_id, created_at = excluded.created_at + `; +} + +export async function cleanup(ticketKey: string): Promise { + await sql`DELETE FROM active_runs WHERE ticket_key = ${ticketKey}`.catch( + () => {}, + ); +} + +export interface FailedTicketMeta { + runId: string; + error: string; + failedAt: string; +} + +export async function markFailed( + ticketKey: string, + meta: FailedTicketMeta, +): Promise { + await sql` + INSERT INTO failed_tickets (ticket_key, run_id, error, failed_at) + VALUES (${ticketKey}, ${meta.runId}, ${meta.error}, ${meta.failedAt}) + ON CONFLICT (ticket_key) DO UPDATE + SET run_id = excluded.run_id, error = excluded.error, failed_at = excluded.failed_at + `; +} + +export async function isTicketFailed(ticketKey: string): Promise { + const rows = await sql`SELECT 1 FROM failed_tickets WHERE ticket_key = ${ticketKey}`; + return rows.length > 0; +} + +export async function cleanupFailed(ticketKey: string): Promise { + await sql`DELETE FROM failed_tickets WHERE ticket_key = ${ticketKey}`.catch( + () => {}, + ); +} +``` + +- [ ] **Step 2: Update the 14 importers.** Files (import line numbers from the audit): `us01-clear-ticket-pr.test.ts:16`, `us03-review-fix-cycle.test.ts:20`, `us04-merge-conflict-rebase.test.ts:19`, `us05-unclear-ticket-clarification.test.ts:10`, `us06-clarification-answered.test.ts:17`, `us07-agent-failure-backlog.test.ts:9`, `us08-previously-failed-skip.test.ts:16`, `us09-failed-marker-cleared.test.ts:14`, `us10-duplicate-dispatch-prevented.test.ts:13`, `us11-capacity-limit-respected.test.ts:13`, `us12-ticket-moved-out-during-dispatch.test.ts:9`, `us13-webhook-immediate-dispatch.test.ts:9`, `us14-stale-claim-cleanup.test.ts:12`, `us15-orphaned-run-cancelled.test.ts:12`. + +```bash +cd apps/worker +grep -rl 'helpers/redis' e2e/tier2 | xargs sed -i '' 's|helpers/redis\.js|helpers/registry.js|g' +git rm e2e/helpers/redis.ts +grep -rn 'helpers/redis' e2e # expected: no output +``` + +- [ ] **Step 3: Update `apps/worker/e2e/env.ts`** — replace: + +```ts + AI_WORKFLOW_KV_REST_API_URL: z.string().url(), + AI_WORKFLOW_KV_REST_API_TOKEN: z.string().min(1), +``` + +with: + +```ts + /** + * Neon Postgres connection for the SAME branch the deployment under test + * uses (registry seeding/cleanup). Pull with `vercel env pull` for the + * matching environment. + */ + DATABASE_URL: z.string().url(), +``` + +(Also delete the now-stale `VERCEL_ENV` doc-comment line "Must match the deployed app's VERCEL_ENV" ONLY if nothing else in e2e uses `e2eEnv.VERCEL_ENV` — `grep -rn "e2eEnv.VERCEL_ENV" e2e/` first; if other helpers use it, leave it.) + +- [ ] **Step 4: Rewrite `apps/worker/scripts/clear-run-registry.ts`** + +```ts +/** + * Clear run-registry entries in Neon Postgres. + * + * pnpm exec tsx scripts/clear-run-registry.ts # show state, no writes + * pnpm exec tsx scripts/clear-run-registry.ts AWT-42 # clear one ticket + * pnpm exec tsx scripts/clear-run-registry.ts --all # clear every ticket + */ +import "dotenv/config"; +import { neon } from "@neondatabase/serverless"; + +const url = process.env.DATABASE_URL; +if (!url) { + console.error("Missing DATABASE_URL"); + process.exit(1); +} +const sql = neon(url); + +const tables = { + active: "active_runs", + failed: "failed_tickets", + threads: "thread_parents", +} as const; + +async function dump() { + for (const [label, table] of Object.entries(tables)) { + const rows = await sql.query(`SELECT * FROM ${table}`); + console.log(`\n[${label}] ${table}`); + if (rows.length === 0) console.log(" (empty)"); + else for (const r of rows) console.log(` ${JSON.stringify(r)}`); + } +} + +async function clearTicket(t: string) { + for (const [label, table] of Object.entries(tables)) { + const rows = await sql.query( + `DELETE FROM ${table} WHERE ticket_key = $1 RETURNING ticket_key`, + [t], + ); + console.log(` delete ${label} ${t} -> ${rows.length}`); + } +} + +async function clearAll() { + for (const [label, table] of Object.entries(tables)) { + const rows = await sql.query(`DELETE FROM ${table} RETURNING ticket_key`); + console.log(` delete all ${label} -> ${rows.length}`); + } +} + +const args = process.argv.slice(2); +(async () => { + if (args.length === 0) { + console.log("dumping current state (no writes)"); + await dump(); + return; + } + if (args[0] === "--all") { + if (args.length !== 2 || args[1] !== "--yes") { + console.error( + "refusing to clear ALL run-registry tables without confirmation.\n" + + " re-run with: pnpm exec tsx scripts/clear-run-registry.ts --all --yes", + ); + process.exit(1); + } + console.log("clearing ALL run-registry tables"); + await clearAll(); + return; + } + if (args.length !== 1) { + console.error(`unexpected extra args: ${args.slice(1).join(" ")}`); + process.exit(1); + } + console.log(`clearing ticket ${args[0]}`); + await clearTicket(args[0]); +})().catch((e) => { + console.error(e); + process.exit(1); +}); +``` + +Note: `thread_parents` is now included in `--all`/single-ticket clears (the old script couldn't touch it). That matches the script's "reset everything for a ticket" intent; the Slack thread simply restarts. + +- [ ] **Step 5: Verify** + +```bash +cd apps/worker && pnpm typecheck +pnpm exec tsx scripts/clear-run-registry.ts # against dev branch .env — dumps (likely empty) tables +``` + +Expected: typecheck PASS; script prints the three tables. + +- [ ] **Step 6: Commit** + +```bash +git add -A apps/worker/e2e apps/worker/scripts/clear-run-registry.ts +git commit -m "feat(e2e): port registry helpers and clear script to Postgres" +``` + +--- + +### Task 8: Remove Upstash — code, dependency, last references + +**Files:** +- Delete: `apps/worker/src/adapters/run-registry/upstash.ts` +- Delete: `apps/worker/src/adapters/run-registry/upstash.test.ts` +- Modify: `apps/worker/package.json` (drop `@upstash/redis`) + +- [ ] **Step 1: Delete the adapter and its tests; drop the dependency** + +```bash +cd apps/worker +git rm src/adapters/run-registry/upstash.ts src/adapters/run-registry/upstash.test.ts +pnpm remove @upstash/redis +``` + +- [ ] **Step 2: Verify zero Redis references remain in code** + +```bash +cd /Users/kacper/Desktop/blazity/ai-workflow +grep -rn -i "upstash\|AI_WORKFLOW_KV" apps --include="*.ts" --include="*.json" | grep -v node_modules +``` + +Expected: no output. (Docs/skills still match — next task.) + +- [ ] **Step 3: Full verification** + +```bash +cd apps/worker && pnpm typecheck && pnpm test +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add -A apps/worker pnpm-lock.yaml +git commit -m "chore: remove @upstash/redis and the Upstash run-registry adapter" +``` + +--- + +### Task 9: Docs and operator skills + +**Files:** +- Modify: `SETUP.md` (§4 lines 201-213, env table line 251, troubleshooting lines 482+488, skills list line 504, toc line 12, prerequisites line 43) +- Modify: `README.md` (lines 111, 136, 144, 225) +- Create: `.claude/skills/init-neon/SKILL.md` +- Delete: `.claude/skills/init-upstash/` +- Modify: `.claude/skills/init-env/SKILL.md` (description line 3, step list line 39, step 6 lines 156-164, summary table line 354, diagram line 380) + +- [ ] **Step 1: Rewrite SETUP.md §4** ("Install the Upstash marketplace integration" → "Install the Neon Postgres marketplace integration"): + +```markdown +## 4. Install the Neon Postgres marketplace integration + +ai-workflow uses Neon Postgres as its run registry and post-PR-gate store +(atomic claim/release for concurrent runs, dedupe, locking). Tables are +created automatically — migrations run during every deploy's build step. + +1. Open https://vercel.com/marketplace/neon and click **Install**. +2. Connect it to the ai-workflow Vercel project. +3. **Critical:** enable a **separate branch per environment** (development / + preview / production) when configuring the integration. Each environment's + `DATABASE_URL` must point at its own Neon branch. The build fails with an + `env_marker` error if two environments share one branch — that guard + protects the production run registry from preview deployments. + +Verify: + +```bash +vercel env ls | grep DATABASE_URL +``` + +You should see `DATABASE_URL` present for each environment, with different +values. +``` + +Also update: toc line 12, prerequisites line 43 (`**Neon Postgres** — installed via Vercel Marketplace in step 4.`), env-table line 251 (`| \`DATABASE_URL\` | Auto-injected by Neon integration |`), troubleshooting line 482 (`DATABASE_URL undefined` → reinstall the Neon integration / check it's connected to this project), line 488 ("flush the registry key in Upstash" → "run `pnpm exec tsx scripts/clear-run-registry.ts `"), skills list line 504 (Upstash → Neon). + +- [ ] **Step 2: Update README.md** — line 111 (Run Registry row → `[Neon Postgres](https://neon.tech) (via Vercel Marketplace integration)`), line 136 ("in Redis" → "in Postgres"), line 144 ("Redis run registry" → "Postgres run registry"), line 225 ("atomic claim pattern via Upstash Redis" → "atomic claim pattern via Postgres (`INSERT … ON CONFLICT DO NOTHING`)"). + +- [ ] **Step 3: Create `.claude/skills/init-neon/SKILL.md`** — model the structure on the deleted `.claude/skills/init-upstash/SKILL.md` (read it before deleting; ~70 lines: frontmatter, when-to-use, state detection, runbook, verification). Required content: + +```markdown +--- +name: init-neon +description: Configure the Neon Postgres database for Blazebot (run registry + post-PR gate store) via the Vercel Marketplace. Verifies DATABASE_URL is injected per environment, that environments do NOT share a branch, and that migrations apply. Use for "set up neon", "set up postgres", "configure database", "fix run registry", "env_marker error". +--- + +# Init Neon Postgres + +## State detection +1. `vercel env ls | grep DATABASE_URL` — if present for all three environments, skip install and go to verification. +2. If missing: walk the user through https://vercel.com/marketplace/neon → + Install → connect to this project → **enable branch-per-environment**. + CLI alternative: `vercel integration add neon`. + +## Verification (all must pass) +1. `vercel env ls` shows `DATABASE_URL` for development, preview, and production. +2. Branch isolation: pull each environment's value and confirm the hosts + differ (`vercel env pull --environment=production .env.prod` etc., compare + the `ep-…` endpoint hosts). Identical hosts across environments = the + build's env_marker guard will fail — fix the integration's branch settings. +3. Migrations: `cd apps/worker && pnpm db:migrate` against the development + branch (`vercel env pull`) — expect "OK — branch claimed by 'development'". + +## Troubleshooting +- Build fails with `env_marker ... already claimed by VERCEL_ENV='production'`: + two environments share one Neon branch. Reconfigure the integration for + branch-per-environment, redeploy. +- `DATABASE_URL undefined` at build: integration not connected to this + project, or env var scoped to the wrong environments. +``` + +- [ ] **Step 4: Delete init-upstash and update init-env** + +```bash +git rm -r .claude/skills/init-upstash +``` + +In `.claude/skills/init-env/SKILL.md`: line 3 description ("Upstash" → "Neon"), line 39 (`6. init-neon → Marketplace install runbook`), lines 156-164 (Step 6 invokes `init-neon`; prompt text "Ready for Step 6: Neon Postgres?" / "Neon installed. Ready for Step 7: cron secret?"), line 354 (summary table row: `Neon DATABASE_URL per environment via Marketplace`), line 380 (diagram: `init-upstash` → `init-neon`). + +- [ ] **Step 5: Verify no stale references** + +```bash +cd /Users/kacper/Desktop/blazity/ai-workflow +grep -rn -i "upstash" README.md SETUP.md .claude/skills docs/superpowers/plans/2026-06-10-redis-to-neon-postgres.md --include="*.md" -l +``` + +Expected: only this plan file (historical context is fine). + +- [ ] **Step 6: Commit** + +```bash +git add SETUP.md README.md .claude/skills +git commit -m "docs: replace Upstash setup with Neon Postgres (SETUP.md, README, init-neon skill)" +``` + +--- + +### Task 10: Final verification + +- [ ] **Step 1: Full suite from the repo root** + +```bash +cd /Users/kacper/Desktop/blazity/ai-workflow +pnpm typecheck && pnpm test +``` + +Expected: PASS across worker (and dashboard, unchanged). + +- [ ] **Step 2: Production-shaped build** (with dev `DATABASE_URL` in `apps/worker/.env` from `vercel env pull`): + +```bash +cd apps/worker && pnpm build +``` + +Expected: `validate:pre-sandbox` passes → `[db-migrate] OK — branch claimed by 'development'` → nitro build completes. + +- [ ] **Step 3: Repo-wide sanity grep** + +```bash +grep -rn -i "upstash\|AI_WORKFLOW_KV" apps .claude/skills SETUP.md README.md --include="*.ts" --include="*.json" --include="*.md" | grep -v node_modules | grep -v "plans/2026-06-10" +``` + +Expected: no output. + +- [ ] **Step 4: Commit any stragglers, then hand off** + +Remaining verification is operator-owned (per design review): deploy to a preview environment when the registry is drained (`/ai-workflow status` or dashboard shows no live runs), run the e2e suite against the Neon dev branch (`pnpm test:e2e` — note e2e `.env` needs `DATABASE_URL` instead of the two `AI_WORKFLOW_KV_*` vars), and live-smoke one ticket plus one gate PR. Cutover note: existing Slack threads restart (thread parents are not migrated) and old PRs may re-run the gate once (dedupe history not migrated) — both accepted in the design review. + +--- + +## Self-review notes + +- **Spec coverage:** every design-review decision maps to a task — schema (T1), drizzle+neon (T1-2), build-time migrate + one-click (T6), no-env-column + branch guard (T1 `env_marker`, T6 guard, T9 init-neon verification), cron purge (T4 `purgeExpired` + T5 poll wiring), TTL-as-correctness (T4 `expires_at > now()` on every read + expired-lock steal test), drain cutover (T10 handoff note), dev/e2e on Neon dev branch (T7), full Upstash removal incl. tooling (T8-9). +- **Deviation flagged:** the design review said "init-env verification + startup guard"; this plan implements the guard at **build time** (db-migrate.ts) instead of runtime. Rationale: on Vercel, env-var changes only take effect via redeploy, so every config change passes through a build — the guard runs at exactly the right moments, fails loudly before traffic, and adds zero hot-path latency. The init-neon skill check covers setup time. +- **Known judgment calls for the implementer:** (1) drizzle `values()` with `sql` timestamps — fallback documented in T4 Step 3 note; (2) `registerSandbox` assumes the active-run row exists (true today: sandboxes register only after claim) — the T3 test suite pins current behavior; (3) `clear-run-registry --all` now also clears thread parents (improvement, noted in T7). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60b5d71..0cb27aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: '@gitbeaker/rest': specifier: ^43.8.0 version: 43.8.0 + '@neondatabase/serverless': + specifier: ^1.1.0 + version: 1.1.0 '@octokit/auth-app': specifier: ^8.2.0 version: 8.2.0 @@ -72,9 +75,6 @@ importers: '@t3-oss/env-core': specifier: ^0.13.10 version: 0.13.10(typescript@5.9.3)(zod@3.25.76) - '@upstash/redis': - specifier: ^1.37.0 - version: 1.37.0 '@vercel/functions': specifier: ^3.5.0 version: 3.5.0(@aws-sdk/credential-provider-web-identity@3.972.13) @@ -87,12 +87,15 @@ importers: chat: specifier: ^4.20.2 version: 4.20.2 + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@electric-sql/pglite@0.5.1)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8) h3: specifier: ^1 version: 1.15.9 nitropack: specifier: ^2 - version: 2.13.1(@upstash/redis@1.37.0)(@vercel/functions@3.5.0(@aws-sdk/credential-provider-web-identity@3.972.13))(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)) + version: 2.13.1(@electric-sql/pglite@0.5.1)(@upstash/redis@1.37.0)(@vercel/functions@3.5.0(@aws-sdk/credential-provider-web-identity@3.972.13))(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.1)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)) pino: specifier: ^10.3.1 version: 10.3.1 @@ -106,12 +109,21 @@ importers: specifier: ^3.25.76 version: 3.25.76 devDependencies: + '@electric-sql/pglite': + specifier: ^0.5.1 + version: 0.5.1 '@workflow/vitest': specifier: latest version: 4.0.6(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))(zod@3.25.76) '@workflow/world-postgres': specifier: latest - version: 4.1.2(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(postgres@3.4.8)(typescript@5.9.3) + version: 4.1.2(@electric-sql/pglite@0.5.1)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(postgres@3.4.8)(typescript@5.9.3) + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + drizzle-kit: + specifier: ^0.31.10 + version: 0.31.10 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -291,159 +303,461 @@ packages: resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} engines: {node: '>=18.0.0'} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@electric-sql/pglite@0.5.1': + resolution: {integrity: sha512-h2Vc+qkQqsEL5kvyN5nBAxn3Vbyvka7QfDW7Io+CdcwU1+X8JbCAN2og+5dI11S3eJuDfroUCxzJaap6k+ezEw==} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.4': resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.4': resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.4': resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.4': resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.4': resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.4': resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.4': resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.4': resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.4': resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.4': resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.4': resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.4': resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.4': resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.4': resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.4': resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.4': resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.4': resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.4': resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.4': resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.4': resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.4': resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.27.4': resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.4': resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.4': resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.4': resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.4': resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} engines: {node: '>=18'} @@ -747,6 +1061,10 @@ packages: resolution: {integrity: sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==} engines: {node: '>= 10'} + '@neondatabase/serverless@1.1.0': + resolution: {integrity: sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q==} + engines: {node: '>=19.0.0'} + '@nestjs/common@11.1.17': resolution: {integrity: sha512-hLODw5Abp8OQgA+mUO4tHou4krKgDtUcM9j5Ihxncst9XeyxYBTt2bwZm4e4EQr5E352S4Fyy6V3iFx9ggxKAg==} peerDependencies: @@ -2505,6 +2823,14 @@ packages: resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + drizzle-orm@0.45.1: resolution: {integrity: sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==} peerDependencies: @@ -2597,6 +2923,98 @@ packages: sqlite3: optional: true + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -2671,6 +3089,16 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.4: resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} @@ -5124,86 +5552,244 @@ snapshots: '@cloudflare/kv-asset-handler@0.4.2': {} + '@drizzle-team/brocli@0.10.2': {} + + '@electric-sql/pglite@0.5.1': {} + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 optional: true + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.0 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.27.4': optional: true + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.27.4': optional: true + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.27.4': optional: true + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.27.4': optional: true + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.27.4': optional: true + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.27.4': optional: true + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.27.4': optional: true + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.27.4': optional: true + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.27.4': optional: true + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.27.4': optional: true + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.27.4': optional: true + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.27.4': optional: true + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.27.4': optional: true + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.27.4': optional: true + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.27.4': optional: true + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.27.4': optional: true + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.27.4': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.27.4': optional: true + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.27.4': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.27.4': optional: true + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.27.4': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.27.4': optional: true + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.27.4': optional: true + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.27.4': optional: true + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.27.4': optional: true + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.27.4': optional: true @@ -5450,6 +6036,8 @@ snapshots: '@napi-rs/nice-win32-x64-msvc': 1.1.1 optional: true + '@neondatabase/serverless@1.1.0': {} + '@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.3.2 @@ -6430,6 +7018,7 @@ snapshots: '@upstash/redis@1.37.0': dependencies: uncrypto: 0.1.3 + optional: true '@vercel/cli-auth@0.0.1': dependencies: @@ -6586,7 +7175,7 @@ snapshots: chalk: 5.6.2 chokidar: 4.0.3 date-fns: 4.1.0 - dotenv: 17.3.1 + dotenv: 17.4.2 easy-table: 1.2.0 enhanced-resolve: 5.19.0 esbuild: 0.27.4 @@ -6783,7 +7372,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 - '@workflow/world-postgres@4.1.2(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(postgres@3.4.8)(typescript@5.9.3)': + '@workflow/world-postgres@4.1.2(@electric-sql/pglite@0.5.1)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(postgres@3.4.8)(typescript@5.9.3)': dependencies: '@vercel/queue': 0.1.7 '@workflow/errors': 4.1.2 @@ -6792,7 +7381,7 @@ snapshots: '@workflow/world-local': 4.1.2(@opentelemetry/api@1.9.0) cbor-x: 1.6.0 dotenv: 17.3.1 - drizzle-orm: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8) + drizzle-orm: 0.45.1(@electric-sql/pglite@0.5.1)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8) graphile-worker: 0.16.6(typescript@5.9.3) pg: 8.20.0 ulid: 3.0.2 @@ -7194,7 +7783,7 @@ snapshots: chokidar: 5.0.0 confbox: 0.2.4 defu: 6.1.4 - dotenv: 17.3.1 + dotenv: 17.4.2 exsolve: 1.0.8 giget: 2.0.0 jiti: 2.6.1 @@ -7416,9 +8005,10 @@ snapshots: date-fns@4.1.0: {} - db0@0.3.4(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)): + db0@0.3.4(@electric-sql/pglite@0.5.1)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.1)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)): optionalDependencies: - drizzle-orm: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8) + '@electric-sql/pglite': 0.5.1 + drizzle-orm: 0.45.2(@electric-sql/pglite@0.5.1)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8) debug@2.6.9: dependencies: @@ -7490,8 +8080,29 @@ snapshots: dotenv@17.3.1: {} - drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8): + dotenv@17.4.2: {} + + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.21.0 + + drizzle-orm@0.45.1(@electric-sql/pglite@0.5.1)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8): optionalDependencies: + '@electric-sql/pglite': 0.5.1 + '@neondatabase/serverless': 1.1.0 + '@opentelemetry/api': 1.9.0 + '@types/pg': 8.18.0 + '@upstash/redis': 1.37.0 + pg: 8.20.0 + postgres: 3.4.8 + + drizzle-orm@0.45.2(@electric-sql/pglite@0.5.1)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8): + optionalDependencies: + '@electric-sql/pglite': 0.5.1 + '@neondatabase/serverless': 1.1.0 '@opentelemetry/api': 1.9.0 '@types/pg': 8.18.0 '@upstash/redis': 1.37.0 @@ -7565,6 +8176,60 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.4: optionalDependencies: '@esbuild/aix-ppc64': 0.27.4 @@ -8689,7 +9354,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - nitropack@2.13.1(@upstash/redis@1.37.0)(@vercel/functions@3.5.0(@aws-sdk/credential-provider-web-identity@3.972.13))(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)): + nitropack@2.13.1(@electric-sql/pglite@0.5.1)(@upstash/redis@1.37.0)(@vercel/functions@3.5.0(@aws-sdk/credential-provider-web-identity@3.972.13))(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.1)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.59.0) @@ -8710,7 +9375,7 @@ snapshots: cookie-es: 2.0.0 croner: 9.1.0 crossws: 0.3.5 - db0: 0.3.4(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)) + db0: 0.3.4(@electric-sql/pglite@0.5.1)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.1)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)) defu: 6.1.4 destr: 2.0.5 dot-prop: 10.1.0 @@ -8756,7 +9421,7 @@ snapshots: unenv: 2.0.0-rc.24 unimport: 5.7.0 unplugin-utils: 0.3.1 - unstorage: 1.17.4(@upstash/redis@1.37.0)(@vercel/functions@3.5.0(@aws-sdk/credential-provider-web-identity@3.972.13))(db0@0.3.4(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)))(ioredis@5.10.1) + unstorage: 1.17.4(@upstash/redis@1.37.0)(@vercel/functions@3.5.0(@aws-sdk/credential-provider-web-identity@3.972.13))(db0@0.3.4(@electric-sql/pglite@0.5.1)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.1)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)))(ioredis@5.10.1) untyped: 2.0.0 unwasm: 0.5.3 youch: 4.1.0 @@ -9804,7 +10469,7 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - unstorage@1.17.4(@upstash/redis@1.37.0)(@vercel/functions@3.5.0(@aws-sdk/credential-provider-web-identity@3.972.13))(db0@0.3.4(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)))(ioredis@5.10.1): + unstorage@1.17.4(@upstash/redis@1.37.0)(@vercel/functions@3.5.0(@aws-sdk/credential-provider-web-identity@3.972.13))(db0@0.3.4(@electric-sql/pglite@0.5.1)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.1)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)))(ioredis@5.10.1): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -9817,7 +10482,7 @@ snapshots: optionalDependencies: '@upstash/redis': 1.37.0 '@vercel/functions': 3.5.0(@aws-sdk/credential-provider-web-identity@3.972.13) - db0: 0.3.4(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)) + db0: 0.3.4(@electric-sql/pglite@0.5.1)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.1)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)) ioredis: 5.10.1 untun@0.1.3: