feat: add disposable Vercel and Convex PR previews (#3017)

Adds isolated Convex-backed Vercel PR previews with shared local/preview seeding, preview-safe routing, and production guards.
This commit is contained in:
Patrick Erichsen
2026-07-08 20:54:09 -07:00
committed by GitHub
parent 01753578f2
commit dfca6f5d9d
43 changed files with 1228 additions and 248 deletions
@@ -1,7 +1,6 @@
---
name: convex-create-component
description:
Builds reusable Convex components with isolated tables and app-facing APIs.
description: Builds reusable Convex components with isolated tables and app-facing APIs.
Use for new components, reusable backend modules, integrations, or component
boundary work.
---
@@ -131,9 +130,7 @@ export const listUnread = query({
handler: async (ctx, args) => {
return await ctx.db
.query("notifications")
.withIndex("by_user_read", (q) =>
q.eq("userId", args.userId).eq("read", false),
)
.withIndex("by_user_read", (q) => q.eq("userId", args.userId).eq("read", false))
.collect();
},
});
@@ -1,12 +1,10 @@
interface:
display_name: "Convex Create Component"
short_description:
"Design and build reusable Convex components with clear boundaries."
short_description: "Design and build reusable Convex components with clear boundaries."
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#14B8A6"
default_prompt:
"Help me create a Convex component for this feature. First check that a
default_prompt: "Help me create a Convex component for this feature. First check that a
component is actually justified, then design the tables, API surface, and
app-facing wrappers before implementing it."
@@ -1,7 +1,6 @@
---
name: convex-migration-helper
description:
Plans Convex schema and data migrations with widen-migrate-narrow and
description: Plans Convex schema and data migrations with widen-migrate-narrow and
@convex-dev/migrations. Use for breaking schema changes, backfills, table
reshaping, or zero-downtime rollouts.
---
@@ -4,8 +4,7 @@ interface:
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#8B5CF6"
default_prompt:
"Help me plan and execute this Convex migration safely. Start by identifying
default_prompt: "Help me plan and execute this Convex migration safely. Start by identifying
the schema change, the existing data shape, and the widen-migrate-narrow
path before making edits."
@@ -1,7 +1,6 @@
---
name: convex-performance-audit
description:
Audits Convex performance for reads, subscriptions, write contention, and
description: Audits Convex performance for reads, subscriptions, write contention, and
function limits. Use for slow features, insights findings, OCC conflicts, or
read amplification.
---
@@ -1,12 +1,10 @@
interface:
display_name: "Convex Performance Audit"
short_description:
"Audit slow Convex reads, subscriptions, OCC conflicts, and limits."
short_description: "Audit slow Convex reads, subscriptions, OCC conflicts, and limits."
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#EF4444"
default_prompt:
"Audit this Convex app for performance issues. Start with the strongest
default_prompt: "Audit this Convex app for performance issues. Start with the strongest
signal available, identify the problem class, and suggest the smallest
high-impact fix before proposing bigger structural changes."
@@ -144,10 +144,10 @@ defineTable({ team: v.id("teams"), user: v.id("users") })
```ts
// Good: single compound index serves both query patterns
defineTable({ team: v.id("teams"), user: v.id("users") }).index(
"by_team_and_user",
["team", "user"],
);
defineTable({ team: v.id("teams"), user: v.id("users") }).index("by_team_and_user", [
"team",
"user",
]);
```
Exception: `.index("by_foo", ["foo"])` is really an index on `foo` +
@@ -195,8 +195,7 @@ const ownerName = project.ownerName ?? "Unknown owner";
```ts
// Good: denormalized data is an optimization, not the only source of truth
const ownerName =
project.ownerName ?? (await ctx.db.get(project.ownerId))?.name ?? null;
const ownerName = project.ownerName ?? (await ctx.db.get(project.ownerId))?.name ?? null;
```
Bad lookup map pattern:
@@ -157,10 +157,7 @@ const profile = useQuery(api.users.getProfile, { userId: selectedId! });
```ts
// Good: skip when there is nothing to fetch
const profile = useQuery(
api.users.getProfile,
selectedId ? { userId: selectedId } : "skip",
);
const profile = useQuery(api.users.getProfile, selectedId ? { userId: selectedId } : "skip");
```
### 4. Isolate frequently-updated fields into separate documents
+3 -10
View File
@@ -1,7 +1,6 @@
---
name: convex-quickstart
description:
Creates or adds Convex to an app. Use for new Convex projects, npm create
description: Creates or adds Convex to an app. Use for new Convex projects, npm create
convex@latest, frontend setup, env vars, or the first npx convex dev run.
---
@@ -222,9 +221,7 @@ Create the `ConvexReactClient` at module scope, not inside a component:
```tsx
// Bad: re-creates the client on every render
function App() {
const convex = new ConvexReactClient(
import.meta.env.VITE_CONVEX_URL as string,
);
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);
return <ConvexProvider client={convex}>...</ConvexProvider>;
}
@@ -275,11 +272,7 @@ export function ConvexClientProvider({ children }: { children: ReactNode }) {
// app/layout.tsx
import { ConvexClientProvider } from "./ConvexClientProvider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
@@ -1,12 +1,10 @@
interface:
display_name: "Convex Quickstart"
short_description:
"Start a new Convex app or add Convex to an existing frontend."
short_description: "Start a new Convex app or add Convex to an existing frontend."
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#F97316"
default_prompt:
"Set up Convex for this project as fast as possible. First decide whether
default_prompt: "Set up Convex for this project as fast as possible. First decide whether
this is a new app or an existing app, then scaffold or integrate Convex and
verify the setup works."
+2 -5
View File
@@ -1,7 +1,6 @@
---
name: convex-setup-auth
description:
Sets up Convex auth, identity mapping, and access control. Use for login, auth
description: Sets up Convex auth, identity mapping, and access control. Use for login, auth
providers, users tables, protected functions, or roles in a Convex app.
---
@@ -131,9 +130,7 @@ export const getMyProfile = query({
return await ctx.db
.query("users")
.withIndex("by_tokenIdentifier", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier),
)
.withIndex("by_tokenIdentifier", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier))
.unique();
},
});
@@ -1,12 +1,10 @@
interface:
display_name: "Convex Setup Auth"
short_description:
"Set up Convex auth, user identity mapping, and access control."
short_description: "Set up Convex auth, user identity mapping, and access control."
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#2563EB"
default_prompt:
"Set up authentication for this Convex app. Figure out the provider first,
default_prompt: "Set up authentication for this Convex app. Figure out the provider first,
then wire up the user model, identity mapping, and access control with the
smallest solid implementation."
+1 -2
View File
@@ -1,7 +1,6 @@
---
name: convex
description:
Routes general Convex requests to the right project skill. Use when the user
description: Routes general Convex requests to the right project skill. Use when the user
asks which Convex skill to use or gives an underspecified Convex app task.
---
+1
View File
@@ -27,6 +27,7 @@ Keep this section as the command map agents normally need, not a full `package.j
- `bun run setup:worktree` — validate copied `.env.local` / `.convex` state, or link missing fallback state from a usable source worktree. Use `-- --from <path>` or `CLAWHUB_WORKTREE_SOURCE=<path>` when auto-discovery picks the wrong source.
- `bun run dev:worktree` — Worktrunk-managed detached worktree server that also seeds local fixtures plus the public corpus once before starting the app when `VITE_CONVEX_URL` and `CONVEX_DEPLOYMENT` are local. Requires `wt` on `PATH`; from that worktree use `wt --yes url` to print the branch URL and `wt --yes stop` to stop it.
- `bun run seed:dev` — manual reseed path; runs worktree setup, waits for local Convex, seeds local fixtures plus the public corpus, and refreshes stats.
- `bun run seed` — shared non-production seed pipeline used by local setup and disposable PR previews after their target Convex deployment is ready.
- `bun run build` — production build (Vite + Nitro).
- `bun run ci:static` — required pre-handoff static gate: peer checks, audit, formatting, lint, and dead-code checks.
- `bun run ci:unit` — Vitest coverage gate; required for source/test PRs unless docs/config-only.
+10
View File
@@ -100,10 +100,12 @@ describe("crons", () => {
vi.resetModules();
mocks.interval.mockReset();
delete process.env.CLAWHUB_DISABLE_CRONS;
delete process.env.CLAWHUB_PREVIEW;
});
afterEach(() => {
delete process.env.CLAWHUB_DISABLE_CRONS;
delete process.env.CLAWHUB_PREVIEW;
});
it("does not register production cron work when explicitly disabled", async () => {
@@ -114,6 +116,14 @@ describe("crons", () => {
expect(mocks.interval).not.toHaveBeenCalled();
});
it("does not register side-effecting cron work in disposable previews", async () => {
process.env.CLAWHUB_PREVIEW = "1";
await import("./crons");
expect(mocks.interval).not.toHaveBeenCalled();
});
it("runs GitHub skill source sync every 15 minutes", async () => {
await import("./crons");
+1 -1
View File
@@ -4,7 +4,7 @@ import { RETENTION_STANDARD_BATCH_SIZE } from "./lib/retentionPolicy";
const crons = cronJobs();
if (process.env.CLAWHUB_DISABLE_CRONS !== "1") {
if (process.env.CLAWHUB_DISABLE_CRONS !== "1" && process.env.CLAWHUB_PREVIEW !== "1") {
crons.interval(
"github-skill-source-sync",
{ minutes: 15 },
+46 -1
View File
@@ -56,6 +56,7 @@ function createDb() {
const tables: Record<string, Array<Record<string, unknown> & { _id: string }>> = {};
const counters: Record<string, number> = {};
const operations: Array<{ type: "delete"; table: string; id: string }> = [];
const queries: Array<{ table: string; constraints: Record<string, unknown> }> = [];
const list = (table: string) => {
tables[table] ??= [];
@@ -114,6 +115,7 @@ function createDb() {
withIndex: (_name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
const constraints: Record<string, unknown> = {};
build(chainEq(constraints));
queries.push({ table, constraints });
const matched = () =>
list(table).filter((doc) => matches(doc as Record<string, unknown>, constraints));
return {
@@ -137,7 +139,7 @@ function createDb() {
}),
};
return { db, tables, operations };
return { db, tables, operations, queries };
}
function createMutationCtx(db: ReturnType<typeof createDb>["db"]) {
@@ -277,6 +279,49 @@ describe("devSeed local fixtures", () => {
);
});
it("resolves a shared public corpus owner once per mutation batch", async () => {
const { db, queries } = createDb();
const dummyOwner = {
handle: "corpus-owner",
displayName: "Corpus Owner",
image: "https://example.invalid/avatar.png",
};
await seedPublicCorpusBatchHandler(
createMutationCtx(db) as never,
{
rows: [
{
kind: "skill",
slug: "corpus-one",
displayName: "Corpus One",
version: "0.1.0",
skillMd: "# Corpus one",
storageId: "storage:corpus-one",
embedding: [0, 1, 2],
dummyOwner,
},
{
kind: "skill",
slug: "corpus-two",
displayName: "Corpus Two",
version: "0.1.0",
skillMd: "# Corpus two",
storageId: "storage:corpus-two",
embedding: [0, 1, 2],
dummyOwner,
},
],
} as never,
);
expect(
queries.filter(
(query) => query.table === "users" && query.constraints.handle === dummyOwner.handle,
),
).toHaveLength(1);
});
it("backfills daily activity for existing public corpus skills", async () => {
const { db, tables } = createDb();
const userId = (await db.insert("users", {
+57 -11
View File
@@ -64,6 +64,12 @@ type PublicCorpusSeedBatchResult = {
skipped: string[];
};
type PublicCorpusSeedBatchHandlerResult = {
ok: true;
seeded: string[];
skipped: string[];
};
function seededPackageRecommendationScore(stats: {
downloads: number;
installs: number;
@@ -189,6 +195,33 @@ const publicCorpusSeedRowValidator = v.union(
publicCorpusPluginRowValidator,
);
type PublicCorpusSeedRow =
| {
kind: "skill";
slug: string;
displayName: string;
version: string;
skillMd: string;
summary?: string;
createdAt?: number;
dummyOwner: PublicCorpusDummyOwner;
}
| {
kind: "plugin";
name: string;
displayName: string;
version: string;
readme: string;
summary?: string;
categories?: string[];
topics?: string[];
family?: "skill" | "code-plugin" | "bundle-plugin";
channel?: "official" | "community" | "private";
sourceRepoHost?: string | null;
createdAt?: number;
dummyOwner: PublicCorpusDummyOwner;
};
const publicCorpusPreparedSkillRowValidator = v.object({
kind: v.literal("skill"),
slug: v.string(),
@@ -924,13 +957,14 @@ export const backfillExistingPublicCorpusBatchRows = internalMutation({
},
});
export const seedPublicCorpusBatch: ReturnType<typeof internalAction> = internalAction({
async function seedPublicCorpusBatchHandler(
ctx: ActionCtx,
args: {
reset: v.optional(v.boolean()),
resetOwnerHandles: v.optional(v.array(v.string())),
rows: v.array(publicCorpusSeedRowValidator),
reset?: boolean;
resetOwnerHandles?: string[];
rows: PublicCorpusSeedRow[];
},
handler: async (ctx, args) => {
): Promise<PublicCorpusSeedBatchHandlerResult> {
const existingResult: PublicCorpusExistingRowsResult | null = args.reset
? null
: await ctx.runMutation(internal.devSeed.backfillExistingPublicCorpusBatchRows, {
@@ -941,7 +975,7 @@ export const seedPublicCorpusBatch: ReturnType<typeof internalAction> = internal
? args.rows
: args.rows.filter((row) => missingKeys.has(publicCorpusSeedRowKey(row)));
if (!args.reset && rowsToPrepare.length === 0) {
return { ok: true, seeded: [], skipped: existingResult?.skipped ?? [] };
return { ok: true as const, seeded: [], skipped: existingResult?.skipped ?? [] };
}
const preparedRows = await Promise.all(
@@ -959,9 +993,7 @@ export const seedPublicCorpusBatch: ReturnType<typeof internalAction> = internal
const embedding = await generateEmbedding(embeddingText);
return { ...row, storageId, embedding };
}
const storageId = await ctx.storage.store(
new Blob([row.readme], { type: "text/markdown" }),
);
const storageId = await ctx.storage.store(new Blob([row.readme], { type: "text/markdown" }));
return { ...row, storageId };
}),
);
@@ -976,11 +1008,19 @@ export const seedPublicCorpusBatch: ReturnType<typeof internalAction> = internal
);
return {
ok: true,
ok: true as const,
seeded: seedResult.seeded,
skipped: [...(existingResult?.skipped ?? []), ...seedResult.skipped],
};
}
export const seedPublicCorpusBatch: ReturnType<typeof internalAction> = internalAction({
args: {
reset: v.optional(v.boolean()),
resetOwnerHandles: v.optional(v.array(v.string())),
rows: v.array(publicCorpusSeedRowValidator),
},
handler: seedPublicCorpusBatchHandler,
});
function publicCorpusSeedRowKey(
@@ -1016,9 +1056,15 @@ export const seedPublicCorpusBatchMutation = internalMutation({
const seeded: string[] = [];
const skipped: string[] = [];
const owners = new Map<string, { userId: Id<"users">; publisherId: Id<"publishers"> }>();
for (const row of args.rows) {
const { userId, publisherId } = await ensurePublicCorpusOwner(ctx, row.dummyOwner);
let owner = owners.get(row.dummyOwner.handle);
if (!owner) {
owner = await ensurePublicCorpusOwner(ctx, row.dummyOwner);
owners.set(row.dummyOwner.handle, owner);
}
const { userId, publisherId } = owner;
if (row.kind === "skill") {
const existing = await ctx.db
.query("skills")
+2
View File
@@ -10,6 +10,7 @@
"scripts": {
"admin": "bun packages/clawhub-admin/src/cli.ts",
"build": "bun run llms:generate && vite build && bun scripts/copy-og-assets.ts",
"build:vercel": "bun scripts/vercel-build.ts",
"check": "bun run lint",
"check:peers": "bun scripts/check-peer-deps.ts",
"check:release-workflow-action-pins": "node scripts/check-release-workflow-action-pins.mjs",
@@ -56,6 +57,7 @@
"release:clawhub:cli:changelog": "node scripts/extract-changelog-release.mjs",
"release:clawhub:cli:npm:check": "node scripts/clawhub-cli-npm-release-check.mjs",
"security:codex-worker": "bun scripts/security/run-codex-scan-worker.ts",
"seed": "bun scripts/seed.ts",
"seed:dev": "bun run setup:worktree -- --quiet && bun scripts/dev-worktree.ts --seed-only",
"seed:public-corpus": "bun run setup:worktree -- --quiet && bunx convex dev --once --typecheck=disable && bun scripts/public-corpus/seed-public-corpus.ts",
"setup:worktree": "bun scripts/setup-worktree.ts",
+1 -1
View File
@@ -8,7 +8,6 @@ import {
buildEnvFileCandidates,
buildViteArgs,
applyLocalConvexEnvForUrl,
isConvexFunctionUnavailableOutput,
isLocalConvexUrl,
isRunningPid,
parseArgs,
@@ -16,6 +15,7 @@ import {
shouldSeedLocalData,
shouldStartDevWorkers,
} from "./dev-worktree";
import { isConvexFunctionUnavailableOutput } from "./seed";
describe("dev-worktree helpers", () => {
it("seeds local fixtures and public corpus when Worktrunk starts dev services", () => {
+1 -69
View File
@@ -16,7 +16,6 @@ type Options = {
const DEFAULT_ENV_SOURCES = [".env.local"];
const CONVEX_START_TIMEOUT_MS = 120_000;
const CONVEX_FUNCTIONS_READY_TIMEOUT_MS = 120_000;
const REACHABILITY_POLL_MS = 500;
const RUNTIME_DIR = ".codex/runtime";
const DETACHED_PID_FILE = `${RUNTIME_DIR}/dev-worktree.pid`;
@@ -305,57 +304,6 @@ export function runSync(
);
}
function runSyncBuffered(
command: string,
args: string[],
extraEnv: Record<string, string | undefined>,
) {
const result = spawnSync(command, args, {
cwd: process.cwd(),
encoding: "utf8",
env: { ...process.env, ...extraEnv },
});
return {
output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
status: result.status ?? 1,
};
}
function writeBufferedOutput(output: string) {
if (output) process.stdout.write(output);
}
export function isConvexFunctionUnavailableOutput(output: string) {
return (
output.includes("Could not find function for") &&
output.includes("Did you forget to run `npx convex dev`")
);
}
async function runConvexFunctionWhenReady(args: string[]) {
const startedAt = Date.now();
while (true) {
const result = runSyncBuffered("bunx", args, {});
if (result.status === 0) {
writeBufferedOutput(result.output);
return 0;
}
if (
!isConvexFunctionUnavailableOutput(result.output) ||
Date.now() - startedAt >= CONVEX_FUNCTIONS_READY_TIMEOUT_MS
) {
writeBufferedOutput(result.output);
return result.status;
}
console.log("Convex functions are not queryable yet; retrying...");
await sleep(REACHABILITY_POLL_MS);
}
}
function spawnManaged(command: string, args: string[]) {
const child = spawn(command, args, {
cwd: process.cwd(),
@@ -549,24 +497,8 @@ async function main() {
if (seed.seed) {
console.log("Seeding local fixtures and public corpus...");
const seedStatus = await runConvexFunctionWhenReady([
"convex",
"run",
"--no-push",
"devSeed:seedLocalFixtures",
]);
const seedStatus = runSync("bun", ["run", "seed"], {});
if (seedStatus !== 0) exitAfterStoppingManagedChildren(seedStatus);
const publicCorpusStatus = runSync("bun", ["scripts/public-corpus/seed-public-corpus.ts"], {});
if (publicCorpusStatus !== 0) exitAfterStoppingManagedChildren(publicCorpusStatus);
const statsStatus = await runConvexFunctionWhenReady([
"convex",
"run",
"--no-push",
"statsMaintenance:updateGlobalStatsAction",
]);
if (statsStatus !== 0) exitAfterStoppingManagedChildren(statsStatus);
writeSeedSentinel(convexUrl, convexDeployment?.trim() ?? "");
}
@@ -1,12 +1,138 @@
import { describe, expect, it, vi } from "vitest";
import {
DEFAULT_BATCH_BYTES,
DEFAULT_SEED_CONCURRENCY,
MAX_CONVEX_RUN_ARG_BYTES,
buildSeedCorpusRow,
chunkRowsByOwner,
isRetryableConvexSeedBatchOutput,
runSeedCorpusBatches,
runConvexSeedBatchWithRetry,
type SeedBatchRunOnce,
type SeedCorpusRow,
serializeConvexSeedArgs,
} from "./seed-public-corpus";
describe("public corpus seed runner", () => {
it("uses portable owner-isolated batches", () => {
const owner = {
handle: "dummy-owner",
displayName: "Dummy Owner",
image: "https://example.invalid/avatar.png",
};
const rows = Array.from({ length: 4 }, (_, index) =>
buildSeedCorpusRow(
{
kind: "skill",
slug: `skill-${index}`,
displayName: `Skill ${index}`,
version: "1.0.0",
skillMd: "x".repeat(50_000),
},
owner,
),
);
expect(DEFAULT_BATCH_BYTES).toBeGreaterThan(96_000);
expect(DEFAULT_SEED_CONCURRENCY).toBe(24);
expect(chunkRowsByOwner(rows, DEFAULT_BATCH_BYTES)).toEqual([rows.slice(0, 2), rows.slice(2)]);
});
it("rejects batch arguments above the portable process limit", () => {
expect(() =>
serializeConvexSeedArgs({
rows: [{ skillMd: "x".repeat(MAX_CONVEX_RUN_ARG_BYTES) }],
}),
).toThrow("Public corpus batch argument is");
});
it("runs different owners concurrently while serializing each owner", async () => {
const buildRow = (ownerHandle: string, index: number) =>
buildSeedCorpusRow(
{
kind: "skill",
slug: `${ownerHandle}-${index}`,
displayName: `${ownerHandle} ${index}`,
version: "1.0.0",
skillMd: "# Fixture",
},
{
handle: ownerHandle,
displayName: ownerHandle,
image: "https://example.invalid/avatar.png",
},
);
const batches = [
[buildRow("owner-a", 1)],
[buildRow("owner-b", 1)],
[buildRow("owner-a", 2)],
[buildRow("owner-c", 1)],
];
const activeOwners = new Set<string>();
let active = 0;
let maxActive = 0;
const runOnce = vi.fn(async (args: unknown) => {
const rows = (args as { rows: (typeof batches)[number] }).rows;
const ownerHandle = rows[0]!.dummyOwner.handle;
expect(activeOwners.has(ownerHandle)).toBe(false);
activeOwners.add(ownerHandle);
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 5));
active -= 1;
activeOwners.delete(ownerHandle);
return { status: 0, output: "" };
});
await runSeedCorpusBatches(batches, {
concurrency: 2,
runOnce,
log: vi.fn(),
});
expect(runOnce).toHaveBeenCalledTimes(4);
expect(maxActive).toBe(2);
});
it("waits for active owners and stops scheduling after a batch fails", async () => {
const buildRow = (ownerHandle: string) =>
buildSeedCorpusRow(
{
kind: "skill",
slug: ownerHandle,
displayName: ownerHandle,
version: "1.0.0",
skillMd: "# Fixture",
},
{
handle: ownerHandle,
displayName: ownerHandle,
image: "https://example.invalid/avatar.png",
},
);
const completedOwners: string[] = [];
const runOnce = vi.fn(async (args: unknown) => {
const rows = (args as { rows: SeedCorpusRow[] }).rows;
const ownerHandle = rows[0]!.dummyOwner.handle;
await new Promise((resolve) => setTimeout(resolve, ownerHandle === "owner-a" ? 5 : 20));
completedOwners.push(ownerHandle);
return ownerHandle === "owner-a"
? { status: 1, output: "failed" }
: { status: 0, output: "" };
});
await expect(
runSeedCorpusBatches([[buildRow("owner-a")], [buildRow("owner-b")], [buildRow("owner-c")]], {
concurrency: 2,
runOnce,
log: vi.fn(),
}),
).rejects.toThrow("Public corpus batch 1/3 failed");
expect(completedOwners).toEqual(["owner-a", "owner-b"]);
expect(runOnce).toHaveBeenCalledTimes(2);
});
it("strips retired capability metadata before sending rows to Convex", () => {
const owner = {
handle: "dummy-owner",
+139 -29
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bun
import { spawnSync } from "node:child_process";
import { spawn } from "node:child_process";
import { readFile } from "node:fs/promises";
import { assertPreviewSeedTargetAllowed } from "../seed";
import { buildDummyOwnerPool, ownerForCorpusKey, type DummyCorpusOwner } from "./dummyOwners";
import {
DEFAULT_PUBLIC_CORPUS_FIXTURE,
@@ -14,13 +15,18 @@ type Options = {
reset: boolean;
limit: number | null;
batchBytes: number;
concurrency: number;
previewName: string | null;
};
type SeedCorpusRow = PublicCorpusRow & {
export type SeedCorpusRow = PublicCorpusRow & {
dummyOwner: DummyCorpusOwner;
};
const DEFAULT_BATCH_BYTES = 96_000;
// Keep the encoded Convex CLI argument below Linux's per-argument exec limit.
export const DEFAULT_BATCH_BYTES = 120_000;
export const DEFAULT_SEED_CONCURRENCY = 24;
export const MAX_CONVEX_RUN_ARG_BYTES = 130_000;
const MAX_SEED_BATCH_ATTEMPTS = 4;
const BASE_SEED_BATCH_RETRY_DELAY_MS = 500;
@@ -29,10 +35,13 @@ export type SeedBatchRunResult = {
output: string;
};
export type SeedBatchRunOnce = (args: unknown) => SeedBatchRunResult;
export type SeedBatchRunOnce = (args: unknown) => SeedBatchRunResult | Promise<SeedBatchRunResult>;
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.previewName) {
assertPreviewSeedTargetAllowed();
}
const text = await readFile(options.fixture, "utf8");
const rows = parseCorpusJsonl(text).slice(0, options.limit ?? undefined);
const validation = validateCorpusRows(rows);
@@ -48,20 +57,12 @@ async function main() {
buildSeedCorpusRow(row, ownerForCorpusKey(corpusKey(row), owners)),
);
const batches = chunkRowsByOwner(seedRows, options.batchBytes);
for (let index = 0; index < batches.length; index += 1) {
const batch = batches[index]!;
const args = {
reset: options.reset && index === 0,
resetOwnerHandles: options.reset ? owners.map((owner) => owner.handle) : [],
rows: batch,
};
const status = await runConvexSeedBatchWithRetry(args);
if (status !== 0) process.exit(status);
console.log(
`Seeded public corpus batch ${index + 1}/${batches.length} (${batch.length} rows).`,
);
}
await runSeedCorpusBatches(batches, {
concurrency: options.concurrency,
reset: options.reset,
resetOwnerHandles: owners.map((owner) => owner.handle),
runOnce: (batchArgs) => runConvexSeedBatchOnce(batchArgs, options.previewName),
});
console.log(
JSON.stringify(
@@ -84,6 +85,8 @@ function parseArgs(args: string[]): Options {
reset: false,
limit: null,
batchBytes: DEFAULT_BATCH_BYTES,
concurrency: DEFAULT_SEED_CONCURRENCY,
previewName: null,
};
for (let index = 0; index < args.length; index += 1) {
@@ -96,6 +99,12 @@ function parseArgs(args: string[]): Options {
options.limit = readPositiveInt(readValue(args, ++index, arg), arg);
} else if (arg === "--batch-bytes") {
options.batchBytes = readPositiveInt(readValue(args, ++index, arg), arg);
} else if (arg === "--concurrency") {
options.concurrency = readPositiveInt(readValue(args, ++index, arg), arg);
} else if (arg === "--preview-name") {
options.previewName = readValue(args, ++index, arg);
} else if (arg.startsWith("--preview-name=")) {
options.previewName = arg.slice("--preview-name=".length);
} else {
throw new Error(`Unknown argument: ${arg}`);
}
@@ -104,7 +113,7 @@ function parseArgs(args: string[]): Options {
return options;
}
function chunkRowsByOwner(rows: SeedCorpusRow[], maxBytes: number) {
export function chunkRowsByOwner(rows: SeedCorpusRow[], maxBytes: number) {
const byOwner = new Map<string, SeedCorpusRow[]>();
for (const row of rows) {
const ownerRows = byOwner.get(row.dummyOwner.handle) ?? [];
@@ -123,7 +132,7 @@ function chunkRows(rows: SeedCorpusRow[], maxBytes: number) {
let currentBytes = 0;
for (const row of rows) {
const rowBytes = JSON.stringify(row).length + 2;
const rowBytes = Buffer.byteLength(JSON.stringify(row)) + 2;
if (current.length > 0 && currentBytes + rowBytes > maxBytes) {
batches.push(current);
current = [];
@@ -177,20 +186,53 @@ export function isRetryableConvexSeedBatchOutput(output: string) {
return output.includes("Data read or written in this mutation changed while it was being run");
}
export function runConvexSeedBatchOnce(args: unknown): SeedBatchRunResult {
const result = spawnSync(
export async function runConvexSeedBatchOnce(
args: unknown,
previewName: string | null = null,
): Promise<SeedBatchRunResult> {
const targetArgs = previewName ? ["--preview-name", previewName] : ["--no-push"];
const serializedArgs = serializeConvexSeedArgs(args);
const child = spawn(
"bunx",
["convex", "run", "--no-push", "devSeed:seedPublicCorpusBatch", JSON.stringify(args)],
["convex", "run", ...targetArgs, "devSeed:seedPublicCorpusBatch", serializedArgs],
{
cwd: process.cwd(),
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
},
);
const output = `${result.stdout ?? ""}${result.stderr ?? ""}${
result.error ? `${result.error.message}\n` : ""
}`;
let output = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
output += chunk;
});
child.stderr.on("data", (chunk: string) => {
output += chunk;
});
return await new Promise<SeedBatchRunResult>((resolve) => {
let settled = false;
const finish = (status: number, errorOutput = "") => {
if (settled) return;
settled = true;
output += errorOutput;
writeCommandOutput(output);
return { status: result.status ?? 1, output };
resolve({ status, output });
};
child.on("error", (error) => finish(1, `${error.message}\n`));
child.on("close", (status) => finish(status ?? 1));
});
}
export function serializeConvexSeedArgs(args: unknown) {
const serialized = JSON.stringify(args);
const bytes = Buffer.byteLength(serialized);
if (bytes > MAX_CONVEX_RUN_ARG_BYTES) {
throw new Error(
`Public corpus batch argument is ${bytes} bytes; maximum is ${MAX_CONVEX_RUN_ARG_BYTES}`,
);
}
return serialized;
}
export async function runConvexSeedBatchWithRetry(
@@ -210,7 +252,7 @@ export async function runConvexSeedBatchWithRetry(
const log = options.log ?? console.warn;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const result = runOnce(args);
const result = await runOnce(args);
if (result.status === 0) return 0;
if (!isRetryableConvexSeedBatchOutput(result.output) || attempt >= maxAttempts) {
return result.status;
@@ -225,6 +267,74 @@ export async function runConvexSeedBatchWithRetry(
return 1;
}
export async function runSeedCorpusBatches(
batches: SeedCorpusRow[][],
options: {
concurrency: number;
reset?: boolean;
resetOwnerHandles?: string[];
runOnce: SeedBatchRunOnce;
log?: (message: string) => void;
},
) {
const entries = batches.map((rows, index) => ({
index,
ownerHandle: rows[0]?.dummyOwner.handle ?? "",
rows,
}));
const totalBatches = entries.length;
const log = options.log ?? console.log;
const runEntry = async (entry: (typeof entries)[number], reset = false) => {
const status = await runConvexSeedBatchWithRetry(
{
reset,
resetOwnerHandles: reset ? (options.resetOwnerHandles ?? []) : [],
rows: entry.rows,
},
{ runOnce: options.runOnce },
);
if (status !== 0) {
throw new Error(`Public corpus batch ${entry.index + 1}/${totalBatches} failed`);
}
log(
`Seeded public corpus batch ${entry.index + 1}/${totalBatches} (${entry.rows.length} rows).`,
);
};
const firstEntry = options.reset ? entries.shift() : undefined;
if (firstEntry) await runEntry(firstEntry, true);
const ownerQueues = new Map<string, typeof entries>();
for (const entry of entries) {
const queue = ownerQueues.get(entry.ownerHandle) ?? [];
queue.push(entry);
ownerQueues.set(entry.ownerHandle, queue);
}
const queues = Array.from(ownerQueues.values());
let nextQueue = 0;
let firstError: unknown;
const workerCount = Math.min(options.concurrency, queues.length);
const workers = Array.from({ length: workerCount }, async () => {
while (!firstError) {
const queue = queues[nextQueue++];
if (!queue) return;
try {
for (const entry of queue) {
if (firstError) return;
await runEntry(entry);
}
} catch (error) {
firstError ??= error;
return;
}
}
});
await Promise.all(workers);
if (firstError) throw firstError;
}
function readValue(args: string[], index: number, flag: string) {
const value = args[index];
if (!value) throw new Error(`Missing value for ${flag}`);
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { assertSeedTargetAllowed, buildSeedSteps, parseSeedArgs } from "./seed";
describe("shared seed runner", () => {
it("uses the local deployment selected by the environment by default", () => {
expect(buildSeedSteps(parseSeedArgs([]))).toEqual([
{
command: "bunx",
args: ["convex", "run", "--no-push", "devSeed:seedLocalFixtures"],
},
{
command: "bun",
args: ["scripts/public-corpus/seed-public-corpus.ts"],
},
{
command: "bunx",
args: ["convex", "run", "--no-push", "statsMaintenance:updateGlobalStatsAction"],
},
]);
});
it("targets every seed step at the same named preview deployment", () => {
expect(buildSeedSteps(parseSeedArgs(["--preview-name", "feature/demo"]))).toEqual([
{
command: "bunx",
args: ["convex", "run", "--preview-name", "feature/demo", "devSeed:seedLocalFixtures"],
},
{
command: "bun",
args: ["scripts/public-corpus/seed-public-corpus.ts", "--preview-name", "feature/demo"],
},
{
command: "bunx",
args: [
"convex",
"run",
"--preview-name",
"feature/demo",
"statsMaintenance:updateGlobalStatsAction",
],
},
]);
});
it("allows only local or explicitly keyed preview targets", () => {
expect(() =>
assertSeedTargetAllowed(parseSeedArgs([]), {
CONVEX_DEPLOYMENT: "local:local-amantus-clawdhub",
}),
).not.toThrow();
expect(() =>
assertSeedTargetAllowed(parseSeedArgs(["--preview-name", "feature/demo"]), {
CONVEX_DEPLOY_KEY: "preview:openclaw:clawhub|secret",
}),
).not.toThrow();
expect(() =>
assertSeedTargetAllowed(parseSeedArgs(["--preview-name", "feature/demo"]), {
CONVEX_DEPLOY_KEY: "prod:wry-manatee-359|secret",
}),
).toThrow("requires a Convex Preview deploy key");
});
});
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bun
import { spawnSync } from "node:child_process";
type SeedOptions = {
previewName: string | null;
};
type SeedStep = {
command: string;
args: string[];
};
const CONVEX_FUNCTIONS_READY_TIMEOUT_MS = 120_000;
const REACHABILITY_POLL_MS = 500;
export function parseSeedArgs(args: string[]): SeedOptions {
const options: SeedOptions = { previewName: null };
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--preview-name") {
options.previewName = readValue(args, ++index, arg);
} else if (arg.startsWith("--preview-name=")) {
options.previewName = arg.slice("--preview-name=".length);
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
export function buildSeedSteps(options: SeedOptions): SeedStep[] {
const convexTargetArgs = options.previewName
? ["--preview-name", options.previewName]
: ["--no-push"];
const corpusTargetArgs = options.previewName ? ["--preview-name", options.previewName] : [];
return [
{
command: "bunx",
args: ["convex", "run", ...convexTargetArgs, "devSeed:seedLocalFixtures"],
},
{
command: "bun",
args: ["scripts/public-corpus/seed-public-corpus.ts", ...corpusTargetArgs],
},
{
command: "bunx",
args: ["convex", "run", ...convexTargetArgs, "statsMaintenance:updateGlobalStatsAction"],
},
];
}
export function isConvexFunctionUnavailableOutput(output: string) {
return (
output.includes("Could not find function for") &&
output.includes("Did you forget to run `npx convex dev`")
);
}
export function assertSeedTargetAllowed(
options: SeedOptions,
env: NodeJS.ProcessEnv = process.env,
) {
if (options.previewName) {
assertPreviewSeedTargetAllowed(env);
return;
}
const deployment = env.CONVEX_DEPLOYMENT?.trim();
if (
deployment === "anonymous-agent" ||
deployment?.startsWith("anonymous:") ||
deployment?.startsWith("local:")
) {
return;
}
throw new Error("Shared seed without --deployment requires a local Convex deployment");
}
export function assertPreviewSeedTargetAllowed(env: NodeJS.ProcessEnv = process.env) {
if (!env.CONVEX_DEPLOY_KEY?.trim().startsWith("preview:")) {
throw new Error("Shared preview seed requires a Convex Preview deploy key");
}
}
async function runStep(step: SeedStep) {
if (step.command !== "bunx" || step.args[0] !== "convex" || step.args[1] !== "run") {
return (
spawnSync(step.command, step.args, {
cwd: process.cwd(),
env: process.env,
stdio: "inherit",
}).status ?? 1
);
}
const startedAt = Date.now();
while (true) {
const result = spawnSync(step.command, step.args, {
cwd: process.cwd(),
encoding: "utf8",
env: process.env,
});
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
if (result.status === 0) {
if (output) process.stdout.write(output);
return 0;
}
if (
!isConvexFunctionUnavailableOutput(output) ||
Date.now() - startedAt >= CONVEX_FUNCTIONS_READY_TIMEOUT_MS
) {
if (output) process.stdout.write(output);
return result.status ?? 1;
}
console.log("Convex functions are not queryable yet; retrying...");
await sleep(REACHABILITY_POLL_MS);
}
}
function sleep(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
function readValue(args: string[], index: number, flag: string) {
const value = args[index]?.trim();
if (!value) throw new Error(`Missing value for ${flag}`);
return value;
}
async function main() {
const options = parseSeedArgs(process.argv.slice(2));
assertSeedTargetAllowed(options);
for (const step of buildSeedSteps(options)) {
const status = await runStep(step);
if (status !== 0) process.exit(status);
}
}
if (import.meta.main) {
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { resolveFrontendBuildEnv } from "./vercel-build-frontend";
describe("Vercel frontend build environment", () => {
it("derives the preview site URL from the Convex CLI injected cloud URL", () => {
const env = resolveFrontendBuildEnv({
VERCEL_ENV: "preview",
VITE_CONVEX_URL: "https://paired-preview-123.convex.cloud",
VITE_CONVEX_SITE_URL: "https://wry-manatee-359.convex.site",
});
expect(env.VITE_CONVEX_SITE_URL).toBe("https://paired-preview-123.convex.site");
expect(env.VITE_CLAWHUB_DEPLOY_ENV).toBe("preview");
});
it("preserves an explicit production site URL", () => {
const env = resolveFrontendBuildEnv({
VERCEL_ENV: "production",
VITE_CONVEX_URL: "https://wry-manatee-359.convex.cloud",
VITE_CONVEX_SITE_URL: "https://api.clawhub.example",
});
expect(env.VITE_CONVEX_SITE_URL).toBe("https://api.clawhub.example");
expect(env.VITE_CLAWHUB_DEPLOY_ENV).toBe("production");
});
});
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bun
import { spawnSync } from "node:child_process";
import { resolveConvexSiteUrl } from "../src/lib/convexDeploymentUrl";
export function resolveFrontendBuildEnv(env: NodeJS.ProcessEnv) {
const convexSiteUrl = resolveConvexSiteUrl({
CONVEX_URL: env.CONVEX_URL,
VITE_CONVEX_SITE_URL: env.VERCEL_ENV === "preview" ? undefined : env.VITE_CONVEX_SITE_URL,
VITE_CONVEX_URL: env.VITE_CONVEX_URL,
});
return {
...env,
VITE_CONVEX_SITE_URL: convexSiteUrl,
VITE_CLAWHUB_DEPLOY_ENV: env.VERCEL_ENV ?? "development",
};
}
function main() {
const result = spawnSync("bun", ["run", "build"], {
cwd: process.cwd(),
env: resolveFrontendBuildEnv(process.env),
stdio: "inherit",
});
if (result.error) throw result.error;
process.exit(result.status ?? 1);
}
if (import.meta.main) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { resolveVercelBuildPlan } from "./vercel-build";
describe("Vercel build plan", () => {
it("recreates and seeds the branch Convex deployment for previews", () => {
expect(
resolveVercelBuildPlan({
VERCEL_ENV: "preview",
VERCEL_GIT_COMMIT_REF: "pe/claw-413-pr-previews",
CONVEX_DEPLOY_KEY: "preview:openclaw:clawhub|secret",
}),
).toEqual([
{
command: "bunx",
args: [
"convex",
"deploy",
"--preview-create",
"pe/claw-413-pr-previews",
"--cmd",
"bun scripts/vercel-build-frontend.ts",
"--cmd-url-env-var-name",
"VITE_CONVEX_URL",
],
},
{
command: "bun",
args: ["run", "seed", "--", "--preview-name", "pe/claw-413-pr-previews"],
},
]);
});
it("fails closed when a preview deploy key is missing or has the wrong type", () => {
expect(() =>
resolveVercelBuildPlan({
VERCEL_ENV: "preview",
VERCEL_GIT_COMMIT_REF: "feature/demo",
}),
).toThrow("Preview builds require a Convex Preview deploy key");
expect(() =>
resolveVercelBuildPlan({
VERCEL_ENV: "preview",
VERCEL_GIT_COMMIT_REF: "feature/demo",
CONVEX_DEPLOY_KEY: "prod:wry-manatee-359|secret",
}),
).toThrow("Preview builds require a Convex Preview deploy key");
});
it("runs the ordinary frontend build for production and rejects deploy credentials", () => {
expect(resolveVercelBuildPlan({ VERCEL_ENV: "production" })).toEqual([
{
command: "bun",
args: ["scripts/vercel-build-frontend.ts"],
},
]);
expect(() =>
resolveVercelBuildPlan({
VERCEL_ENV: "production",
CONVEX_DEPLOY_KEY: "prod:wry-manatee-359|secret",
}),
).toThrow("Production Vercel builds must not receive CONVEX_DEPLOY_KEY");
});
});
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bun
import { spawnSync } from "node:child_process";
type BuildEnv = {
CONVEX_DEPLOY_KEY?: string;
VERCEL_ENV?: string;
VERCEL_GIT_COMMIT_REF?: string;
};
type BuildStep = {
command: string;
args: string[];
};
export function resolveVercelBuildPlan(env: BuildEnv): BuildStep[] {
if (env.VERCEL_ENV !== "preview") {
if (env.VERCEL_ENV === "production" && env.CONVEX_DEPLOY_KEY?.trim()) {
throw new Error("Production Vercel builds must not receive CONVEX_DEPLOY_KEY");
}
return [{ command: "bun", args: ["scripts/vercel-build-frontend.ts"] }];
}
const deployKey = env.CONVEX_DEPLOY_KEY?.trim();
if (!deployKey?.startsWith("preview:")) {
throw new Error("Preview builds require a Convex Preview deploy key");
}
const previewName = env.VERCEL_GIT_COMMIT_REF?.trim();
if (!previewName) {
throw new Error("Preview builds require VERCEL_GIT_COMMIT_REF");
}
return [
{
command: "bunx",
args: [
"convex",
"deploy",
"--preview-create",
previewName,
"--cmd",
"bun scripts/vercel-build-frontend.ts",
"--cmd-url-env-var-name",
"VITE_CONVEX_URL",
],
},
// --preview-create guarantees an empty backend, so the shared seed stays
// idempotent and avoids a destructive full-corpus reset transaction.
{
command: "bun",
args: ["run", "seed", "--", "--preview-name", previewName],
},
];
}
function main() {
for (const step of resolveVercelBuildPlan(process.env)) {
const result = spawnSync(step.command, step.args, {
cwd: process.cwd(),
env: process.env,
stdio: "inherit",
});
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
}
}
if (import.meta.main) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
+97
View File
@@ -0,0 +1,97 @@
/* @vitest-environment node */
import { mockEvent } from "h3";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildConvexProxyTarget,
isConvexProxyMethodAllowed,
proxyConvexRequest,
resolveConvexProxyEnv,
} from "./convexProxy";
describe("Convex HTTP proxy", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("maps API and hosted feed paths to the paired Convex site", () => {
const env = {
VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
};
expect(buildConvexProxyTarget("/api/v1/skills/demo?include=latest", env)).toBe(
"https://preview-branch-123.convex.site/api/v1/skills/demo?include=latest",
);
expect(buildConvexProxyTarget("/v1/feeds/plugins", env)).toBe(
"https://preview-branch-123.convex.site/api/v1/feeds/plugins",
);
});
it("allows only read methods when the frontend is a preview", () => {
const previewEnv = { VERCEL_ENV: "preview" };
expect(isConvexProxyMethodAllowed("GET", previewEnv)).toBe(true);
expect(isConvexProxyMethodAllowed("HEAD", previewEnv)).toBe(true);
expect(isConvexProxyMethodAllowed("POST", previewEnv)).toBe(false);
expect(isConvexProxyMethodAllowed("DELETE", previewEnv)).toBe(false);
expect(isConvexProxyMethodAllowed("POST", { VERCEL_ENV: "production" })).toBe(true);
});
it("prefers the build-paired Convex URL over stale Vercel runtime values", () => {
expect(
resolveConvexProxyEnv(
{
VERCEL_ENV: "preview",
VITE_CONVEX_SITE_URL: "https://wry-manatee-359.convex.site",
VITE_CONVEX_URL: "https://wry-manatee-359.convex.cloud",
},
{
VITE_CLAWHUB_DEPLOY_ENV: "preview",
VITE_CONVEX_SITE_URL: "https://paired-preview-123.convex.site",
VITE_CONVEX_URL: "https://paired-preview-123.convex.cloud",
},
),
).toEqual({
VERCEL_ENV: "preview",
VITE_CLAWHUB_DEPLOY_ENV: "preview",
VITE_CONVEX_SITE_URL: "https://paired-preview-123.convex.site",
VITE_CONVEX_URL: "https://paired-preview-123.convex.cloud",
});
});
it("proxies reads and exposes the non-secret preview deployment name for proof", async () => {
const fetchMock = vi.fn(async () => {
return new Response(JSON.stringify({ ok: true }), {
headers: { "content-type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchMock);
const event = mockEvent("https://preview.example/api/v1/skills/demo?include=latest");
const response = await proxyConvexRequest(event, {
VERCEL_ENV: "preview",
VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
});
expect(fetchMock).toHaveBeenCalledWith(
"https://preview-branch-123.convex.site/api/v1/skills/demo?include=latest",
expect.objectContaining({ method: "GET" }),
);
expect(response.headers.get("X-ClawHub-Preview-Backend")).toBe("preview-branch-123");
});
it("rejects preview writes without contacting Convex", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const event = mockEvent("https://preview.example/api/v1/skills/demo", {
method: "POST",
});
const response = await proxyConvexRequest(event, {
VERCEL_ENV: "preview",
VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
});
expect(response.status).toBe(405);
expect(fetchMock).not.toHaveBeenCalled();
});
});
+84
View File
@@ -0,0 +1,84 @@
import {
defineEventHandler,
getRequestURL,
proxyRequest,
type H3Event,
type HTTPResponse,
} from "h3";
import { convexDeploymentName, resolveConvexSiteUrl } from "../src/lib/convexDeploymentUrl";
type ProxyEnv = {
CONVEX_URL?: string;
VERCEL_ENV?: string;
VITE_CLAWHUB_DEPLOY_ENV?: string;
VITE_CONVEX_SITE_URL?: string;
VITE_CONVEX_URL?: string;
};
const BUNDLED_PROXY_ENV: ProxyEnv = {
VITE_CLAWHUB_DEPLOY_ENV: import.meta.env.VITE_CLAWHUB_DEPLOY_ENV,
VITE_CONVEX_SITE_URL: import.meta.env.VITE_CONVEX_SITE_URL,
VITE_CONVEX_URL: import.meta.env.VITE_CONVEX_URL,
};
export function resolveConvexProxyEnv(
runtimeEnv: ProxyEnv,
bundledEnv: ProxyEnv = BUNDLED_PROXY_ENV,
): ProxyEnv {
return {
...runtimeEnv,
...(bundledEnv.VITE_CLAWHUB_DEPLOY_ENV
? { VITE_CLAWHUB_DEPLOY_ENV: bundledEnv.VITE_CLAWHUB_DEPLOY_ENV }
: {}),
...(bundledEnv.VITE_CONVEX_SITE_URL
? { VITE_CONVEX_SITE_URL: bundledEnv.VITE_CONVEX_SITE_URL }
: {}),
...(bundledEnv.VITE_CONVEX_URL ? { VITE_CONVEX_URL: bundledEnv.VITE_CONVEX_URL } : {}),
};
}
function isPreviewFrontend(env: ProxyEnv) {
return env.VERCEL_ENV === "preview" || env.VITE_CLAWHUB_DEPLOY_ENV === "preview";
}
export function isConvexProxyMethodAllowed(method: string, env: ProxyEnv) {
if (!isPreviewFrontend(env)) return true;
return method === "GET" || method === "HEAD";
}
export function buildConvexProxyTarget(pathAndQuery: string, env: ProxyEnv) {
const requestUrl = new URL(pathAndQuery, "https://clawhub.invalid");
const targetPath = requestUrl.pathname.startsWith("/v1/feeds/")
? `/api${requestUrl.pathname}`
: requestUrl.pathname;
const targetUrl = new URL(targetPath, resolveConvexSiteUrl(env));
targetUrl.search = requestUrl.search;
return targetUrl.toString();
}
export async function proxyConvexRequest(
event: H3Event,
env: ProxyEnv = resolveConvexProxyEnv(process.env),
): Promise<HTTPResponse | Response> {
if (!isConvexProxyMethodAllowed(event.req.method, env)) {
return new Response("Disposable previews are read-only.", {
status: 405,
headers: {
Allow: "GET, HEAD",
"Cache-Control": "no-store",
"Content-Type": "text/plain; charset=utf-8",
},
});
}
const requestUrl = getRequestURL(event);
const target = buildConvexProxyTarget(`${requestUrl.pathname}${requestUrl.search}`, env);
const response = await proxyRequest(event, target);
if (isPreviewFrontend(env)) {
const deployment = convexDeploymentName(target);
if (deployment) response.headers.set("X-ClawHub-Preview-Backend", deployment);
}
return response;
}
export default defineEventHandler((event) => proxyConvexRequest(event));
+1
View File
@@ -0,0 +1 @@
export { default } from "../convexProxy";
+44 -7
View File
@@ -1,5 +1,5 @@
---
summary: "Maintainer deploy checklist: Convex backend, Vercel web app, CLI npm release, and /api rewrites."
summary: "Maintainer deploy checklist: Convex backend, Vercel web app and PR previews, CLI npm release, and API routing."
---
# Deploy
@@ -153,16 +153,53 @@ Deploy order:
3. wait for Vercel production deploy for the same Git SHA
4. smoke
### Disposable PR previews
Vercel Preview builds use `bun run build:vercel`. The build entrypoint requires a
Convex Preview deploy key, recreates the branch's Convex preview with
`--preview-create`, builds the frontend with that deployment's URL, and runs the
same `bun run seed` pipeline used by local development against that preview name.
One-time setup:
1. In the Convex project settings, generate a Preview deploy key.
2. In the Vercel project, set `CONVEX_DEPLOY_KEY` to that key for the **Preview**
environment only.
3. In the Convex project default environment variables for Preview deployments,
set:
- `CLAWHUB_PREVIEW=1`
- `CLAWHUB_DISABLE_CRONS=1`
4. Do not copy production auth, email, webhook, scanner, worker, backup, or
user-channel secrets into Preview defaults.
5. Remove `CONVEX_DEPLOY_KEY` from the Vercel **Production** environment if it
exists. Production Convex deploys remain manual-only through
`.github/workflows/deploy.yml`.
The shared seed command fails closed unless its target is local or an explicit
preview name selected with a Convex Preview deploy key. It installs
the committed public corpus plus the same synthetic clean, suspicious, and
malicious presentation states used locally. Staging remains snapshot-backed and
production is never seeded.
Preview browser traffic is public and read-only. Nitro rejects non-GET/HEAD
requests before proxying and adds `X-ClawHub-Preview-Backend` to proxied preview
responses so smoke proof can record the paired non-secret deployment name.
Authenticated write flows belong in the permanent test environment.
## 3) Route `/api/*` to Convex
This repo currently uses `vercel.json` rewrites:
Nitro handles `/api/**` and `/v1/feeds/**` through the environment-aware Convex
proxy in `server/convexProxy.ts`. The target comes from the build's
`VITE_CONVEX_SITE_URL`, or is derived from the paired `VITE_CONVEX_URL`. Those
build-time values are compiled into the Nitro server output so stale Vercel
runtime variables cannot redirect a Preview deployment to production.
- `source: /api/:path*`
- `destination: https://<deployment>.convex.site/api/:path*`
Do not add a production deployment hostname back to `vercel.json`. Static
rewrites would make Vercel previews query production even when their Convex
client points at a disposable backend.
For self-host:
- update `vercel.json` to your deployment's Convex site URL.
For self-hosting, set `VITE_CONVEX_URL` and optionally
`VITE_CONVEX_SITE_URL` to the intended deployment before building.
## 4) Registry discovery
+10 -3
View File
@@ -18,8 +18,15 @@ Local fixture seeding is command-driven by default:
the documented first-run local setup path.
- CLI seeding (`bun run seed:dev`) runs the same seed path manually without starting the preview and
bypasses the first-run sentinel.
- `bun run seed` is the shared seed pipeline used after local setup and by disposable PR previews.
It installs the same moderation fixtures and committed public corpus, then refreshes global stats.
Without `--preview-name` it accepts only a local Convex deployment; remote use requires an
explicit preview name plus a Convex Preview deploy key. Vercel recreates that preview deployment
before invoking the shared seed, so the corpus import does not perform a destructive reset.
- `bun run seed:public-corpus` is the lower-level corpus-only import command. Use it for corpus
fixture work, not as the default local setup command.
fixture work, not as the default local setup command. The importer keeps each dummy owner's
batches serialized while running different owners concurrently, so owner creation remains
deterministic without paying one network round trip per corpus row.
- `bun run validate:public-corpus` validates the committed public corpus fixture without seeding.
- `internal.devSeed.seedCurrentUserFixtures` remains a dev-only internal action for explicit local
development tools/tests that need fixtures cloned to a local user.
@@ -34,5 +41,5 @@ should not be exposed as a first-run dashboard button unless the UX and ownershi
intentionally revisited.
Without `OPENAI_API_KEY`, public corpus import may use zero vectors. That is
acceptable for local setup and layout QA, but semantic search quality will be weaker than an
embedding-backed local database.
acceptable for local setup, disposable PR previews, and layout QA, but semantic search quality will
be weaker than an embedding-backed database.
+5 -5
View File
@@ -124,11 +124,11 @@ provides:
- `Surrogate-Control: max-age=300, stale-while-revalidate=86400`
- `304 Not Modified` for matching `If-None-Match` or `If-Modified-Since`
`vercel.json` exposes `/v1/feeds/plugins`, `/v1/feeds/skills`, and
`/v1/feeds/promotions` as edge-friendly rewrites to the Convex endpoints. The
unversioned `/feeds/*` paths permanently redirect to their versioned paths. The
`registry.openclaw.ai` custom domain must point at the same Vercel project
before the public RFC URLs are enabled.
Nitro exposes `/v1/feeds/plugins`, `/v1/feeds/skills`, and
`/v1/feeds/promotions` through the same environment-aware Convex proxy used for
`/api/*`. The unversioned `/feeds/*` paths permanently redirect to their
versioned paths. The `registry.openclaw.ai` custom domain must point at the same
Vercel project before the public RFC URLs are enabled.
The serialized payload uses stable object-key ordering and deterministic entry
and install-candidate ordering. Additive fields may be introduced within a
@@ -0,0 +1,20 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
describe("Vercel preview configuration", () => {
it("uses the environment-aware build entrypoint without production-pinned rewrites", async () => {
const configText = await readFile("vercel.json", "utf8");
const config = JSON.parse(configText) as {
buildCommand?: string;
rewrites?: unknown[];
};
const packageJson = JSON.parse(await readFile("package.json", "utf8")) as {
scripts?: Record<string, string>;
};
expect(config.buildCommand).toBe("bun run build:vercel");
expect(config.rewrites).toBeUndefined();
expect(configText).not.toContain("wry-manatee-359");
expect(packageJson.scripts?.["build:vercel"]).toBe("bun scripts/vercel-build.ts");
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { resolveConvexSiteUrl } from "./convexDeploymentUrl";
describe("resolveConvexSiteUrl", () => {
it("derives the paired HTTP Actions origin from a Convex cloud URL", () => {
expect(
resolveConvexSiteUrl({
VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
}),
).toBe("https://preview-branch-123.convex.site");
});
it("prefers an explicit site URL for custom domains", () => {
expect(
resolveConvexSiteUrl({
VITE_CONVEX_SITE_URL: "https://api.preview.example/",
VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
}),
).toBe("https://api.preview.example");
});
it("rejects malformed or non-Convex cloud URLs instead of guessing", () => {
expect(() => resolveConvexSiteUrl({ VITE_CONVEX_URL: "https://example.com" })).toThrow(
"Cannot derive a Convex site URL",
);
expect(() => resolveConvexSiteUrl({ VITE_CONVEX_URL: "not-a-url" })).toThrow(
"Cannot derive a Convex site URL",
);
});
});
+41
View File
@@ -0,0 +1,41 @@
type ConvexUrlEnv = {
CONVEX_URL?: string;
VITE_CONVEX_SITE_URL?: string;
VITE_CONVEX_URL?: string;
};
function parseAbsoluteUrl(value: string | undefined) {
const trimmed = value?.trim();
if (!trimmed) return null;
try {
return new URL(trimmed);
} catch {
return null;
}
}
export function resolveConvexSiteUrl(env: ConvexUrlEnv) {
const explicitSiteUrl = parseAbsoluteUrl(env.VITE_CONVEX_SITE_URL);
if (explicitSiteUrl) return explicitSiteUrl.origin;
const cloudUrl = parseAbsoluteUrl(env.VITE_CONVEX_URL ?? env.CONVEX_URL);
if (cloudUrl?.protocol === "https:" && cloudUrl.hostname.endsWith(".convex.cloud")) {
cloudUrl.hostname = `${cloudUrl.hostname.slice(0, -".convex.cloud".length)}.convex.site`;
return cloudUrl.origin;
}
if (
cloudUrl?.protocol === "http:" &&
["localhost", "127.0.0.1", "[::1]"].includes(cloudUrl.hostname)
) {
return cloudUrl.origin;
}
throw new Error("Cannot derive a Convex site URL from VITE_CONVEX_URL");
}
export function convexDeploymentName(url: string) {
const parsed = new URL(url);
if (!parsed.hostname.endsWith(".convex.site")) return null;
return parsed.hostname.slice(0, -".convex.site".length);
}
+2 -2
View File
@@ -20,8 +20,8 @@ function resolveAbsoluteBaseUrl(...candidates: Array<string | undefined>) {
export function publicApiUrl(path: string) {
const normalizedPath = normalizeApiPath(path);
if (typeof window !== "undefined") {
// In production, Vercel rewrites /api/* to the Convex site. Local Nitro
// intercepts those paths, so local browsers use the Convex site directly.
// Hosted browsers use the same-origin Nitro proxy. Local browsers use the
// Convex site directly so anonymous local backends do not need edge routing.
const convexClientBaseUrl = resolveAbsoluteBaseUrl(
getRuntimeEnv("VITE_CONVEX_SITE_URL"),
getRuntimeEnv("VITE_CONVEX_URL"),
+14
View File
@@ -25,6 +25,20 @@ describe("runtimeEnv", () => {
import.meta.env.VITE_SITE_URL = originalClientValue;
});
it("prefers the bundled Convex URL for preview SSR", () => {
const originalDeployEnv = import.meta.env.VITE_CLAWHUB_DEPLOY_ENV;
const originalConvexUrl = import.meta.env.VITE_CONVEX_URL;
vi.stubEnv("VITE_CLAWHUB_DEPLOY_ENV", "production");
vi.stubEnv("VITE_CONVEX_URL", "https://wry-manatee-359.convex.cloud");
import.meta.env.VITE_CLAWHUB_DEPLOY_ENV = "preview";
import.meta.env.VITE_CONVEX_URL = "https://paired-preview-123.convex.cloud";
expect(getRuntimeEnv("VITE_CONVEX_URL")).toBe("https://paired-preview-123.convex.cloud");
import.meta.env.VITE_CLAWHUB_DEPLOY_ENV = originalDeployEnv;
import.meta.env.VITE_CONVEX_URL = originalConvexUrl;
});
it("throws for missing required env", () => {
expect(() => getRequiredRuntimeEnv("VITE_MISSING_VALUE")).toThrow(
"Missing required environment variable: VITE_MISSING_VALUE",
+8 -5
View File
@@ -9,16 +9,19 @@ function readProcessEnv(name: string) {
return readString(process.env?.[name]);
}
function readClientMetaEnv(name: string) {
if (typeof window === "undefined") return undefined;
function readMetaEnv(name: string) {
return readString((import.meta.env as Record<string, unknown>)[name]);
}
export function getRuntimeEnv(name: string) {
if (typeof window !== "undefined") {
return readClientMetaEnv(name) ?? readProcessEnv(name);
const bundledValue = readMetaEnv(name);
const preferBundledValue =
typeof window !== "undefined" ||
(name.startsWith("VITE_") && readMetaEnv("VITE_CLAWHUB_DEPLOY_ENV") === "preview");
if (preferBundledValue) {
return bundledValue ?? readProcessEnv(name);
}
return readProcessEnv(name) ?? readClientMetaEnv(name);
return readProcessEnv(name) ?? bundledValue;
}
export function getRequiredRuntimeEnv(name: string) {
+1 -18
View File
@@ -1,4 +1,5 @@
{
"buildCommand": "bun run build:vercel",
"headers": [
{
"source": "/(.*)",
@@ -57,24 +58,6 @@
"statusCode": 302
}
],
"rewrites": [
{
"source": "/api/:path*",
"destination": "https://wry-manatee-359.convex.site/api/:path*"
},
{
"source": "/v1/feeds/plugins",
"destination": "https://wry-manatee-359.convex.site/api/v1/feeds/plugins"
},
{
"source": "/v1/feeds/skills",
"destination": "https://wry-manatee-359.convex.site/api/v1/feeds/skills"
},
{
"source": "/v1/feeds/promotions",
"destination": "https://wry-manatee-359.convex.site/api/v1/feeds/promotions"
}
],
"images": {
"sizes": [256, 640, 1024, 1920],
"formats": ["image/webp"],
+4
View File
@@ -201,6 +201,10 @@ const config = defineConfig({
devtools(),
nitro({
serverDir: "server",
handlers: [
{ route: "/api/**", handler: "./server/handlers/convexProxy.ts" },
{ route: "/v1/feeds/**", handler: "./server/handlers/convexProxy.ts" },
],
rollupConfig: {
onwarn: handleRollupWarning,
},