mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
@@ -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,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.
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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", {
|
||||
|
||||
+98
-52
@@ -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,63 +957,70 @@ export const backfillExistingPublicCorpusBatchRows = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
async function seedPublicCorpusBatchHandler(
|
||||
ctx: ActionCtx,
|
||||
args: {
|
||||
reset?: boolean;
|
||||
resetOwnerHandles?: string[];
|
||||
rows: PublicCorpusSeedRow[];
|
||||
},
|
||||
): Promise<PublicCorpusSeedBatchHandlerResult> {
|
||||
const existingResult: PublicCorpusExistingRowsResult | null = args.reset
|
||||
? null
|
||||
: await ctx.runMutation(internal.devSeed.backfillExistingPublicCorpusBatchRows, {
|
||||
rows: args.rows,
|
||||
});
|
||||
const missingKeys = new Set(existingResult?.missingKeys ?? []);
|
||||
const rowsToPrepare = args.reset
|
||||
? args.rows
|
||||
: args.rows.filter((row) => missingKeys.has(publicCorpusSeedRowKey(row)));
|
||||
if (!args.reset && rowsToPrepare.length === 0) {
|
||||
return { ok: true as const, seeded: [], skipped: existingResult?.skipped ?? [] };
|
||||
}
|
||||
|
||||
const preparedRows = await Promise.all(
|
||||
rowsToPrepare.map(async (row) => {
|
||||
if (row.kind === "skill") {
|
||||
const storageId = await ctx.storage.store(
|
||||
new Blob([row.skillMd], { type: "text/markdown" }),
|
||||
);
|
||||
const frontmatter = parseFrontmatter(row.skillMd);
|
||||
const embeddingText = buildEmbeddingText({
|
||||
frontmatter,
|
||||
readme: row.skillMd,
|
||||
otherFiles: [],
|
||||
});
|
||||
const embedding = await generateEmbedding(embeddingText);
|
||||
return { ...row, storageId, embedding };
|
||||
}
|
||||
const storageId = await ctx.storage.store(new Blob([row.readme], { type: "text/markdown" }));
|
||||
return { ...row, storageId };
|
||||
}),
|
||||
);
|
||||
|
||||
const seedResult: PublicCorpusSeedBatchResult = await ctx.runMutation(
|
||||
internal.devSeed.seedPublicCorpusBatchMutation,
|
||||
{
|
||||
reset: args.reset,
|
||||
resetOwnerHandles: args.resetOwnerHandles,
|
||||
rows: preparedRows,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
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: async (ctx, args) => {
|
||||
const existingResult: PublicCorpusExistingRowsResult | null = args.reset
|
||||
? null
|
||||
: await ctx.runMutation(internal.devSeed.backfillExistingPublicCorpusBatchRows, {
|
||||
rows: args.rows,
|
||||
});
|
||||
const missingKeys = new Set(existingResult?.missingKeys ?? []);
|
||||
const rowsToPrepare = args.reset
|
||||
? args.rows
|
||||
: args.rows.filter((row) => missingKeys.has(publicCorpusSeedRowKey(row)));
|
||||
if (!args.reset && rowsToPrepare.length === 0) {
|
||||
return { ok: true, seeded: [], skipped: existingResult?.skipped ?? [] };
|
||||
}
|
||||
|
||||
const preparedRows = await Promise.all(
|
||||
rowsToPrepare.map(async (row) => {
|
||||
if (row.kind === "skill") {
|
||||
const storageId = await ctx.storage.store(
|
||||
new Blob([row.skillMd], { type: "text/markdown" }),
|
||||
);
|
||||
const frontmatter = parseFrontmatter(row.skillMd);
|
||||
const embeddingText = buildEmbeddingText({
|
||||
frontmatter,
|
||||
readme: row.skillMd,
|
||||
otherFiles: [],
|
||||
});
|
||||
const embedding = await generateEmbedding(embeddingText);
|
||||
return { ...row, storageId, embedding };
|
||||
}
|
||||
const storageId = await ctx.storage.store(
|
||||
new Blob([row.readme], { type: "text/markdown" }),
|
||||
);
|
||||
return { ...row, storageId };
|
||||
}),
|
||||
);
|
||||
|
||||
const seedResult: PublicCorpusSeedBatchResult = await ctx.runMutation(
|
||||
internal.devSeed.seedPublicCorpusBatchMutation,
|
||||
{
|
||||
reset: args.reset,
|
||||
resetOwnerHandles: args.resetOwnerHandles,
|
||||
rows: preparedRows,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
seeded: seedResult.seeded,
|
||||
skipped: [...(existingResult?.skipped ?? []), ...seedResult.skipped],
|
||||
};
|
||||
},
|
||||
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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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` : ""
|
||||
}`;
|
||||
writeCommandOutput(output);
|
||||
return { status: result.status ?? 1, output };
|
||||
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);
|
||||
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}`);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../convexProxy";
|
||||
+44
-7
@@ -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
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user