Commitebbb527unknown_key
feat(claude-deploy): paste loop → hosted endpoint with budget meter at /connect/claude/deploy
4 files changed+1712−0ebbb52704b310525d6669965a08d88672d1dcb68
4 changed files+1712−0
Addedsrc/__tests__/hosted-claude-loop.test.ts+337−0View fileUnifiedSplit
@@ -0,0 +1,337 @@
1/**
2 * Hosted Claude loops — migration 0069 + src/lib/hosted-claude-loop.ts.
3 *
4 * Two layers:
5 *
6 * 1. Pure helpers (slugify, endpoint path build, usage extraction) —
7 * no DB or AI key required, always run.
8 *
9 * 2. DB-backed flows (createLoop, invokeLoop via the executor seam,
10 * budget cap enforcement) — gated on HAS_DB. The executor seam
11 * avoids needing ANTHROPIC_API_KEY in CI, so HAS_AI just adds a
12 * smoke check that the default template parses as JS.
13 */
14
15import { beforeAll, afterAll, describe, expect, it } from "bun:test";
16import { eq } from "drizzle-orm";
17import { randomBytes, createHash } from "crypto";
18
19import {
20 DEFAULT_LOOP_TEMPLATE,
21 __setExecutorForTests,
22 buildEndpointPath,
23 capStream,
24 createLoop,
25 deleteLoop,
26 extractUsageFromStdout,
27 getLoop,
28 invokeLoop,
29 listLoopsForOwner,
30 listRunsForLoop,
31 pauseLoop,
32 resumeLoop,
33 slugifyLoopName,
34} from "../lib/hosted-claude-loop";
35
36const HAS_DB = Boolean(process.env.DATABASE_URL);
37const HAS_AI = Boolean(process.env.ANTHROPIC_API_KEY);
38
39// Seed a unique test user per run when DB is available.
40async function seedTestUser(): Promise<string | null> {
41 if (!HAS_DB) return null;
42 const { db } = await import("../db");
43 const { users } = await import("../db/schema");
44 const username = `cldploy-${randomBytes(4).toString("hex")}`;
45 try {
46 const [row] = await db
47 .insert(users)
48 .values({
49 username,
50 email: `${username}@test.local`,
51 passwordHash: "x",
52 })
53 .returning();
54 return row?.id ?? null;
55 } catch {
56 return null;
57 }
58}
59
60async function cleanupTestUser(userId: string | null) {
61 if (!userId || !HAS_DB) return;
62 try {
63 const { db } = await import("../db");
64 const { users } = await import("../db/schema");
65 await db.delete(users).where(eq(users.id, userId));
66 } catch {
67 /* best-effort */
68 }
69}
70
71let testUserId: string | null = null;
72
73beforeAll(async () => {
74 testUserId = await seedTestUser();
75});
76
77afterAll(async () => {
78 __setExecutorForTests(null);
79 await cleanupTestUser(testUserId);
80});
81
82// ---------------------------------------------------------------------------
83// Pure helpers
84// ---------------------------------------------------------------------------
85
86describe("hosted-claude-loop — pure helpers", () => {
87 it("slugifyLoopName lowercases + dasherises + trims to 40 chars", () => {
88 expect(slugifyLoopName("Hello World!")).toBe("hello-world");
89 expect(slugifyLoopName(" Foo BAR ")).toBe("foo-bar");
90 expect(slugifyLoopName("a".repeat(60)).length).toBe(40);
91 expect(slugifyLoopName("")).toBe("");
92 expect(slugifyLoopName("!!!---")).toBe("");
93 });
94
95 it("buildEndpointPath always begins with /claude-loops/ and has a suffix", () => {
96 const a = buildEndpointPath("my-loop");
97 const b = buildEndpointPath("my-loop");
98 expect(a.startsWith("/claude-loops/")).toBe(true);
99 expect(b.startsWith("/claude-loops/")).toBe(true);
100 // Suffix randomness should virtually never collide.
101 expect(a).not.toBe(b);
102 // Empty name falls back to `loop` prefix.
103 expect(buildEndpointPath("").startsWith("/claude-loops/loop-")).toBe(true);
104 });
105
106 it("capStream truncates at the cap and tags it", () => {
107 const big = "x".repeat(50_000);
108 const out = capStream(big, 100);
109 expect(out.length).toBeLessThanOrEqual(200);
110 expect(out.endsWith("[truncated]")).toBe(true);
111 expect(capStream("short", 1000)).toBe("short");
112 expect(capStream("", 100)).toBe("");
113 });
114
115 it("extractUsageFromStdout pulls input/output tokens from JSON stdout", () => {
116 const stdout = JSON.stringify({
117 ok: true,
118 model: "claude-haiku-4-5",
119 usage: { input_tokens: 120, output_tokens: 80 },
120 });
121 const got = extractUsageFromStdout(stdout);
122 expect(got.inputTokens).toBe(120);
123 expect(got.outputTokens).toBe(80);
124 expect(got.model).toBe("claude-haiku-4-5");
125 });
126
127 it("extractUsageFromStdout returns zeros for non-JSON or no usage block", () => {
128 expect(extractUsageFromStdout("plain text output")).toEqual({
129 inputTokens: 0,
130 outputTokens: 0,
131 model: null,
132 });
133 expect(extractUsageFromStdout("")).toEqual({
134 inputTokens: 0,
135 outputTokens: 0,
136 model: null,
137 });
138 expect(
139 extractUsageFromStdout(JSON.stringify({ no_usage_here: true }))
140 ).toEqual({ inputTokens: 0, outputTokens: 0, model: null });
141 });
142
143 it("extractUsageFromStdout scans line-by-line for embedded JSON lines", () => {
144 const stdout =
145 "preamble log\n" +
146 JSON.stringify({ usage: { input_tokens: 5, output_tokens: 3 } }) +
147 "\nepilogue text";
148 const got = extractUsageFromStdout(stdout);
149 expect(got.inputTokens).toBe(5);
150 expect(got.outputTokens).toBe(3);
151 });
152
153 it("DEFAULT_LOOP_TEMPLATE references the SDK + prints JSON usage", () => {
154 expect(DEFAULT_LOOP_TEMPLATE).toContain("@anthropic-ai/sdk");
155 expect(DEFAULT_LOOP_TEMPLATE).toContain("usage");
156 expect(DEFAULT_LOOP_TEMPLATE).toContain("process.env.INPUT");
157 });
158});
159
160// ---------------------------------------------------------------------------
161// DB-backed flows
162// ---------------------------------------------------------------------------
163
164describe.skipIf(!HAS_DB)("hosted-claude-loop — DB flows", () => {
165 it("createLoop persists a row + mints an agent token", async () => {
166 if (!testUserId) return;
167 const r = await createLoop({
168 ownerUserId: testUserId,
169 name: "test-loop-create",
170 sourceCode: "console.log(JSON.stringify({ok:true}));",
171 monthlyBudgetCents: 500,
172 });
173 expect(r).not.toBeNull();
174 expect(r!.loop.name).toBe("test-loop-create");
175 expect(r!.loop.endpointPath.startsWith("/claude-loops/")).toBe(true);
176 expect(r!.loop.status).toBe("paused");
177 expect(r!.loop.monthlyBudgetCents).toBe(500);
178 // Agent token is plaintext, shown once.
179 if (r!.agentToken) {
180 expect(/^agt_[0-9a-f]{64}$/.test(r!.agentToken)).toBe(true);
181 }
182 const back = await getLoop(r!.loop.id);
183 expect(back?.id).toBe(r!.loop.id);
184 await deleteLoop(r!.loop.id, testUserId);
185 });
186
187 it("listLoopsForOwner only returns loops owned by the user", async () => {
188 if (!testUserId) return;
189 const otherUserId = await seedTestUser();
190 try {
191 const mine = await createLoop({
192 ownerUserId: testUserId,
193 name: "mine-list-test",
194 sourceCode: "console.log('hi');",
195 });
196 const theirs = otherUserId
197 ? await createLoop({
198 ownerUserId: otherUserId,
199 name: "theirs-list-test",
200 sourceCode: "console.log('hi');",
201 })
202 : null;
203 const mineList = await listLoopsForOwner(testUserId);
204 expect(mineList.some((l) => l.id === mine?.loop.id)).toBe(true);
205 expect(mineList.some((l) => l.id === theirs?.loop.id)).toBe(false);
206 if (mine) await deleteLoop(mine.loop.id, testUserId);
207 if (theirs && otherUserId) await deleteLoop(theirs.loop.id, otherUserId);
208 } finally {
209 await cleanupTestUser(otherUserId);
210 }
211 });
212
213 it("invokeLoop runs the snippet via the executor seam + records a run", async () => {
214 if (!testUserId) return;
215 const created = await createLoop({
216 ownerUserId: testUserId,
217 name: "test-loop-invoke",
218 sourceCode: "console.log('seeded');",
219 monthlyBudgetCents: 5000,
220 });
221 expect(created).not.toBeNull();
222
223 // Resume so the owner-side invoke runs without an extra hop.
224 await resumeLoop(created!.loop.id, testUserId);
225
226 __setExecutorForTests(async (args) => ({
227 stdout: JSON.stringify({
228 echo: args.inputPayload,
229 usage: { input_tokens: 100, output_tokens: 50 },
230 model: "claude-haiku-4-5",
231 }),
232 stderr: "",
233 exitCode: 0,
234 durationMs: 5,
235 timedOut: false,
236 }));
237 const result = await invokeLoop({
238 loopId: created!.loop.id,
239 inputPayload: { repo: "demo" },
240 });
241 expect(result.status).toBe("ok");
242 expect(result.centsCharged).toBeGreaterThan(0);
243 expect(result.run).not.toBeNull();
244 expect(result.run?.status).toBe("ok");
245 expect(result.run?.claudeInputTokens).toBe(100);
246 expect(result.run?.claudeOutputTokens).toBe(50);
247 // Output parsed back from the stdout JSON.
248 expect((result.output as { echo: { repo: string } }).echo.repo).toBe("demo");
249
250 const runs = await listRunsForLoop(created!.loop.id, 10);
251 expect(runs.length).toBeGreaterThanOrEqual(1);
252
253 // Totals on the loop row should have bumped.
254 const after = await getLoop(created!.loop.id);
255 expect(after?.totalInvocations).toBeGreaterThan(0);
256 expect(after?.totalCentsSpent).toBeGreaterThan(0);
257 expect(after?.lastRunAt).not.toBeNull();
258
259 __setExecutorForTests(null);
260 await deleteLoop(created!.loop.id, testUserId);
261 });
262
263 it("invokeLoop returns budget_exceeded once the cap is hit", async () => {
264 if (!testUserId) return;
265 const created = await createLoop({
266 ownerUserId: testUserId,
267 name: "test-loop-budget",
268 sourceCode: "console.log('seeded');",
269 // 1¢ budget — first invocation should land at the cap.
270 monthlyBudgetCents: 1,
271 });
272 expect(created).not.toBeNull();
273 await resumeLoop(created!.loop.id, testUserId);
274
275 // Stub an executor that reports 10k input + 10k output tokens so
276 // computeCentsForCall lands well over the 1¢ cap.
277 __setExecutorForTests(async () => ({
278 stdout: JSON.stringify({
279 usage: { input_tokens: 10_000, output_tokens: 10_000 },
280 model: "claude-haiku-4-5",
281 }),
282 stderr: "",
283 exitCode: 0,
284 durationMs: 5,
285 timedOut: false,
286 }));
287
288 // First call lands OK (no spend yet → cap not exceeded at the
289 // pre-flight check) and bumps the spend counter over cap.
290 const first = await invokeLoop({
291 loopId: created!.loop.id,
292 inputPayload: {},
293 });
294 expect(["ok", "budget_exceeded"].includes(first.status)).toBe(true);
295
296 // Second call MUST short-circuit on budget.
297 const second = await invokeLoop({
298 loopId: created!.loop.id,
299 inputPayload: {},
300 });
301 expect(second.status).toBe("budget_exceeded");
302 expect(second.centsCharged).toBe(0);
303
304 __setExecutorForTests(null);
305 await deleteLoop(created!.loop.id, testUserId);
306 });
307
308 it("pauseLoop + invokeLoop with isPublicInvocation=true returns disabled", async () => {
309 if (!testUserId) return;
310 const created = await createLoop({
311 ownerUserId: testUserId,
312 name: "test-loop-paused",
313 sourceCode: "console.log('seeded');",
314 });
315 expect(created).not.toBeNull();
316 await pauseLoop(created!.loop.id, testUserId);
317 const r = await invokeLoop({
318 loopId: created!.loop.id,
319 inputPayload: {},
320 isPublicInvocation: true,
321 });
322 expect(r.status).toBe("disabled");
323 await deleteLoop(created!.loop.id, testUserId);
324 });
325});
326
327// ---------------------------------------------------------------------------
328// HAS_AI smoke: just verify the template is syntactically importable as
329// JS (parses) when an AI key is present — we don't actually hit Claude.
330// ---------------------------------------------------------------------------
331describe.skipIf(!HAS_AI)("hosted-claude-loop — HAS_AI surface", () => {
332 it("DEFAULT_LOOP_TEMPLATE is non-empty + hashes deterministically", () => {
333 expect(DEFAULT_LOOP_TEMPLATE.length).toBeGreaterThan(50);
334 const h = createHash("sha256").update(DEFAULT_LOOP_TEMPLATE).digest("hex");
335 expect(h.length).toBe(64);
336 });
337});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -127,6 +127,7 @@ import pwaRoutes from "./routes/pwa";
127127import installRoutes from "./routes/install";
128128import dxtRoutes from "./routes/dxt";
129129import connectClaudeRoutes from "./routes/connect-claude";
130import claudeDeployRoutes from "./routes/claude-deploy";
130131import releasesRoutes from "./routes/releases";
131132import requiredChecksRoutes from "./routes/required-checks";
132133import rulesetsRoutes from "./routes/rulesets";
@@ -597,6 +598,9 @@ app.route("/", dxtRoutes);
597598// Connect Claude — user-facing one-click MCP setup (/connect/claude). Mounted
598599// next to the other one-click flows (install.sh + .dxt) for surface symmetry.
599600app.route("/", connectClaudeRoutes);
601// Hosted Claude tool-use loops — paste loop, get endpoint, billing meter.
602// See src/routes/claude-deploy.tsx + src/lib/hosted-claude-loop.ts.
603app.route("/", claudeDeployRoutes);
600604app.route("/", releasesRoutes);
601605app.route("/", requiredChecksRoutes);
602606app.route("/", rulesetsRoutes);
Addedsrc/routes/claude-deploy.tsx+1346−0View fileUnifiedSplit
Large file (1,346 lines). Load full file
Modifiedsrc/routes/connect-claude.tsx+25−0View fileUnifiedSplit
@@ -988,6 +988,31 @@ connectClaude.get("/connect/claude", async (c) => {
988988 </div>
989989 </section>
990990
991 {/* ─── Hosted Claude loops CTA ─── */}
992 <section class="connect-claude-section">
993 <div class="connect-claude-section-head">
994 <div class="connect-claude-step-row">
995 <span class="connect-claude-step-num">⚡</span>
996 <h2 class="connect-claude-section-title">
997 Host a Claude tool-use loop
998 </h2>
999 </div>
1000 <p class="connect-claude-section-desc">
1001 Paste a Claude loop, get a hosted endpoint with a built-in budget
1002 meter. Your code runs in a 30s sandboxed Bun subprocess —
1003 no infra to wire up.
1004 </p>
1005 </div>
1006 <div class="connect-claude-section-body">
1007 <a
1008 href="/connect/claude/deploy"
1009 class="connect-claude-btn connect-claude-btn-primary"
1010 >
1011 Open Claude loops deploy wizard →
1012 </a>
1013 </div>
1014 </section>
1015
9911016 {/* ─── Step 4: Status ─── */}
9921017 <section class="connect-claude-section">
9931018 <div class="connect-claude-section-head">
9941019