Skip to content

Commit b331912

Browse files
dcramercodex
andauthored
fix(vercel): Add queue consumer source (#501)
Add a concrete Vercel queue consumer source for durable conversation work so Vercel's `functions` entry matches an actual root `api/` file during deployment validation. The source delegates to `server.ts`, keeping queue delivery on the same Junior app wiring as the rest of the runtime. **Scaffold Contract** `junior init` now writes the queue consumer source alongside `vercel.json`, and the example app has a test that the trigger points to a concrete file and routes through the app. The deploy guide now calls out both the heartbeat cron and queue trigger entries. Validated with targeted Junior tests, docs checks, the example Nitro build with snapshot warmup skipped, and the example app typecheck. Co-authored-by: GPT-5 Codex <codex@openai.com>
1 parent 2e5eae3 commit b331912

7 files changed

Lines changed: 82 additions & 5 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import app from "../../../server.ts";
2+
3+
export const POST = (request: Request) => app.fetch(request);

packages/docs/src/content/docs/start-here/deploy-to-vercel.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ The scaffolded `package.json` includes the production build script:
4141

4242
Keep the Vercel build command as `pnpm build`. `junior snapshot create` prepares sandbox runtime dependencies declared by enabled plugins before request handling starts.
4343

44-
## Enable the heartbeat cron
44+
## Keep Vercel runtime entries
4545

46-
Junior uses a one-minute internal heartbeat to run trusted plugin heartbeats and recover stale agent dispatches. The scheduler plugin uses this heartbeat when scheduled tasks are enabled. The scaffolded `vercel.json` should include this cron:
46+
Junior uses a one-minute internal heartbeat to run trusted plugin heartbeats and recover stale agent dispatches. The scheduler plugin uses this heartbeat when scheduled tasks are enabled. The scaffolded `vercel.json` should include these runtime entries:
4747

4848
```json title="vercel.json"
4949
{
@@ -54,11 +54,24 @@ Junior uses a one-minute internal heartbeat to run trusted plugin heartbeats and
5454
"path": "/api/internal/heartbeat",
5555
"schedule": "* * * * *"
5656
}
57-
]
57+
],
58+
"functions": {
59+
"api/internal/agent/continue.ts": {
60+
"maxDuration": 300,
61+
"experimentalTriggers": [
62+
{
63+
"type": "queue/v2beta",
64+
"topic": "junior_conversation_work"
65+
}
66+
]
67+
}
68+
}
5869
}
5970
```
6071

61-
If you maintain `vercel.json` manually, keep the `/api/internal/heartbeat` cron entry. The endpoint returns `401` unless the incoming Vercel Cron request has a bearer token that matches `CRON_SECRET`.
72+
If you maintain `vercel.json` manually, keep the `/api/internal/heartbeat` cron entry and the queue trigger for `api/internal/agent/continue.ts`. The scaffolded `api/internal/agent/continue.ts` file delegates queue delivery to `server.ts`, and Vercel requires the `functions` key to match a concrete source file.
73+
74+
The heartbeat endpoint returns `401` unless the incoming Vercel Cron request has a bearer token that matches `CRON_SECRET`.
6275

6376
## Configure production environment
6477

packages/docs/src/content/docs/start-here/quickstart.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ cd my-bot
3232
pnpm install
3333
```
3434

35-
`junior init` creates the app entrypoint, Nitro/Vite config, Vercel config, CI workflow, app context files, local plugin and skill directories, and `.env.example`.
35+
`junior init` creates the app entrypoint, Nitro/Vite config, Vercel config, Vercel queue consumer source, CI workflow, app context files, local plugin and skill directories, and `.env.example`.
3636

3737
The generated `app/` files have separate jobs:
3838

packages/junior/src/cli/init.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@ export default app;
1717
);
1818
}
1919

20+
function writeQueueConsumerEntry(targetDir: string): void {
21+
const queueConsumerDir = path.join(targetDir, "api", "internal", "agent");
22+
fs.mkdirSync(queueConsumerDir, { recursive: true });
23+
fs.writeFileSync(
24+
path.join(queueConsumerDir, "continue.ts"),
25+
`import app from "../../../server.ts";
26+
27+
export const POST = (request: Request) => app.fetch(request);
28+
`,
29+
);
30+
}
31+
2032
function writeNitroConfig(targetDir: string): void {
2133
fs.writeFileSync(
2234
path.join(targetDir, "nitro.config.ts"),
@@ -188,6 +200,7 @@ SENTRY_ORG_SLUG=
188200
);
189201

190202
writeServerEntry(target);
203+
writeQueueConsumerEntry(target);
191204
writeNitroConfig(target);
192205
writeViteConfig(target);
193206
writeVercelJson(target);

packages/junior/tests/integration/example-build-discovery.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ const originalCwd = process.cwd();
1010
const repoRoot = path.resolve(import.meta.dirname, "../../../..");
1111
const exampleRoot = path.join(repoRoot, "apps/example");
1212
const exampleEntry = path.join(exampleRoot, "server.ts");
13+
const exampleQueueConsumerEntry = path.join(
14+
exampleRoot,
15+
"api/internal/agent/continue.ts",
16+
);
1317
const examplePluginsModule = path.join(exampleRoot, "plugins.ts");
1418
const exampleDashboardConfig = path.join(exampleRoot, "dashboard.ts");
1519
const exampleRequire = createRequire(exampleEntry);
@@ -82,6 +86,13 @@ async function importExampleApp() {
8286
};
8387
}
8488

89+
async function importExampleQueueConsumer() {
90+
const href = `${pathToFileURL(exampleQueueConsumerEntry).href}?t=${Date.now()}`;
91+
return (await import(href)) as {
92+
POST: (request: Request) => Promise<Response>;
93+
};
94+
}
95+
8596
async function importExampleDashboardConfig() {
8697
const href = `${pathToFileURL(exampleDashboardConfig).href}?t=${Date.now()}`;
8798
return (await import(href)) as {
@@ -152,6 +163,27 @@ describe.sequential("example build discovery integration", () => {
152163
expect(await oauth.text()).toContain("missing required parameters");
153164
}, 15_000);
154165

166+
it("routes the concrete Vercel queue consumer source through the app", async () => {
167+
process.chdir(exampleRoot);
168+
process.env.JUNIOR_PLUGIN_PACKAGES = JSON.stringify(
169+
await getExamplePluginPackages(),
170+
);
171+
172+
const { POST } = await importExampleQueueConsumer();
173+
const response = await POST(
174+
new Request("http://localhost/api/internal/agent/continue", {
175+
method: "POST",
176+
body: "{}",
177+
headers: {
178+
"content-type": "application/json",
179+
},
180+
}),
181+
);
182+
183+
expect(response.status).toBe(400);
184+
expect(await response.text()).toContain("Invalid content type");
185+
}, 15_000);
186+
155187
it("does not expose discovery state from the public example app", async () => {
156188
const packageNames = await getExamplePluginPackages();
157189
process.chdir(exampleRoot);

packages/junior/tests/unit/cli/init-cli.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ describe("init cli", () => {
2626

2727
expect(fs.existsSync(path.join(target, "package.json"))).toBe(true);
2828
expect(fs.existsSync(path.join(target, "server.ts"))).toBe(true);
29+
expect(
30+
fs.existsSync(
31+
path.join(target, "api", "internal", "agent", "continue.ts"),
32+
),
33+
).toBe(true);
2934
expect(fs.existsSync(path.join(target, "vercel.json"))).toBe(true);
3035
expect(fs.existsSync(path.join(target, "nitro.config.ts"))).toBe(true);
3136
expect(fs.existsSync(path.join(target, "vite.config.ts"))).toBe(true);

packages/junior/tests/unit/vercel.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ describe("juniorVercelConfig", () => {
5050

5151
expect(config).toEqual(juniorVercelConfig());
5252
});
53+
54+
it("keeps the example queue trigger pointed at a concrete function source", () => {
55+
const config = juniorVercelConfig();
56+
const functionSources = Object.keys(config.functions as object);
57+
58+
for (const source of functionSources) {
59+
expect(
60+
fs.existsSync(path.join(WORKSPACE_ROOT, "apps/example", source)),
61+
).toBe(true);
62+
}
63+
});
5364
});
5465

5566
describe("resolveConversationWorkVisibilityTimeoutSeconds", () => {

0 commit comments

Comments
 (0)