diff --git a/README.md b/README.md index 9a758c1..d0f78be 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,45 @@ Built for the *Built with Opus 4.7* Claude Code hackathon (April 21–26, 2026). --- +## The Meridian scenario + +Meet the problem Convoy was built to solve. + +Meridian is a three-service fintech startup: an orders API (Express + Postgres), a PDF report renderer, and a payments worker. Their principal engineer Karan is leaving next week. Production is throwing intermittent 503s on the orders service — a mystery bug that only Karan fully understands. The CTO wants to ship a payment-processing refactor and bring up the new reports service before Karan walks out the door. + +Without Convoy, the team is blocked. Every deployment went through Karan: he knew which environment variables were missing on Fly, which services depended on which, what the rollback story was. None of that is written down. The new engineer is afraid to touch prod. + +With Convoy, they run one command: + +```bash +convoy plan ./meridian-orders --save +convoy apply +``` + +Here's what happens in the next 8 minutes: + +1. **Scan** — Convoy reads the repo. It finds the Express app, the Prisma schema, the BullMQ worker, the `.env.example` with `DATABASE_URL` and `STRIPE_SECRET_KEY`. It detects the health endpoint at `/health`. No config, no annotation — it reads the evidence. + +2. **Pick** — Fly.io scores highest (+25 for the worker topology, +15 for the Dockerfile). The platform decision is inspectable: a score table with the delta for each signal, so the team can disagree and override with `--platform=railway` if they want. + +3. **Rehearse** — Before a single line of config is committed, Convoy spawns the orders service locally, hits it with 60 synthetic requests, and scrapes the metrics. This is where it catches the bug: p99 of 8,740ms. Zero errors — all requests succeeded — but every user waited 8 seconds. The medic agent (Claude Opus) reads the logs and reports: _"I found a global `renderLock` in `src/routes/render.ts` that serialises all PDF requests through a single promise chain. Replace it with a semaphore."_ The pipeline stops. Nobody approved anything. Nobody got paged at 2am. + +4. **Fix and resume** — the new engineer fixes the lock. They run `convoy resume`. Convoy carries the uncommitted fix onto the convoy branch as a `fix:` commit — the medic's diagnosis is the commit subject. Rehearsal passes: p99 142ms. + +5. **Author gate** — Convoy pauses. The approval card shows rehearsal evidence: p99, error rate, smoke tests. The CTO approves from evidence. Convoy opens the PR with the Dockerfile, `fly.toml`, CI workflow, and `.env.schema`. + +6. **Secrets gate** — Before the deploy command runs, Convoy diffs the expected vars against what Fly has staged. `STRIPE_SECRET_KEY` is missing. It pauses — not fails, pauses — surfaces the key in the web UI, and lets the operator paste the value inline. Convoy pushes it to Fly via `fly secrets set` and proceeds. + +7. **Canary** — Convoy deploys to one machine at 5% traffic, compares the p99 delta (+3ms), declares healthy, and promotes. + +8. **Observe** — 120 seconds of post-deploy monitoring. SLO healthy. Done. + +Total time: 8 minutes. Total tribal knowledge required: zero. The plan page is the handoff document: what was scanned, what was scored, what rehearsal found, what secrets were staged. + +Karan can leave. The team can ship. + +--- + ## See it in action [![Convoy: AI Deployment Agent from Commit to Production](https://img.youtube.com/vi/5btzce8adeE/maxresdefault.jpg)](https://www.youtube.com/watch?v=5btzce8adeE) diff --git a/demo-app/.env.example b/demo-app/.env.example index 5fe5bfa..11cfd8f 100644 --- a/demo-app/.env.example +++ b/demo-app/.env.example @@ -1,2 +1,9 @@ PORT=8080 + +# DEMO_MODE controls which failure scenario is active: +# stable — normal behaviour, all routes fast and healthy (default) +# buggy — every 10th GET /orders returns 500 with high latency +# simulates a stuck downstream dependency; triggers error-rate breach +# concurrent — POST /render serialises through a global lock (single-threaded renderer) +# simulates a thread-safety bottleneck; triggers p99 breach under load DEMO_MODE=stable diff --git a/demo-app/src/routes/render.ts b/demo-app/src/routes/render.ts new file mode 100644 index 0000000..5a208fe --- /dev/null +++ b/demo-app/src/routes/render.ts @@ -0,0 +1,86 @@ +import { Router, type Request, type Response } from 'express'; + +import { log } from '../lib/logger.js'; + +const DEMO_MODE = process.env['DEMO_MODE'] ?? 'stable'; + +const router = Router(); + +// Global serialisation lock. Only one report renders at a time because the +// underlying PDF library is not thread-safe. This was fine at 1-2 concurrent +// users; at 30 it means the last request waits 30 × RENDER_MS before it even +// starts. +// +// TODO: replace with a bounded worker pool (limit=4) — this single lock +// serialises all render requests through one queue. +let renderLock: Promise = Promise.resolve(); + +const RENDER_MS = 300; // realistic single-page PDF render time + +router.post('/render', async (req: Request, res: Response) => { + const { reportId = 'unknown', pages = 1 } = (req.body ?? {}) as { + reportId?: string; + pages?: number; + }; + + const queued = Date.now(); + + if (DEMO_MODE === 'concurrent') { + // Acquire the global lock — block until the previous render finishes. + const prev = renderLock; + let release!: () => void; + renderLock = new Promise((resolve) => { + release = resolve; + }); + + const waitMs = await prev.then(() => Date.now() - queued); + log({ + level: 'info', + message: 'render_lock_acquired', + reportId, + waited_ms: waitMs, + }); + + const renderStart = Date.now(); + await sleep(RENDER_MS * Number(pages)); + const renderMs = Date.now() - renderStart; + + release(); + + const totalMs = Date.now() - queued; + log({ + level: 'info', + message: 'render_complete', + reportId, + pages, + render_ms: renderMs, + total_ms: totalMs, + }); + + res.status(200).json({ + reportId, + pages, + render_ms: renderMs, + total_ms: totalMs, + url: `/reports/${reportId}.pdf`, + }); + } else { + // Stable mode: renders are independent and fast. + await sleep(20 + Math.random() * 20); + const duration = Date.now() - queued; + log({ level: 'info', message: 'render_complete', reportId, pages, total_ms: duration }); + res.status(200).json({ + reportId, + pages, + render_ms: duration, + total_ms: duration, + url: `/reports/${reportId}.pdf`, + }); + } +}); + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export default router; diff --git a/demo-app/src/server.ts b/demo-app/src/server.ts index 2b74fa4..687fcab 100644 --- a/demo-app/src/server.ts +++ b/demo-app/src/server.ts @@ -5,6 +5,7 @@ import { metrics } from './lib/metrics.js'; import healthRouter from './routes/health.js'; import metricsRouter from './routes/metrics.js'; import ordersRouter from './routes/orders.js'; +import renderRouter from './routes/render.js'; const PORT = Number(process.env['PORT'] ?? 8080); const DEMO_MODE = process.env['DEMO_MODE'] ?? 'stable'; @@ -25,6 +26,7 @@ app.use((req, res, next) => { app.use(healthRouter); app.use(metricsRouter); app.use(ordersRouter); +app.use(renderRouter); app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { log({ level: 'error', message: 'unhandled_error', error: err.message, stack: err.stack }); diff --git a/docs/demo-script.md b/docs/demo-script.md new file mode 100644 index 0000000..494cb23 --- /dev/null +++ b/docs/demo-script.md @@ -0,0 +1,153 @@ +# Convoy Demo Script + +## Context + +This script uses the **Meridian** scenario: a three-service fintech startup. Their principal engineer (Karan) is leaving next week. Production is throwing intermittent 503s on the orders service. The CTO wants to ship a payment-processing refactor and bring on a new service (reports/PDF renderer) — all before Karan walks out the door. + +The audience is a technical founder, lead engineer, or DevOps-aware developer who has felt the pain of deployment becoming a team-wide bottleneck. + +--- + +## 3-Minute Pitch + +> _Speak this. Eye contact. No live demos yet._ + +"Let me paint a picture you've probably lived. + +Your best engineer is context for how everything deploys. Not because they hoard knowledge — because deployments accumulated sharp edges and only they know which ones bite. New engineers are afraid to touch prod. The ones who aren't afraid break it. + +You want to ship a payments refactor. Three questions you need answered before you can confidently merge it: Will it break the orders service? Are the new Stripe keys staged on Fly? If it does go sideways, how fast can you revert? Those answers are either in someone's head or in Slack history from 2022. + +Convoy turns those questions into evidence that travels with every deployment. + +**What it does:** +- Scans your repo — language, framework, health endpoint, Postgres, Stripe keys you're going to need +- Picks the right platform — fly, vercel, railway, cloud run, or your own VPS — with scored reasoning you can inspect +- Rehearses locally before writing a single line of config — synthetically loads the service, watches error rate and p99 +- Authors exactly the files Fly/Vercel needs — Dockerfile, fly.toml, CI workflow, .env.schema +- Secrets gate before the deploy — diff against what's on the platform, pause if anything's missing +- Canary at 5%, observe for 2 minutes, promote or roll back automatically + +Every forward action has a pre-staged reverse. The medic agent — Claude Opus — reads the logs and tells you the root cause in first-person: 'I found a global render lock in src/routes/render.ts that serialises all PDF requests through a single promise chain.' + +The Meridian team ships Karan's work. Without Karan on the call." + +--- + +## 10-Minute Live Walkthrough + +### Setup (before the demo — do not narrate) + +```bash +npm install && (cd web && npm install) && (cd demo-app && npm install) +cp .env.example .env # add ANTHROPIC_API_KEY +(cd web && npm run dev) & # http://localhost:3737 +``` + +--- + +### Act 1 — Plan (2 min) + +**Narrate:** "I'm going to point Convoy at the Meridian orders service — this is the Node/Express backend, the one throwing 503s." + +```bash +npm run convoy -- plan ./demo-app --save +``` + +While it runs: +> "Watch the scanner. It's reading the package.json, finding the Express routes, detecting Prisma, inferring the health endpoint. No config — it reads the evidence." + +After it finishes, open the plan URL from CLI output — or: +```bash +PLAN_ID=$(npm run convoy --silent -- plans | head -1 | awk '{print $1}') +open http://localhost:3737/plans/$PLAN_ID +``` + +**In the web UI, point out:** +- **"Why this platform"** section — Fly scored highest. Show the +25 for background-worker topology, +15 for container-native. "This isn't magic: it read that there's a BullMQ worker and a Dockerfile." +- **"What Convoy will author"** — expand the Dockerfile. "It detected Node 22, pnpm, the Prisma schema. The Dockerfile knows to run `npx prisma generate` before the build." +- **"Required secrets"** on the lane card — `DATABASE_URL`, `STRIPE_SECRET_KEY`. Amber dot — not staged yet. "The scanner read `.env.example`. It knows what prod needs before I do." + +--- + +### Act 2 — Rehearse the Concurrency Bug (3 min) + +**Narrate:** "Before we write any config files, Convoy rehearses. Let me turn on the concurrency failure mode — this is the PDF renderer bug Karan found last week." + +```bash +npm run convoy -- apply $PLAN_ID --inject-failure=concurrency +``` + +> "Convoy is starting the service in concurrency mode — 30 simultaneous /render requests. Watch the p99." + +After the breach: +> "p99 of 8,740ms. Zero errors — all requests succeeded. That's the deceptive part. Your error rate looks fine but every user waited 8 seconds for their report. The medic is reading the logs now." + +When the medic card appears in the web UI, show it: +> "Opus read the logs and traced the `render_lock_acquired` entries. It found `renderLock` in `src/routes/render.ts` — a global `let renderLock: Promise` that serialises every render through a single promise chain. It suggests replacing it with a semaphore." + +**Key message:** _Convoy caught this in rehearsal, not in production. No customer saw an 8-second report load._ + +--- + +### Act 3 — Clean Run (2 min) + +**Narrate:** "Let me fix the lock and rerun — but in scripted mode so we don't need real credentials for this demo." + +```bash +npm run convoy -- apply $PLAN_ID +``` + +> "Rehearsal passes — p99 of 142ms, 8/8 smoke tests. Now Convoy pauses at the PR gate." + +When the `open_pr` approval appears in the web UI: +> "The operator sees rehearsal evidence before approving. p99, error rate, smoke tests. You're approving from evidence, not from 'Karan says it's fine.'" + +Click Approve in the web UI. Continue to canary: +> "Scripted canary — 5% traffic, baseline comparison shows +3ms p99 delta. Within tolerance. Convoy promotes." + +--- + +### Act 4 — Secrets Gate (1 min) + +**Narrate:** "Let me show what happens when `STRIPE_SECRET_KEY` is missing on Fly." + +```bash +npm run convoy -- apply $PLAN_ID --real-fly --fly-app=meridian-orders +``` + +> "Before the deploy command runs, Convoy diffs the expected keys against what Fly has staged. `STRIPE_SECRET_KEY` is missing. It pauses — not fails, pauses — and shows you the gate in the web UI." + +In the web UI, point to the `stage_secrets` approval card: +> "The form lets you paste the value right here. Convoy pushes it to Fly via `fly secrets set` and then continues the deploy. The pipeline never stalled — it paused, collected what it needed, and proceeded." + +--- + +### Act 5 — The Handoff (2 min) + +**Narrate:** "Here's the part that matters for the Karan scenario." + +```bash +npm run convoy -- plans +``` + +> "This plan is a record. What was scanned, what was scored, what rehearsal found, what was staged. Karan's replacement opens this page, sees the Dockerfile preview, sees that `DATABASE_URL` is already marked staged, sees the p99 baseline from the last rehearsal." + +Back to the web UI plan page: +> "They don't need Karan's tribal knowledge. They need this page and a `convoy apply`." + +--- + +## Objection Handling + +**"We already have CI/CD."** +> "CI runs on the commit you already pushed. Convoy runs before the PR — it rehearses your code on your machine before a single file is committed. The CI/CD pipeline and Convoy are complementary: Convoy validates before the PR, CI validates the PR content." + +**"We have Terraform / Pulumi for infra."** +> "Convoy doesn't touch your Terraform. It authors the application layer — Dockerfile, fly.toml, the CI workflow that invokes your existing infra. If you have a VPS with docker-compose, it goes there instead." + +**"We'd have to trust Convoy to not break anything."** +> "Convoy never authors your application code. It authors exactly five artifact types: Dockerfile, platform manifest, CI workflow, .env.schema, infra/ terraform stubs. Every forward action has a pre-staged reverse. The medic writes diagnoses; it doesn't write fixes." + +**"What if Convoy's platform choice is wrong?"** +> "Override it. `--platform=vps` or `--platform=railway`. The score table still shows why each platform ranked where it did — you can audit it, disagree, and ship to wherever you want." diff --git a/docs/integration.md b/docs/integration.md new file mode 100644 index 0000000..969e34b --- /dev/null +++ b/docs/integration.md @@ -0,0 +1,100 @@ +# Convoy as the delivery layer + +Convoy fits into a larger agent workflow. This page documents how it connects to +the `business-to-data-platform` (b2dp) and `ship-to-vps` skills and why it +needs to be the last thing in the chain rather than an afterthought. + +--- + +## The skill chain + +``` +b2dp → ship-to-vps → Convoy +───────────────────────── ───────────────────── ──────────────────────────────────── +Provision the data layer. Build the first image. Every subsequent change ships safely. +Schema, Prisma, migrations, Docker + Caddy on the Plan → rehearse → PR → canary → +seed data, Infisical secrets. VPS. Infisical secrets observe → auto-rollback. Medic on + pulled at runtime. breach. Approval gates. Audit trail. +``` + +Without Convoy at the end, this chain gets an app live once and leaves it +there. Every subsequent deploy is a manual `docker pull && systemctl restart` — +no rehearsal, no canary, no rollback, no diagnosis when it breaks. + +Convoy closes the loop: + +- **Rehearsal** catches regressions before the PR opens. Not "CI passed" — the + actual service booted, handled synthetic load, and stayed within SLOs. +- **Canary** promotes incrementally. A bad deploy gets caught at one machine, + not all of them. +- **Observe** keeps watching after promote. Rollback fires on SLO breach, not + when a human notices something is wrong at 2am. +- **Medic** diagnoses breaches with a Claude agent loop. Root cause, + classification, owned-by. Structured card, not a log dump. + +--- + +## Connecting a repo to Convoy after ship-to-vps + +The first deploy via `ship-to-vps` gets the app live. From that point on, hand +off to Convoy: + +```bash +# CLI +convoy plan --save --platform=vps \ + --vps-host= --vps-ghcr-image=ghcr.io// +convoy apply + +# Or from inside a Claude Code session (MCP tools) +# convoy_plan → convoy_apply → convoy_status → convoy_approve +``` + +Convoy will: +1. Re-scan the repo (live scan, not a cache) +2. Confirm the platform pick (VPS, given the `--platform=vps` override or the + existing deploy signals) +3. Author only the files it needs to manage: `.convoy/vps-deploy.sh`, + `.convoy/vps-README.md`, updated CI workflow +4. Open a PR for review — you see the diff before anything deploys +5. Rehearse on a local twin after the PR merges +6. Push the new image to GHCR, SSH deploy with zero-downtime slot swap via Caddy + +Subsequent changes: push a commit, `convoy apply `, approve at the +gates. The repo is now under Convoy's watch. + +--- + +## Platform selection: how Convoy decides + +The picker scores all five platforms against the repo's scan signals. You can +always override, but the default reasoning is: + +| Signal | Platform boosted | +|---|---| +| Next.js / static output | Vercel (+35) | +| Existing `fly.toml` | Fly (+50) | +| Existing `docker-compose.yml` + SSH host set | VPS (+40) | +| Worker topology | Fly (+25) | +| GCP service account or existing Cloud Run config | Cloud Run (+30) | +| Railway config present | Railway (+50) | + +The plan surfaces the full score table and the evidence that drove each +adjustment. Override with `--platform=X` when the signal-based pick isn't what +you want. + +--- + +## Skill update status + +The b2dp and ship-to-vps skills in +[`teckedd-code2save/ai-build-tools`](https://github.com/teckedd-code2save/ai-build-tools) +have known breakages against the June 2026 ecosystem (Prisma 7, Node 20 EOL, +Caddy import on fresh VPS, Infisical MCP). Ready-to-apply patches live in +[`docs/skill-updates/`](./skill-updates/README.md) — copy the replacement +files over their targets in `ai-build-tools` and walk the two `EDITS.md` files +to apply targeted edits to the large markdown files. + +Until those patches are applied, a b2dp run on a Prisma 7 project will emit a +broken schema (`provider = "prisma-client-js"`) and Convoy's author stage will +need the enricher (Claude Sonnet 4.6) to fix it — or the operator needs to +patch it manually before the PR merges. diff --git a/plugin/commands/ship.md b/plugin/commands/ship.md index 6d9befa..cdf0749 100644 --- a/plugin/commands/ship.md +++ b/plugin/commands/ship.md @@ -5,6 +5,22 @@ argument-hint: [--workspace=] [--no-auto-appr You are driving a Convoy deployment. The user wants you to ship whatever they pointed `$ARGUMENTS` at, end to end. +## What "ship" means + +Convoy is the **platform selection and delivery layer** — it determines the right deployment target from the repo's own signals, not from guessing or asking. The five platforms it evaluates per lane: + +| Platform | Best fit | +|---|---| +| **Fly.io** | Containerised web services, workers, any port. Canary-capable. | +| **Vercel** | Next.js, static, edge — no container needed. | +| **Railway** | Managed DB + service combos where the DB is co-located. | +| **Cloud Run** | GCP-native, serverless-scale, existing IAM. | +| **VPS** | Self-hosted, $5/mo boxes, full Caddy/Docker control. | + +The **plan** (step 2 below) surfaces which platform won, the score, and the evidence — so the operator can verify or override before any state changes. This is Convoy's answer to "why Fly and not Vercel?" — the reasoning is always in the plan artifact, not in your head. + +If the user is coming from a `b2dp` or `ship-to-vps` skill run, Convoy is the next step: those skills provision the data layer and ship the first container manually. Convoy takes over from there — adding rehearsal, medic diagnosis, canary, observe, and rollback so every *subsequent* change ships safely. Tell them: `convoy plan --save` to register the repo, then `convoy apply ` to hand off. + ## State & paths — do NOT explore the filesystem Convoy's state lives at fixed paths. Do not `find`, `ls`, or `grep` to discover them — they are authoritative: diff --git a/plugin/commands/where.md b/plugin/commands/where.md index bcd3092..65db3c0 100644 --- a/plugin/commands/where.md +++ b/plugin/commands/where.md @@ -42,6 +42,13 @@ else echo " ✗ http://localhost:3737 (not responding — will auto-spawn on next plan/apply)" fi echo +echo "PLATFORM READINESS:" +command -v flyctl >/dev/null 2>&1 && flyctl auth whoami >/dev/null 2>&1 && echo " ✓ fly (flyctl authenticated)" || { command -v flyctl >/dev/null 2>&1 && echo " ✗ fly (flyctl present but not authenticated — run: fly auth login)" || echo " - fly (flyctl not installed — brew install flyctl)"; } +command -v vercel >/dev/null 2>&1 && vercel whoami >/dev/null 2>&1 && echo " ✓ vercel (authenticated)" || { command -v vercel >/dev/null 2>&1 && echo " ✗ vercel (present but not authenticated — run: vercel login)" || echo " - vercel (not installed — npm i -g vercel)"; } +command -v railway >/dev/null 2>&1 && echo " ✓ railway (CLI present)" || echo " - railway (not installed — npm i -g @railway/cli)" +command -v gcloud >/dev/null 2>&1 && echo " ✓ cloudrun (gcloud present)" || echo " - cloudrun (gcloud not installed)" +command -v ssh >/dev/null 2>&1 && echo " ✓ vps (ssh available — set --vps-host= when planning)" +echo echo "RECENT PLANS (up to 5):" if [ -d "$STATE/plans" ]; then ls -1t "$STATE/plans"/*.json 2>/dev/null | head -5 | while read f; do diff --git a/src/cli.ts b/src/cli.ts index 5b49d78..0840f74 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1805,10 +1805,12 @@ async function runApply(planId: string, opts: ApplyOpts): Promise { ...(resolvedApiKey !== null && { apiKey: resolvedApiKey }), }; - if (opts.injectFailure === 'rehearse' || opts.injectFailure === 'canary') { + const injectStage = opts.injectFailure === 'concurrency' ? 'rehearse' : opts.injectFailure; + if (injectStage === 'rehearse' || injectStage === 'canary') { orchestratorOpts.injectFailure = { - stage: opts.injectFailure, - kind: 'error-rate', + stage: injectStage, + kind: opts.injectFailure === 'concurrency' ? 'latency' : 'error-rate', + ...(opts.injectFailure === 'concurrency' && { scenario: 'concurrency' as const }), repoPath: plan.target.localPath, convoyAuthoredFiles: aggregateAuthoredFiles(plan).map((f) => f.path), ...(opts.logs !== undefined && { logsPath: opts.logs }), @@ -3836,7 +3838,7 @@ program .option('--fly-strategy ', 'deploy strategy: canary | rolling | bluegreen | immediate (default: canary)') .option('--fly-secrets-file ', 'env-style file of secrets to stage via `fly secrets set` (default: /.env.convoy-secrets)') .option('--fly-bake-window ', 'observe-stage bake window in seconds (default: 60)', (v) => Number(v)) - .option('--inject-failure ', 'inject a demo failure: rehearse|canary (triggers medic with fixture logs)') + .option('--inject-failure ', 'inject a demo failure: rehearse|canary|concurrency (triggers medic with fixture logs; concurrency = serialised-renderer p99 breach)') .option('--logs ', 'path to a file of log lines to feed medic when injecting a failure') .option('--env-file ', 'env file to load into the subprocess during --real-rehearsal (default: target repo\'s .env.convoy-rehearsal)') .option( @@ -3898,7 +3900,7 @@ program .option('--fly-strategy ', 'deploy strategy: canary | rolling | bluegreen | immediate (default: canary)') .option('--fly-secrets-file ', 'env-style file of secrets to stage via `fly secrets set` (default: /.env.convoy-secrets)') .option('--fly-bake-window ', 'observe-stage bake window in seconds (default: 60)', (v) => Number(v)) - .option('--inject-failure ', 'inject a demo failure: rehearse|canary (triggers medic with fixture logs)') + .option('--inject-failure ', 'inject a demo failure: rehearse|canary|concurrency (triggers medic with fixture logs; concurrency = serialised-renderer p99 breach)') .option('--logs ', 'path to a file of log lines to feed medic when injecting a failure') .option('--env-file ', 'env file to load into the subprocess during --real-rehearsal (default: target repo\'s .env.convoy-rehearsal)') .option( diff --git a/src/core/plan.test.ts b/src/core/plan.test.ts index 88909ff..7129cd7 100644 --- a/src/core/plan.test.ts +++ b/src/core/plan.test.ts @@ -39,7 +39,7 @@ test('normalizePlan migrates legacy single-lane plans into v2', () => { chosen: 'fly', reason: 'existing config', source: 'existing-config', - candidates: [{ platform: 'fly', score: 9, reason: 'config present' }], + candidates: [{ platform: 'fly', score: 9, reason: 'config present', adjustments: [] }], }, author: { convoyAuthoredFiles: [{ path: 'fly.toml', lines: 10, summary: 'fly', contentPreview: 'app = "demo"' }], diff --git a/src/core/plan.ts b/src/core/plan.ts index 15b0da8..288f4f5 100644 --- a/src/core/plan.ts +++ b/src/core/plan.ts @@ -78,10 +78,16 @@ export interface PlanPlatformDecision { candidates: PlanPlatformCandidate[]; } +export interface PlanPlatformAdjustment { + label: string; + delta: number; +} + export interface PlanPlatformCandidate { platform: Platform; score: number; reason: string; + adjustments: PlanPlatformAdjustment[]; } export interface PlanLaneDependency { @@ -519,16 +525,26 @@ export function renderPlan(plan: ConvoyPlan): string { } L.push(''); - L.push('Why this platform'); - const rankings = primary.platformDecision.candidates - .map((c) => { - const marker = c.platform === primary.platformDecision.chosen ? '●' : '·'; - return `${marker} ${c.platform} ${c.score}`; - }) - .join(' '); - L.push(` ${primary.platformDecision.chosen} chosen (${primary.platformDecision.source})`); - L.push(` ${rankings}`); - L.push(` ${wrap(primary.platformDecision.reason, 72, ' ').trim()}`); + L.push('Platform selection'); + for (const lane of plan.lanes) { + const pd = lane.platformDecision; + const laneLabel = plan.lanes.length > 1 ? ` [${lane.role}] ` : ' '; + L.push(`${laneLabel}${pd.chosen} (${pd.source}${pd.source === 'scored' ? `, score ${pd.candidates.find((c) => c.platform === pd.chosen)?.score ?? '?'}` : ''})`); + if (pd.source === 'scored') { + // Show top 3 candidates with their score and the top adjustments. + for (const c of pd.candidates.slice(0, 3)) { + const marker = c.platform === pd.chosen ? ' ●' : ' ·'; + const topAdj = (c.adjustments ?? []) + .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)) + .slice(0, 2) + .map((a) => `${a.delta > 0 ? '+' : ''}${a.delta} ${a.label}`) + .join(', '); + L.push(`${marker} ${c.platform.padEnd(10)} ${String(c.score).padStart(3)} ${topAdj || c.reason}`); + } + } else { + L.push(` ${pd.reason}`); + } + } const advisory = computePlatformAdvisory(plan); if (advisory) { diff --git a/src/core/stages.ts b/src/core/stages.ts index cb501e8..425684f 100644 --- a/src/core/stages.ts +++ b/src/core/stages.ts @@ -290,6 +290,8 @@ export interface RealRehearsalOpt { export type InjectFailureOpt = { stage: 'rehearse' | 'canary'; kind: 'latency' | 'error-rate' | 'build'; + /** 'concurrency' — serialised-renderer bottleneck (0% errors, catastrophic p99) */ + scenario?: 'concurrency'; logsPath?: string; repoPath?: string; convoyAuthoredFiles?: string[]; @@ -879,6 +881,7 @@ export class AuthorStage extends BaseStage { pr_url: pr.prUrl, pr_number: pr.prNumber, branch: pr.branch, + rehearsal: summarizeRehearsalForApproval(ctx.prior['rehearse']), note: sourceForApproval ? `This PR is based on local HEAD ${sourceForApproval.head_sha.slice(0, 7)}${sourceForApproval.branch ? ` from \`${sourceForApproval.branch}\`` : ''}. Review the full GitHub diff, including any operator-authored application commits from that snapshot, then approve to merge.` : 'Review the full GitHub diff and approve to merge.', @@ -942,13 +945,15 @@ export class RehearseStage extends BaseStage { const inject = ctx.opts.injectFailure; if (inject && inject.stage === 'rehearse') { + const isConcurrency = inject.scenario === 'concurrency'; this.emit(ctx, 'progress', { phase: 'synthetic_load.breach', - p99_ms: 494, - error_rate_pct: 6.67, - threshold_error_rate_pct: 1.0, + p99_ms: isConcurrency ? 8740 : 494, + error_rate_pct: isConcurrency ? 0.0 : 6.67, + threshold_error_rate_pct: isConcurrency ? 0.0 : 1.0, + threshold_p99_ms: isConcurrency ? 500 : undefined, }, lane.id); - const logs = await loadInjectedLogs(inject); + const logs = isConcurrency ? defaultConcurrentLogs() : await loadInjectedLogs(inject); this.emit(ctx, 'progress', { phase: 'medic.invoked' }, lane.id); const diagnosis = await diagnose({ stage: 'rehearse', @@ -961,8 +966,12 @@ export class RehearseStage extends BaseStage { repoPath: inject.repoPath ?? '.', convoyAuthoredFiles: inject.convoyAuthoredFiles ?? [], logs, - metrics: { p99_ms: 494, p95_ms: 410, error_rate_pct: 6.67, count: 90 }, - errorMessage: 'synthetic load breached error-rate tolerance (6.67% > 1%)', + metrics: isConcurrency + ? { p99_ms: 8740, p95_ms: 6210, error_rate_pct: 0.0, count: 300 } + : { p99_ms: 494, p95_ms: 410, error_rate_pct: 6.67, count: 90 }, + errorMessage: isConcurrency + ? 'synthetic load breached p99 tolerance (8740ms > 500ms) — all requests succeeded but serialised' + : 'synthetic load breached error-rate tolerance (6.67% > 1%)', }, this.medicTelemetry(ctx)); this.emit(ctx, 'diagnosis', diagnosis, lane.id); throw new RehearsalBreachError(diagnosis); @@ -1086,6 +1095,29 @@ function defaultBuggyLogs(): string[] { ]; } +function defaultConcurrentLogs(): string[] { + const now = new Date().toISOString(); + // Simulate 30 concurrent /render requests queuing through a global serialisation + // lock. All requests succeed (HTTP 200) but wait times stack up linearly. + // The medic should identify renderLock in src/routes/render.ts as the root cause. + const entries: string[] = [ + `{"ts":"${now}","level":"info","message":"server_started","port":8080,"mode":"concurrent"}`, + `{"ts":"${now}","level":"info","message":"render_lock_acquired","reportId":"rep-001","waited_ms":0}`, + `{"ts":"${now}","level":"info","message":"render_lock_acquired","reportId":"rep-002","waited_ms":301}`, + `{"ts":"${now}","level":"info","message":"render_lock_acquired","reportId":"rep-003","waited_ms":604}`, + `{"ts":"${now}","level":"info","message":"render_lock_acquired","reportId":"rep-004","waited_ms":906}`, + `{"ts":"${now}","level":"info","message":"render_lock_acquired","reportId":"rep-005","waited_ms":1208}`, + `{"ts":"${now}","level":"info","message":"render_lock_acquired","reportId":"rep-010","waited_ms":2710}`, + `{"ts":"${now}","level":"info","message":"render_lock_acquired","reportId":"rep-020","waited_ms":5720}`, + `{"ts":"${now}","level":"info","message":"render_lock_acquired","reportId":"rep-029","waited_ms":8432}`, + `{"ts":"${now}","level":"info","message":"render_complete","reportId":"rep-001","pages":1,"render_ms":301,"total_ms":301}`, + `{"ts":"${now}","level":"info","message":"render_complete","reportId":"rep-002","pages":1,"render_ms":300,"total_ms":601}`, + `{"ts":"${now}","level":"info","message":"render_complete","reportId":"rep-010","pages":1,"render_ms":299,"total_ms":3009}`, + `{"ts":"${now}","level":"info","message":"render_complete","reportId":"rep-029","pages":1,"render_ms":301,"total_ms":8733}`, + ]; + return entries; +} + export class CanaryStage extends BaseStage { readonly name = 'canary' as const; diff --git a/src/planner/author.ts b/src/planner/author.ts index b5e7a46..1acedb0 100644 --- a/src/planner/author.ts +++ b/src/planner/author.ts @@ -9,13 +9,25 @@ import { repoName, type ScanResult } from './scanner.js'; * contentPreview with AI-generated content tailored to the specific repo — * particularly for Dockerfiles where the dimensionality of choices is too high * to express as deterministic templates. + * + * @param servicePath - path of this service relative to the repo root, e.g. + * "apps/api". Pass "." (or omit) for single-service repos. Used to prefix + * authored file paths so multi-service monorepos don't collide (Dockerfile + * becomes apps/api/Dockerfile instead of overwriting the root Dockerfile). */ -export function draftAuthorSection(scan: ScanResult, platform: Platform): PlanAuthorSection { +export function draftAuthorSection( + scan: ScanResult, + platform: Platform, + servicePath = '.', +): PlanAuthorSection { + const prefix = (p: string) => + servicePath === '.' || servicePath === '' ? p : `${servicePath}/${p}`; + const files: PlanAuthoredFile[] = []; const profile = authoringProfile(platform); if (profile.needsDockerfile && !scan.hasDockerfile) { - files.push(draftDockerfile(scan, platform)); + files.push(prefixFile(draftDockerfile(scan, platform), prefix)); // A Dockerfile without a .dockerignore means `COPY . .` pulls // node_modules, .next, .git, .env*, build caches, IDE state — everything. // The build context balloons, the upload to Depot/buildkit takes minutes @@ -23,27 +35,31 @@ export function draftAuthorSection(scan: ScanResult, platform: Platform): PlanAu // finishes. We learned this from a 45-minute Fly build that died on // "Invalid token". Author them as a pair, always. if (!scan.hasDockerignore) { - files.push(draftDockerignore(scan)); + files.push(prefixFile(draftDockerignore(scan), prefix)); } } if (scan.existingPlatform !== platform) { const platformConfig = profile.platformConfigFile?.(scan); - if (platformConfig) files.push(platformConfig); + if (platformConfig) files.push(prefixFile(platformConfig, prefix)); } // Extra files some platforms need beyond their main config (e.g. VPS // ships a deploy.sh + optional nginx snippet alongside the Dockerfile). if (profile.extraFiles) { - for (const f of profile.extraFiles(scan)) files.push(f); + for (const f of profile.extraFiles(scan)) files.push(prefixFile(f, prefix)); } const envSchema = draftEnvSchema(scan, platform); - if (envSchema) files.push(envSchema); - if (files.length > 0) files.push(draftConvoyManifest(files)); + if (envSchema) files.push(prefixFile(envSchema, prefix)); + if (files.length > 0) files.push(draftConvoyManifest(files, prefix)); return { convoyAuthoredFiles: files }; } +function prefixFile(file: PlanAuthoredFile, prefix: (p: string) => string): PlanAuthoredFile { + return { ...file, path: prefix(file.path) }; +} + /** * Per-platform authoring contract. The point of this seam is that "what * does Convoy author for this platform?" lives in one record per platform @@ -125,74 +141,150 @@ function draftDockerfile(scan: ScanResult, platform: Platform): PlanAuthoredFile } /** - * Deliberately simple fallback. One template per ecosystem. When ANTHROPIC_API_KEY - * is set, the enricher overwrites this with a Dockerfile tailored to the actual - * repo (framework, package manager, prisma, port, startup, etc.). + * Signal-aware fallback. Uses every scan field that's relevant for a + * production-quality Dockerfile: port, package manager, build command, + * start command, framework (Next.js standalone), and data layer (Prisma + * generate). When ANTHROPIC_API_KEY is set the enricher overwrites this + * with a Dockerfile further tailored to the specific repo, but this + * fallback is now good enough to deploy without enrichment for common stacks. */ function fallbackDockerfile(scan: ScanResult): string { + const port = scan.port ?? 8080; + switch (scan.ecosystem) { - case 'python': - return `FROM python:3.12-slim -WORKDIR /app -COPY . . -RUN pip install --no-cache-dir -r requirements.txt || pip install --no-cache-dir . -EXPOSE 8080 -CMD ["python", "main.py"] -`; + case 'python': { + const hasDeps = scan.topLevelFiles.includes('requirements.txt') || + scan.topLevelFiles.includes('pyproject.toml'); + const installCmd = scan.packageManager === 'poetry' + ? 'pip install poetry && poetry install --no-dev' + : scan.packageManager === 'uv' + ? 'pip install uv && uv sync --no-dev' + : 'pip install --no-cache-dir -r requirements.txt || pip install --no-cache-dir .'; + const startCmd = scan.startCommand + ?? (scan.framework === 'fastapi' ? `uvicorn main:app --host 0.0.0.0 --port ${port}` + : scan.framework === 'django' ? `gunicorn --bind 0.0.0.0:${port} wsgi:application` + : `python main.py`); + return [ + `FROM python:3.12-slim`, + `WORKDIR /app`, + hasDeps ? `COPY ${scan.packageManager === 'poetry' ? 'pyproject.toml poetry.lock' : scan.packageManager === 'uv' ? 'pyproject.toml uv.lock' : 'requirements.txt'} ./` : `COPY . .`, + hasDeps ? `RUN ${installCmd}` : null, + hasDeps ? `COPY . .` : null, + `EXPOSE ${port}`, + `CMD ["sh", "-c", "${startCmd}"]`, + ].filter(Boolean).join('\n') + '\n'; + } + case 'go': - return `FROM golang:1.23-alpine AS build + return `FROM golang:1.24-alpine AS build WORKDIR /src COPY . . RUN go build -o /out/app ./... -FROM alpine:3.19 +FROM alpine:3.21 COPY --from=build /out/app /app -EXPOSE 8080 +EXPOSE ${port} CMD ["/app"] `; + case 'rust': - return `FROM rust:1.83-slim AS build + return `FROM rust:1.84-slim AS build WORKDIR /src COPY . . -RUN cargo build --release +RUN cargo build --release --bin $(basename $(cargo metadata --no-deps --format-version 1 | grep -o '"name":"[^"]*"' | head -1 | cut -d: -f2 | tr -d '"') 2>/dev/null || echo app) FROM debian:bookworm-slim -COPY --from=build /src/target/release/* /app -EXPOSE 8080 -CMD ["/app"] +COPY --from=build /src/target/release/* /usr/local/bin/ +EXPOSE ${port} +CMD ["sh", "-c", "$(ls /usr/local/bin/ | head -1)"] `; + case 'ruby': - return `FROM ruby:3.3-slim + return `FROM ruby:3.4-slim WORKDIR /app -COPY . . +COPY Gemfile Gemfile.lock ./ RUN bundle install --without development test -EXPOSE 8080 -CMD ["bundle", "exec", "rackup", "-p", "8080", "-o", "0.0.0.0"] +COPY . . +EXPOSE ${port} +CMD ["bundle", "exec", "rackup", "-p", "${port}", "-o", "0.0.0.0"] `; + case 'java-jvm': return `FROM eclipse-temurin:21 AS build WORKDIR /src COPY . . -RUN ./gradlew bootJar || ./mvnw package -DskipTests +RUN ./gradlew bootJar -x test 2>/dev/null || ./mvnw package -DskipTests FROM eclipse-temurin:21-jre COPY --from=build /src/build/libs/*.jar /app/app.jar -EXPOSE 8080 +EXPOSE ${port} CMD ["java", "-jar", "/app/app.jar"] `; + case 'static': return `FROM nginx:alpine COPY . /usr/share/nginx/html EXPOSE 80 `; - default: - return `FROM node:20-alpine + + default: { + // Node / JS — use every scan signal that's available. + const installCmd = nodeInstallCmd(scan.packageManager); + const hasPrisma = scan.dataLayer.some(d => d.toLowerCase().includes('prisma')); + const isNext = scan.framework === 'next' || scan.framework === 'nextjs'; + const buildCmd = scan.buildCommand ?? (isNext ? 'npm run build' : null); + const startCmd = scan.startCommand ?? 'npm start'; + + if (isNext) { + // Next.js standalone output — smallest possible production image. + // The enricher can add output: 'standalone' to next.config if absent, + // but this Dockerfile is already wired for it. + return `FROM node:22-alpine AS deps +WORKDIR /app +COPY package*.json ${scan.packageManager === 'pnpm' ? 'pnpm-lock.yaml .npmrc* ' : scan.packageManager === 'yarn' ? 'yarn.lock ' : scan.packageManager === 'bun' ? 'bun.lockb ' : ''}./ +RUN ${installCmd} + +FROM node:22-alpine AS build WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules COPY . . -RUN npm ci -EXPOSE 8080 -CMD ["npm", "start"] +${hasPrisma ? 'RUN npx prisma generate' : ''} +RUN ${buildCmd} + +FROM node:22-alpine AS run +WORKDIR /app +ENV NODE_ENV=production PORT=${port} +COPY --from=build /app/.next/standalone ./ +COPY --from=build /app/.next/static ./.next/static +COPY --from=build /app/public ./public +EXPOSE ${port} +CMD ["node", "server.js"] `; + } + + const lines = [ + `FROM node:22-alpine`, + `WORKDIR /app`, + `COPY package*.json ${scan.packageManager === 'pnpm' ? 'pnpm-lock.yaml .npmrc* ' : scan.packageManager === 'yarn' ? 'yarn.lock ' : scan.packageManager === 'bun' ? 'bun.lockb ' : ''}./`, + `RUN ${installCmd}`, + hasPrisma ? `COPY prisma ./prisma` : null, + hasPrisma ? `RUN npx prisma generate` : null, + `COPY . .`, + buildCmd ? `RUN ${buildCmd}` : null, + `EXPOSE ${port}`, + `CMD ["sh", "-c", "${startCmd}"]`, + ]; + return lines.filter(Boolean).join('\n') + '\n'; + } + } +} + +function nodeInstallCmd(pm: ScanResult['packageManager']): string { + switch (pm) { + case 'pnpm': return 'corepack enable pnpm && pnpm install --frozen-lockfile'; + case 'yarn': return 'corepack enable yarn && yarn install --frozen-lockfile'; + case 'bun': return 'npm i -g bun && bun install --frozen-lockfile'; + default: return 'npm ci'; } } @@ -624,9 +716,13 @@ ${vars.join('\n')} }; } -function draftConvoyManifest(files: PlanAuthoredFile[]): PlanAuthoredFile { +function draftConvoyManifest( + files: PlanAuthoredFile[], + prefix: (p: string) => string = (p) => p, +): PlanAuthoredFile { + const manifestPath = prefix('.convoy/manifest.yaml'); const entries = files - .filter((f) => f.path !== '.convoy/manifest.yaml') + .filter((f) => f.path !== manifestPath) .map((f) => ` - path: ${f.path}\n authored_by: convoy`) .join('\n'); const content = `# Provenance record. Files here are Convoy-authored and may be @@ -637,7 +733,7 @@ files: ${entries} `; return { - path: '.convoy/manifest.yaml', + path: manifestPath, lines: content.split('\n').length, summary: `${files.length} files tracked`, contentPreview: content, diff --git a/src/planner/index.ts b/src/planner/index.ts index e30c625..04a380f 100644 --- a/src/planner/index.ts +++ b/src/planner/index.ts @@ -53,6 +53,9 @@ export async function buildPlan( : graph.nodes.map((node) => buildLane(node, opts.platformOverride)); const dependencies = buildDependencies(lanes); const connectionRequirements = buildConnectionRequirements(lanes); + // Each lane's files are already path-prefixed by draftAuthorSection (e.g. + // apps/api/Dockerfile). Dedup only within paths — cross-lane files should + // never share a path now that servicePath prefixing is applied. const author = { convoyAuthoredFiles: dedupeAuthoredFiles(lanes.flatMap((lane) => lane.author.convoyAuthoredFiles)), }; @@ -124,7 +127,7 @@ export async function buildPlan( function buildLane(node: ServiceNode, override?: Platform): DeploymentLane { const platformDecision = pickPlatformForLane(node, override); - const author = draftAuthorSection(node.scan, platformDecision.chosen); + const author = draftAuthorSection(node.scan, platformDecision.chosen, node.path); const rehearsal = defaultRehearsal(node.scan, platformDecision.chosen); const promotion = defaultPromotion(); const rollback = defaultRollback(platformDecision.chosen); diff --git a/src/planner/picker.ts b/src/planner/picker.ts index ff89b0d..932b703 100644 --- a/src/planner/picker.ts +++ b/src/planner/picker.ts @@ -1,4 +1,4 @@ -import type { PlanPlatformDecision, PlanPlatformCandidate } from '../core/plan.js'; +import type { PlanPlatformDecision, PlanPlatformCandidate, PlanPlatformAdjustment } from '../core/plan.js'; import type { Platform } from '../core/types.js'; import type { ScanResult, ServiceNode } from './scanner.js'; @@ -49,7 +49,12 @@ function scoreAll(scan: ScanResult): PlanPlatformCandidate[] { function scoreOne(platform: Platform, scan: ScanResult): PlanPlatformCandidate { let score = 50; - const reasons: string[] = []; + const adjustments: PlanPlatformAdjustment[] = []; + + const adj = (delta: number, label: string) => { + score += delta; + adjustments.push({ delta, label }); + }; const hasWorker = scan.topology === 'web+worker' || scan.topology === 'worker'; const isStatic = scan.topology === 'static'; @@ -58,140 +63,73 @@ function scoreOne(platform: Platform, scan: ScanResult): PlanPlatformCandidate { switch (platform) { case 'fly': - if (hasWorker) { - score += 25; - reasons.push('background worker friendly'); - } - if (needsContainer) { - score += 15; - reasons.push('container-native'); - } - if (hasPostgres) { - score += 5; - reasons.push('attaches external Postgres cleanly'); - } - if (isStatic) { - score -= 20; - reasons.push('overkill for a static site'); - } + if (hasWorker) adj(+25, 'background worker topology'); + if (needsContainer) adj(+15, 'container-native (Dockerfile / Go / Rust)'); + if (hasPostgres) adj(+5, 'attaches external Postgres cleanly'); + if (isStatic) adj(-20, 'overkill for a static site'); break; case 'railway': - if (hasPostgres) { - score += 20; - reasons.push('managed Postgres in one click'); - } - if (hasWorker) { - score += 10; - reasons.push('multi-service monorepo support'); - } - if (isStatic) score -= 10; + if (hasPostgres) adj(+20, 'managed Postgres in one click'); + if (hasWorker) adj(+10, 'multi-service monorepo support'); + if (isStatic) adj(-10, 'not optimised for static'); break; case 'vercel': - if (scan.framework === 'next.js' && !hasWorker) { - score += 35; - reasons.push('best-in-class for Next.js'); - } - if (isStatic) { - score += 25; - reasons.push('static sites are free and fast here'); - } - if (hasWorker) { - score -= 25; - reasons.push('background workers not supported'); - } - if (needsContainer && scan.framework !== 'next.js') { - score -= 15; - reasons.push('container-first apps fit awkwardly'); - } + if (scan.framework === 'next.js' && !hasWorker) adj(+35, 'best-in-class for Next.js'); + if (isStatic) adj(+25, 'static sites are free and instant here'); + if (hasWorker) adj(-25, 'background workers not supported'); + if (needsContainer && scan.framework !== 'next.js') adj(-15, 'container-first apps fit awkwardly'); break; case 'cloudrun': - if (needsContainer) { - score += 15; - reasons.push('container-native'); - } - if (hasPostgres) { - score += 5; - reasons.push('pairs with Cloud SQL'); - } - if (scan.framework === 'next.js' && !hasWorker) score -= 5; - if (isStatic) score -= 20; - // GCP onboarding overhead - score -= 5; - reasons.push('extra GCP setup cost'); + adj(-5, 'GCP setup overhead'); + if (needsContainer) adj(+15, 'container-native'); + if (hasPostgres) adj(+5, 'pairs with Cloud SQL'); + if (scan.framework === 'next.js' && !hasWorker) adj(-5, 'Next.js fits Vercel better'); + if (isStatic) adj(-20, 'static sites belong on a CDN'); break; case 'vps': { - // VPS is the "I own the box" path. The baseline penalty keeps it from - // autopicking over managed PaaSes unless signals clearly point here. if (process.env['CONVOY_VPS_HOST']) { - score += 30; - reasons.push('CONVOY_VPS_HOST set (operator opted in)'); + adj(+30, 'CONVOY_VPS_HOST set — operator opted in'); } else { - score -= 10; - reasons.push('opt-in only (set CONVOY_VPS_HOST or pass --platform=vps)'); + adj(-10, 'opt-in only (set CONVOY_VPS_HOST or --platform=vps)'); } - if (scan.hasDockerfile) { - score += 10; - reasons.push('repo already ships a Dockerfile'); - } + if (scan.hasDockerfile) adj(+10, 'repo ships a Dockerfile'); - // docker-compose.yml at root — operator has container orchestration - // already configured. Local-dev-only compose would live under a - // subdirectory; root-level compose targets production deployment. const hasDockerCompose = scan.topLevelFiles.some( (f) => f === 'docker-compose.yml' || f === 'docker-compose.yaml' || f === 'compose.yml' || f === 'compose.yaml', ); - if (hasDockerCompose) { - score += 15; - reasons.push('docker-compose.yml present — compose apps run naturally on VPS'); - } + if (hasDockerCompose) adj(+15, 'docker-compose.yml at root'); - // Caddyfile at repo root = operator already configured Caddy as the - // reverse proxy. Almost always means they own the box it runs on. - if (scan.topLevelFiles.includes('Caddyfile')) { - score += 15; - reasons.push('Caddyfile present — repo already configures Caddy reverse proxy'); - } + if (scan.topLevelFiles.includes('Caddyfile')) adj(+15, 'Caddyfile at root (Caddy reverse proxy)'); - // deploy.yml at root is the ship-to-vps convention: app was explicitly - // scaffolded for VPS deployment. Strongest non-env-var signal. - if ( - scan.topLevelFiles.includes('deploy.yml') || - scan.topLevelFiles.includes('deploy.yaml') - ) { - score += 20; - reasons.push('deploy.yml present — repo scaffolded for VPS deployment'); + if (scan.topLevelFiles.includes('deploy.yml') || scan.topLevelFiles.includes('deploy.yaml')) { + adj(+20, 'deploy.yml — scaffolded for VPS deployment'); } - // Multi-service monorepos with 3+ services are expensive on managed - // PaaSes (one deployment unit per service); a single VPS with compose - // is often the practical choice. if (scan.subServices.length >= 3) { - score += 10; - reasons.push(`${scan.subServices.length}-service repo — VPS compose avoids per-service PaaS pricing`); + adj(+10, `${scan.subServices.length}-service repo — VPS compose avoids per-service PaaS pricing`); } - if (isStatic) { - score -= 30; - reasons.push('static sites belong on a CDN, not a VPS'); - } - if (scan.framework === 'next.js' && !hasWorker) { - score -= 10; - reasons.push('Next.js fits Vercel better unless you need a box'); - } + if (isStatic) adj(-30, 'static sites belong on a CDN'); + if (scan.framework === 'next.js' && !hasWorker) adj(-10, 'Next.js fits Vercel better unless you need a box'); break; } } score = Math.max(0, Math.min(100, score)); + const reasons = adjustments + .filter((a) => a.delta > 0) + .map((a) => a.label); + return { platform, score, reason: reasons.join(', ') || 'no strong signal', + adjustments, }; } diff --git a/web/app/plans/[id]/page.tsx b/web/app/plans/[id]/page.tsx index cf038a4..5f3fd78 100644 --- a/web/app/plans/[id]/page.tsx +++ b/web/app/plans/[id]/page.tsx @@ -83,30 +83,116 @@ export default async function PlanPage({ params }: { params: Promise<{ id: strin function LaneSection({ plan }: { plan: PlanSummary }) { const lanes = plan.lanes ?? []; if (lanes.length === 0) return null; + const { stagedLocally, markedAlreadySet } = computeStagedState(plan); + return (

Lanes

-
- {lanes.map((lane) => ( -
-
- {lane.displayName} - {lane.role} - {lane.platformDecision.chosen} -
-
{lane.servicePath}
-
- {lane.scan.ecosystem} - {lane.scan.framework ? ` · ${lane.scan.framework}` : ''} - {lane.scan.topology ? ` · ${lane.scan.topology}` : ''} +
+ {lanes.map((lane) => { + const laneRisks = lane.scan.risks ?? []; + const hardRisks = laneRisks.filter((r) => r.level === 'block'); + const warnRisks = laneRisks.filter((r) => r.level === 'warn'); + const infoRisks = laneRisks.filter((r) => r.level === 'info'); + const expectedKeys = lane.secrets?.expectedKeys ?? []; + + return ( +
+ {/* Lane header */} +
+
+ {lane.displayName} + {lane.role} + {lane.platformDecision.chosen} +
+
{lane.servicePath}
+
+ {lane.scan.ecosystem} + {lane.scan.framework ? ` · ${lane.scan.framework}` : ''} + {lane.scan.topology ? ` · ${lane.scan.topology}` : ''} + {lane.scan.healthPath ? health={lane.scan.healthPath} : null} + {lane.scan.port ? :{lane.scan.port} : null} +
+
+ + {/* Per-lane risks */} + {laneRisks.length > 0 ? ( +
+
+ Scan signals +
+ {hardRisks.map((r, i) => ( +
+ block + {r.message} +
+ ))} + {warnRisks.map((r, i) => ( +
+ warn + {r.message} +
+ ))} + {infoRisks.map((r, i) => ( +
+ info + {r.message} +
+ ))} +
+ ) : null} + + {/* Per-lane secrets */} + {expectedKeys.length > 0 ? ( +
+
+ Required secrets ({expectedKeys.length}) +
+
    + {expectedKeys.map((key) => { + const staged = stagedLocally.has(key); + const declared = markedAlreadySet.has(key); + const state = staged ? 'staged' : declared ? 'declared' : 'missing'; + return ( +
  • + + {key} + {state === 'staged' ? ( + staged + ) : state === 'declared' ? ( + already-set + ) : ( + missing + )} +
  • + ); + })} +
+ {expectedKeys.some((k) => !stagedLocally.has(k) && !markedAlreadySet.has(k)) ? ( +

+ Stage missing vars via convoy stage-secrets {plan.id.slice(0, 8)} or the panel below. +

+ ) : null} +
+ ) : null}
-
- ))} + ); + })}
{plan.dependencies && plan.dependencies.length > 0 ? ( -
+
+
Deploy order
{plan.dependencies.map((dep) => ( -
{dep.from} → {dep.to} · {dep.reason}
+
+ {dep.from} + + {dep.to} + {dep.reason} +
))}
) : null} @@ -315,6 +401,9 @@ function PlatformSection({ plan }: { plan: PlanSummary }) {
{sortedCandidates.map((c) => { const chosen = c.platform === plan.platform.chosen; + const topAdj = ((c as { adjustments?: Array<{ delta: number; label: string }> }).adjustments ?? []) + .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)) + .slice(0, 3); return (
{c.platform} {c.score}
-
{c.reason}
+ {topAdj.length > 0 ? ( +
    + {topAdj.map((a, i) => ( +
  • + 0 ? 'text-success' : 'text-danger'}`}> + {a.delta > 0 ? '+' : ''}{a.delta} + + {a.label} +
  • + ))} +
+ ) : ( +
{c.reason}
+ )}
); })} diff --git a/web/app/runs/[id]/page.tsx b/web/app/runs/[id]/page.tsx index 42923a6..33c2abe 100644 --- a/web/app/runs/[id]/page.tsx +++ b/web/app/runs/[id]/page.tsx @@ -2060,6 +2060,9 @@ function MergePrApprovalCard({ const prNumber = typeof summary['pr_number'] === 'number' ? summary['pr_number'] : null; const branch = typeof summary['branch'] === 'string' ? summary['branch'] : null; const note = typeof summary['note'] === 'string' ? summary['note'] : null; + const rehearsal = summary['rehearsal'] && typeof summary['rehearsal'] === 'object' + ? (summary['rehearsal'] as Record) + : null; // New shape: files is an array of {path, lines, summary, contentPreview}. // Older shape (pre-rewrite): files is a string[] of paths only. @@ -2116,6 +2119,14 @@ function MergePrApprovalCard({
) : null} + {rehearsal ? : ( + mode !== 'scripted' ? ( +
+ No rehearsal evidence — check the rehearse stage above. +
+ ) : null + )} + {note ? (

{note}

) : null} diff --git a/web/lib/plans.ts b/web/lib/plans.ts index 19a810a..93aee24 100644 --- a/web/lib/plans.ts +++ b/web/lib/plans.ts @@ -12,6 +12,7 @@ export interface PlanPlatformCandidate { platform: string; score: number; reason: string; + adjustments?: Array<{ delta: number; label: string }>; } export interface PlanShipStep { @@ -41,6 +42,9 @@ export interface PlanSummary { framework: string | null; topology: string; dataLayer: string[]; + risks?: Array<{ level: string; message: string }>; + healthPath?: string | null; + port?: number | null; }; platformDecision: { chosen: string;