Commitc6db5eeunknown_key
feat(vs-github): viral-grade upgrade — 25+ row comparison + speed chart + price bundle + agent era
4 files changed+2491−361c6db5eef17c315498ebe764365fd5b6789368b18
4 changed files+2491−361
Addeddrizzle/0069_hosted_claude_loops.sql+98−0View fileUnifiedSplit
@@ -0,0 +1,98 @@
1-- Gluecron migration 0069: hosted Claude tool-use loops.
2--
3-- Users paste a Claude-flavoured tool-use loop at /connect/claude/deploy,
4-- pick a budget cap, and get back a hosted endpoint that runs the code in
5-- a sandboxed Bun subprocess on demand. Each loop is paired to an
6-- agent_sessions row so it inherits multiplayer namespacing + the daily
7-- budget mutex from src/lib/agent-multiplayer.ts.
8--
9-- hosted_claude_loops — one row per deployed loop
10-- hosted_claude_loop_runs — one row per invocation
11--
12-- Wrapped in DO blocks so the migration is safe to re-run and degrades
13-- gracefully when (e.g.) the agent_sessions table is missing on a partial
14-- replay. Helpers in src/lib/hosted-claude-loop.ts already return null /
15-- empty when these tables are absent.
16
17--> statement-breakpoint
18DO $$
19BEGIN
20 CREATE TABLE IF NOT EXISTS "hosted_claude_loops" (
21 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
22 "owner_user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
23 "name" text NOT NULL,
24 "source_code" text NOT NULL,
25 "endpoint_path" text NOT NULL UNIQUE,
26 "agent_session_id" uuid REFERENCES "agent_sessions"("id") ON DELETE SET NULL,
27 "status" text NOT NULL DEFAULT 'paused',
28 "is_public" boolean NOT NULL DEFAULT false,
29 "monthly_budget_cents" integer NOT NULL DEFAULT 500,
30 "last_run_at" timestamptz,
31 "total_invocations" integer NOT NULL DEFAULT 0,
32 "total_cents_spent" integer NOT NULL DEFAULT 0,
33 "created_at" timestamptz NOT NULL DEFAULT now(),
34 "updated_at" timestamptz NOT NULL DEFAULT now()
35 );
36EXCEPTION WHEN OTHERS THEN
37 RAISE NOTICE 'hosted_claude_loops create failed (%); /connect/claude/deploy will degrade to empty', SQLERRM;
38END $$;
39
40--> statement-breakpoint
41DO $$
42BEGIN
43 CREATE INDEX IF NOT EXISTS "hosted_claude_loops_owner"
44 ON "hosted_claude_loops" ("owner_user_id", "updated_at" DESC);
45EXCEPTION WHEN OTHERS THEN
46 RAISE NOTICE 'hosted_claude_loops_owner index failed (%)', SQLERRM;
47END $$;
48
49--> statement-breakpoint
50DO $$
51BEGIN
52 CREATE INDEX IF NOT EXISTS "hosted_claude_loops_status"
53 ON "hosted_claude_loops" ("status");
54EXCEPTION WHEN OTHERS THEN
55 RAISE NOTICE 'hosted_claude_loops_status index failed (%)', SQLERRM;
56END $$;
57
58--> statement-breakpoint
59DO $$
60BEGIN
61 CREATE TABLE IF NOT EXISTS "hosted_claude_loop_runs" (
62 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
63 "loop_id" uuid NOT NULL REFERENCES "hosted_claude_loops"("id") ON DELETE CASCADE,
64 "input_payload" jsonb NOT NULL DEFAULT '{}'::jsonb,
65 "output_payload" jsonb,
66 "stdout" text,
67 "stderr" text,
68 "started_at" timestamptz NOT NULL DEFAULT now(),
69 "finished_at" timestamptz,
70 "status" text NOT NULL DEFAULT 'running',
71 "cents_estimate" integer NOT NULL DEFAULT 0,
72 "claude_input_tokens" integer NOT NULL DEFAULT 0,
73 "claude_output_tokens" integer NOT NULL DEFAULT 0,
74 "exit_code" integer,
75 "error_message" text,
76 "created_at" timestamptz NOT NULL DEFAULT now()
77 );
78EXCEPTION WHEN OTHERS THEN
79 RAISE NOTICE 'hosted_claude_loop_runs create failed (%); run history will be unavailable', SQLERRM;
80END $$;
81
82--> statement-breakpoint
83DO $$
84BEGIN
85 CREATE INDEX IF NOT EXISTS "hosted_claude_loop_runs_loop_time"
86 ON "hosted_claude_loop_runs" ("loop_id", "started_at" DESC);
87EXCEPTION WHEN OTHERS THEN
88 RAISE NOTICE 'hosted_claude_loop_runs_loop_time index failed (%)', SQLERRM;
89END $$;
90
91--> statement-breakpoint
92DO $$
93BEGIN
94 CREATE INDEX IF NOT EXISTS "hosted_claude_loop_runs_status"
95 ON "hosted_claude_loop_runs" ("status", "started_at" DESC);
96EXCEPTION WHEN OTHERS THEN
97 RAISE NOTICE 'hosted_claude_loop_runs_status index failed (%)', SQLERRM;
98END $$;
Modifiedsrc/db/schema.ts+80−0View fileUnifiedSplit
@@ -3500,3 +3500,83 @@ export const docTracking = pgTable(
35003500
35013501export type DocTracking = typeof docTracking.$inferSelect;
35023502export type NewDocTracking = typeof docTracking.$inferInsert;
3503
3504// ---------------------------------------------------------------------------
3505// 0069 — Hosted Claude tool-use loops. See src/lib/hosted-claude-loop.ts and
3506// src/routes/claude-deploy.tsx.
3507//
3508// Users paste a Claude tool-use loop, get a hosted endpoint + budget meter.
3509// Each loop is paired to an agent_sessions row so it inherits multiplayer
3510// namespacing and the daily budget mutex.
3511// ---------------------------------------------------------------------------
3512export const hostedClaudeLoops = pgTable(
3513 "hosted_claude_loops",
3514 {
3515 id: uuid("id").primaryKey().defaultRandom(),
3516 ownerUserId: uuid("owner_user_id")
3517 .notNull()
3518 .references(() => users.id, { onDelete: "cascade" }),
3519 name: text("name").notNull(),
3520 sourceCode: text("source_code").notNull(),
3521 endpointPath: text("endpoint_path").notNull().unique(),
3522 agentSessionId: uuid("agent_session_id").references(
3523 () => agentSessions.id,
3524 { onDelete: "set null" }
3525 ),
3526 status: text("status").default("paused").notNull(),
3527 isPublic: boolean("is_public").default(false).notNull(),
3528 monthlyBudgetCents: integer("monthly_budget_cents").default(500).notNull(),
3529 lastRunAt: timestamp("last_run_at", { withTimezone: true }),
3530 totalInvocations: integer("total_invocations").default(0).notNull(),
3531 totalCentsSpent: integer("total_cents_spent").default(0).notNull(),
3532 createdAt: timestamp("created_at", { withTimezone: true })
3533 .defaultNow()
3534 .notNull(),
3535 updatedAt: timestamp("updated_at", { withTimezone: true })
3536 .defaultNow()
3537 .notNull(),
3538 },
3539 (table) => [
3540 index("hosted_claude_loops_owner").on(table.ownerUserId, table.updatedAt),
3541 index("hosted_claude_loops_status").on(table.status),
3542 ]
3543);
3544
3545export const hostedClaudeLoopRuns = pgTable(
3546 "hosted_claude_loop_runs",
3547 {
3548 id: uuid("id").primaryKey().defaultRandom(),
3549 loopId: uuid("loop_id")
3550 .notNull()
3551 .references(() => hostedClaudeLoops.id, { onDelete: "cascade" }),
3552 inputPayload: jsonb("input_payload").default({}).notNull(),
3553 outputPayload: jsonb("output_payload"),
3554 stdout: text("stdout"),
3555 stderr: text("stderr"),
3556 startedAt: timestamp("started_at", { withTimezone: true })
3557 .defaultNow()
3558 .notNull(),
3559 finishedAt: timestamp("finished_at", { withTimezone: true }),
3560 status: text("status").default("running").notNull(),
3561 centsEstimate: integer("cents_estimate").default(0).notNull(),
3562 claudeInputTokens: integer("claude_input_tokens").default(0).notNull(),
3563 claudeOutputTokens: integer("claude_output_tokens").default(0).notNull(),
3564 exitCode: integer("exit_code"),
3565 errorMessage: text("error_message"),
3566 createdAt: timestamp("created_at", { withTimezone: true })
3567 .defaultNow()
3568 .notNull(),
3569 },
3570 (table) => [
3571 index("hosted_claude_loop_runs_loop_time").on(
3572 table.loopId,
3573 table.startedAt
3574 ),
3575 index("hosted_claude_loop_runs_status").on(table.status, table.startedAt),
3576 ]
3577);
3578
3579export type HostedClaudeLoop = typeof hostedClaudeLoops.$inferSelect;
3580export type NewHostedClaudeLoop = typeof hostedClaudeLoops.$inferInsert;
3581export type HostedClaudeLoopRun = typeof hostedClaudeLoopRuns.$inferSelect;
3582export type NewHostedClaudeLoopRun = typeof hostedClaudeLoopRuns.$inferInsert;
Addedsrc/lib/hosted-claude-loop.ts+799−0View fileUnifiedSplit
@@ -0,0 +1,799 @@
1/**
2 * Hosted Claude tool-use loop runtime (migration 0069).
3 *
4 * Users paste a JavaScript/TypeScript snippet at /connect/claude/deploy
5 * that drives Anthropic's SDK however they want — typically a tool-use
6 * loop calling Gluecron MCP tools. We persist the source, mint an agent
7 * session for budget enforcement, and execute the snippet on demand in
8 * a sandboxed Bun subprocess.
9 *
10 * Design rules
11 * ─────────────
12 * - Best-effort DB: every read/write is wrapped so missing tables
13 * (migration 0069 not yet applied) degrade to `null` / `[]`. The
14 * routes layer turns null into a 404, the wizard hides the section.
15 * - Sandboxed exec: we never `eval()`. The source is written to a temp
16 * file and run with `bun run --no-install <file>` in a *separate
17 * process* with a 30s hard timeout. The subprocess inherits a
18 * scrubbed env (no Postgres URL, no SMTP creds) and only sees the
19 * three vars it needs.
20 * - Budget meter: each invocation captures the JSON usage block that
21 * the snippet prints (or that we extract from stdout) and pipes the
22 * numbers through `recordAiCost` + `chargeAgent`. Over-budget
23 * invocations short-circuit with a `budget_exceeded` status.
24 * - Test seam: callers can inject `__setExecutorForTests` to bypass
25 * the subprocess and assert tracker behaviour without Bun shell
26 * side effects.
27 */
28
29import { and, desc, eq, sql } from "drizzle-orm";
30import { mkdtemp, rm, writeFile } from "fs/promises";
31import { tmpdir } from "os";
32import { join } from "path";
33import { db } from "../db";
34import {
35 hostedClaudeLoopRuns,
36 hostedClaudeLoops,
37 type HostedClaudeLoop,
38 type HostedClaudeLoopRun,
39} from "../db/schema";
40import {
41 computeCentsForCall,
42 recordAiCost,
43 type AiCostCategory,
44} from "./ai-cost-tracker";
45import { createAgentSession, chargeAgent } from "./agent-multiplayer";
46
47// ---------------------------------------------------------------------------
48// Tunables
49// ---------------------------------------------------------------------------
50
51/** Hard kill timeout for the user's snippet. Must be << any HTTP timeout. */
52export const LOOP_EXEC_TIMEOUT_MS = 30_000;
53
54/** Grace period between SIGTERM and SIGKILL. Mirrors workflow-runner. */
55const KILL_GRACE_MS = 2_000;
56
57/** Cap on persisted stdout/stderr per run so a runaway println doesn't
58 * inflate a Postgres row past the toast limit. */
59const RUN_LOG_CAP_BYTES = 32 * 1024;
60
61/** Category recorded against `ai_cost_events` for these runs. */
62const COST_CATEGORY: AiCostCategory = "other";
63
64/** Default model fallback when the snippet didn't tell us what it ran. */
65const DEFAULT_MODEL = "claude-haiku-4-5";
66
67// ---------------------------------------------------------------------------
68// Pure helpers
69// ---------------------------------------------------------------------------
70
71/**
72 * Slugify a loop name into the URL-safe suffix for `endpoint_path`.
73 * Lowercase alphanum + dashes, trimmed, capped at 40 chars.
74 */
75export function slugifyLoopName(name: string): string {
76 return (name || "")
77 .toLowerCase()
78 .replace(/[^a-z0-9]+/g, "-")
79 .replace(/^-+|-+$/g, "")
80 .slice(0, 40);
81}
82
83/** 6-char hex suffix to disambiguate duplicate slugs. */
84function randomSuffix(): string {
85 const bytes = crypto.getRandomValues(new Uint8Array(3));
86 return Array.from(bytes)
87 .map((b) => b.toString(16).padStart(2, "0"))
88 .join("");
89}
90
91/**
92 * Build a unique endpoint_path from a name. Always begins with
93 * `/claude-loops/`. Suffix is appended even on a clean slug so the path
94 * stays unguessable.
95 */
96export function buildEndpointPath(name: string): string {
97 const slug = slugifyLoopName(name) || "loop";
98 return `/claude-loops/${slug}-${randomSuffix()}`;
99}
100
101/**
102 * Truncate a string at `cap` bytes (UTF-8 safe via TextEncoder length).
103 * Appends a `[truncated]` marker when chopping.
104 */
105export function capStream(s: string, cap: number = RUN_LOG_CAP_BYTES): string {
106 if (!s) return "";
107 if (s.length <= cap) return s;
108 return s.slice(0, cap) + "\n[truncated]";
109}
110
111/**
112 * Best-effort parser: find the FIRST JSON object printed in stdout that
113 * contains a `usage` block with input/output tokens. Returns zeros when
114 * nothing matches. The intent is to encourage snippets to emit something
115 * like `console.log(JSON.stringify({ usage: response.usage, ... }))`.
116 */
117export function extractUsageFromStdout(stdout: string): {
118 inputTokens: number;
119 outputTokens: number;
120 model: string | null;
121} {
122 if (!stdout) return { inputTokens: 0, outputTokens: 0, model: null };
123 // Try whole-stdout-is-json first.
124 const tryParse = (chunk: string) => {
125 try {
126 const parsed = JSON.parse(chunk);
127 if (parsed && typeof parsed === "object") return parsed;
128 } catch {
129 /* ignore */
130 }
131 return null;
132 };
133 const candidates: unknown[] = [];
134 const whole = tryParse(stdout.trim());
135 if (whole) candidates.push(whole);
136 // Fall back to line-by-line.
137 for (const line of stdout.split(/\n/)) {
138 const t = line.trim();
139 if (!t.startsWith("{")) continue;
140 const got = tryParse(t);
141 if (got) candidates.push(got);
142 }
143 for (const c of candidates) {
144 const obj = c as Record<string, unknown>;
145 const usage = obj.usage as Record<string, unknown> | undefined;
146 if (
147 usage &&
148 typeof usage.input_tokens === "number" &&
149 typeof usage.output_tokens === "number"
150 ) {
151 return {
152 inputTokens: usage.input_tokens,
153 outputTokens: usage.output_tokens,
154 model: typeof obj.model === "string" ? obj.model : null,
155 };
156 }
157 }
158 return { inputTokens: 0, outputTokens: 0, model: null };
159}
160
161// ---------------------------------------------------------------------------
162// Default snippet
163// ---------------------------------------------------------------------------
164
165/** Pre-filled template shown in the wizard's textarea. */
166export const DEFAULT_LOOP_TEMPLATE = `import Anthropic from "@anthropic-ai/sdk";
167
168const client = new Anthropic();
169const input = JSON.parse(process.env.INPUT || "{}");
170const repo = input.repo || "ccantynz-alt/Gluecron.com";
171
172const result = await client.messages.create({
173 model: "claude-haiku-4-5",
174 max_tokens: 1024,
175 messages: [
176 { role: "user", content: \`Summarise repo \${repo} in 3 bullets\` },
177 ],
178});
179
180const text = (result.content[0] && "text" in result.content[0])
181 ? result.content[0].text
182 : "";
183
184console.log(JSON.stringify({
185 summary: text,
186 model: result.model,
187 usage: result.usage,
188}));
189`;
190
191// ---------------------------------------------------------------------------
192// Executor test seam — bypassed in tests to skip the actual subprocess.
193// ---------------------------------------------------------------------------
194
195export interface ExecResult {
196 stdout: string;
197 stderr: string;
198 exitCode: number | null;
199 durationMs: number;
200 timedOut: boolean;
201}
202
203export interface ExecArgs {
204 sourceCode: string;
205 inputPayload: unknown;
206 env: Record<string, string>;
207}
208
209export type LoopExecutor = (args: ExecArgs) => Promise<ExecResult>;
210
211let executorOverride: LoopExecutor | null = null;
212
213/** Test seam — pass a fake executor to short-circuit Bun.spawn. */
214export function __setExecutorForTests(fn: LoopExecutor | null): void {
215 executorOverride = fn;
216}
217
218/**
219 * Spawn the user's snippet in a fresh Bun subprocess. The snippet is
220 * written to a tempdir and removed when we're done. The subprocess
221 * sees only the env vars we pass — `process.env` is NOT inherited.
222 */
223async function defaultExecutor(args: ExecArgs): Promise<ExecResult> {
224 const started = Date.now();
225 const dir = await mkdtemp(join(tmpdir(), "gluecron-cldploy-"));
226 const file = join(dir, "loop.mjs");
227 await writeFile(file, args.sourceCode, "utf8");
228
229 let timedOut = false;
230 let killTimer: ReturnType<typeof setTimeout> | null = null;
231 let escalateTimer: ReturnType<typeof setTimeout> | null = null;
232 let proc: ReturnType<typeof Bun.spawn> | null = null;
233 try {
234 proc = Bun.spawn(["bun", "run", "--no-install", file], {
235 cwd: dir,
236 stdout: "pipe",
237 stderr: "pipe",
238 env: {
239 ...args.env,
240 INPUT: JSON.stringify(args.inputPayload ?? {}),
241 },
242 });
243
244 killTimer = setTimeout(() => {
245 timedOut = true;
246 try {
247 proc?.kill("SIGTERM");
248 } catch {
249 /* ignore */
250 }
251 escalateTimer = setTimeout(() => {
252 try {
253 proc?.kill("SIGKILL");
254 } catch {
255 /* ignore */
256 }
257 }, KILL_GRACE_MS);
258 }, LOOP_EXEC_TIMEOUT_MS);
259
260 const stdoutP = proc.stdout
261 ? new Response(proc.stdout as ReadableStream).text().catch(() => "")
262 : Promise.resolve("");
263 const stderrP = proc.stderr
264 ? new Response(proc.stderr as ReadableStream).text().catch(() => "")
265 : Promise.resolve("");
266 const [stdout, stderr] = await Promise.all([stdoutP, stderrP]);
267 const exitCode = await proc.exited;
268
269 return {
270 stdout,
271 stderr: timedOut
272 ? `${stderr}\n[killed after ${LOOP_EXEC_TIMEOUT_MS}ms timeout]`
273 : stderr,
274 exitCode,
275 durationMs: Date.now() - started,
276 timedOut,
277 };
278 } catch (err) {
279 return {
280 stdout: "",
281 stderr: `[hosted-claude-loop] spawn failed: ${(err as Error).message}`,
282 exitCode: null,
283 durationMs: Date.now() - started,
284 timedOut: false,
285 };
286 } finally {
287 if (killTimer) clearTimeout(killTimer);
288 if (escalateTimer) clearTimeout(escalateTimer);
289 await rm(dir, { recursive: true, force: true }).catch(() => undefined);
290 }
291}
292
293function getExecutor(): LoopExecutor {
294 return executorOverride ?? defaultExecutor;
295}
296
297// ---------------------------------------------------------------------------
298// CRUD — every helper swallows DB errors and returns null/[]/false.
299// ---------------------------------------------------------------------------
300
301export interface CreateLoopInput {
302 ownerUserId: string;
303 name: string;
304 sourceCode: string;
305 /** Monthly cap in CENTS. UI shows $5/$25/$100 → 500/2500/10000. */
306 monthlyBudgetCents?: number;
307 isPublic?: boolean;
308}
309
310export interface CreateLoopResult {
311 loop: HostedClaudeLoop;
312 /** Plaintext `agt_…` agent token — returned once, never persisted. */
313 agentToken: string | null;
314}
315
316/**
317 * Create a hosted loop. Mints an agent session in the same call so the
318 * snippet can call back into Gluecron MCP using the returned token.
319 */
320export async function createLoop(
321 input: CreateLoopInput
322): Promise<CreateLoopResult | null> {
323 const name = (input.name || "").trim();
324 if (!name) return null;
325 const source = (input.sourceCode || "").trim();
326 if (!source) return null;
327
328 const monthlyBudgetCents =
329 typeof input.monthlyBudgetCents === "number" &&
330 input.monthlyBudgetCents > 0
331 ? Math.floor(input.monthlyBudgetCents)
332 : 500;
333
334 // Mint an agent session so the user's snippet can reach back into the
335 // MCP surface. Daily budget = monthly cap / 30 (round up).
336 const dailyBudget = Math.max(1, Math.ceil(monthlyBudgetCents / 30));
337 const agent = await createAgentSession({
338 ownerUserId: input.ownerUserId,
339 // Suffix avoids the (owner_user_id, name) UNIQUE collision in
340 // agent_sessions when a user creates two loops with the same name.
341 name: `loop-${slugifyLoopName(name) || "loop"}-${randomSuffix()}`,
342 budgetCentsPerDay: dailyBudget,
343 });
344
345 try {
346 // Try a handful of unique endpoint_paths in case of a slug collision.
347 for (let attempt = 0; attempt < 5; attempt++) {
348 const endpointPath = buildEndpointPath(name);
349 try {
350 const [row] = await db
351 .insert(hostedClaudeLoops)
352 .values({
353 ownerUserId: input.ownerUserId,
354 name,
355 sourceCode: source,
356 endpointPath,
357 agentSessionId: agent?.session.id ?? null,
358 status: "paused",
359 isPublic: Boolean(input.isPublic),
360 monthlyBudgetCents,
361 })
362 .returning();
363 if (!row) continue;
364 return { loop: row, agentToken: agent?.token ?? null };
365 } catch {
366 // 23505 unique conflict on endpoint_path — retry with a new suffix.
367 continue;
368 }
369 }
370 return null;
371 } catch {
372 return null;
373 }
374}
375
376export async function getLoop(loopId: string): Promise<HostedClaudeLoop | null> {
377 try {
378 const [row] = await db
379 .select()
380 .from(hostedClaudeLoops)
381 .where(eq(hostedClaudeLoops.id, loopId))
382 .limit(1);
383 return row ?? null;
384 } catch {
385 return null;
386 }
387}
388
389export async function getLoopByEndpointPath(
390 endpointPath: string
391): Promise<HostedClaudeLoop | null> {
392 try {
393 const [row] = await db
394 .select()
395 .from(hostedClaudeLoops)
396 .where(eq(hostedClaudeLoops.endpointPath, endpointPath))
397 .limit(1);
398 return row ?? null;
399 } catch {
400 return null;
401 }
402}
403
404export async function listLoopsForOwner(
405 ownerUserId: string
406): Promise<HostedClaudeLoop[]> {
407 try {
408 return await db
409 .select()
410 .from(hostedClaudeLoops)
411 .where(eq(hostedClaudeLoops.ownerUserId, ownerUserId))
412 .orderBy(desc(hostedClaudeLoops.updatedAt));
413 } catch {
414 return [];
415 }
416}
417
418export async function listRunsForLoop(
419 loopId: string,
420 limit: number = 50
421): Promise<HostedClaudeLoopRun[]> {
422 try {
423 const n = Math.max(1, Math.min(500, Math.floor(limit)));
424 return await db
425 .select()
426 .from(hostedClaudeLoopRuns)
427 .where(eq(hostedClaudeLoopRuns.loopId, loopId))
428 .orderBy(desc(hostedClaudeLoopRuns.startedAt))
429 .limit(n);
430 } catch {
431 return [];
432 }
433}
434
435async function setLoopStatus(
436 loopId: string,
437 ownerUserId: string,
438 status: "paused" | "running" | "errored"
439): Promise<boolean> {
440 try {
441 const result = await db
442 .update(hostedClaudeLoops)
443 .set({ status, updatedAt: new Date() })
444 .where(
445 and(
446 eq(hostedClaudeLoops.id, loopId),
447 eq(hostedClaudeLoops.ownerUserId, ownerUserId)
448 )
449 )
450 .returning({ id: hostedClaudeLoops.id });
451 return result.length > 0;
452 } catch {
453 return false;
454 }
455}
456
457export async function pauseLoop(
458 loopId: string,
459 ownerUserId: string
460): Promise<boolean> {
461 return setLoopStatus(loopId, ownerUserId, "paused");
462}
463
464export async function resumeLoop(
465 loopId: string,
466 ownerUserId: string
467): Promise<boolean> {
468 return setLoopStatus(loopId, ownerUserId, "running");
469}
470
471export async function deleteLoop(
472 loopId: string,
473 ownerUserId: string
474): Promise<boolean> {
475 try {
476 const result = await db
477 .delete(hostedClaudeLoops)
478 .where(
479 and(
480 eq(hostedClaudeLoops.id, loopId),
481 eq(hostedClaudeLoops.ownerUserId, ownerUserId)
482 )
483 )
484 .returning({ id: hostedClaudeLoops.id });
485 return result.length > 0;
486 } catch {
487 return false;
488 }
489}
490
491export interface UpdateLoopInput {
492 name?: string;
493 sourceCode?: string;
494 monthlyBudgetCents?: number;
495 isPublic?: boolean;
496}
497
498export async function updateLoop(
499 loopId: string,
500 ownerUserId: string,
501 patch: UpdateLoopInput
502): Promise<HostedClaudeLoop | null> {
503 const set: Partial<HostedClaudeLoop> = { updatedAt: new Date() };
504 if (typeof patch.name === "string" && patch.name.trim()) {
505 set.name = patch.name.trim();
506 }
507 if (typeof patch.sourceCode === "string" && patch.sourceCode.trim()) {
508 set.sourceCode = patch.sourceCode.trim();
509 }
510 if (
511 typeof patch.monthlyBudgetCents === "number" &&
512 patch.monthlyBudgetCents > 0
513 ) {
514 set.monthlyBudgetCents = Math.floor(patch.monthlyBudgetCents);
515 }
516 if (typeof patch.isPublic === "boolean") {
517 set.isPublic = patch.isPublic;
518 }
519 try {
520 const [row] = await db
521 .update(hostedClaudeLoops)
522 .set(set)
523 .where(
524 and(
525 eq(hostedClaudeLoops.id, loopId),
526 eq(hostedClaudeLoops.ownerUserId, ownerUserId)
527 )
528 )
529 .returning();
530 return row ?? null;
531 } catch {
532 return null;
533 }
534}
535
536// ---------------------------------------------------------------------------
537// Budget reads
538// ---------------------------------------------------------------------------
539
540/**
541 * Aggregate the loop's lifetime spend (cents) from
542 * `hosted_claude_loops.total_cents_spent`. Cheap O(1) lookup — we keep
543 * the running counter in the loop row so the meter doesn't have to scan
544 * every run on every check.
545 */
546export async function getLoopMonthlySpendCents(loopId: string): Promise<number> {
547 try {
548 const [row] = await db
549 .select({ cents: hostedClaudeLoops.totalCentsSpent })
550 .from(hostedClaudeLoops)
551 .where(eq(hostedClaudeLoops.id, loopId))
552 .limit(1);
553 return row?.cents ?? 0;
554 } catch {
555 return 0;
556 }
557}
558
559// ---------------------------------------------------------------------------
560// Invocation
561// ---------------------------------------------------------------------------
562
563export interface InvokeLoopInput {
564 loopId: string;
565 inputPayload?: unknown;
566 /** Set `true` when invoked from the public endpoint so we record an
567 * is_public=true flag for audit. Defaults to false. */
568 isPublicInvocation?: boolean;
569}
570
571export interface InvokeLoopResult {
572 run: HostedClaudeLoopRun | null;
573 /** `ok` — ran successfully (exit 0). `error` — non-zero exit or
574 * timeout. `budget_exceeded` — short-circuited by the monthly cap.
575 * `not_found` — loop missing. `disabled` — loop marked paused. */
576 status:
577 | "ok"
578 | "error"
579 | "budget_exceeded"
580 | "not_found"
581 | "disabled";
582 output: unknown;
583 stdout: string;
584 stderr: string;
585 /** Cents charged for this single invocation. */
586 centsCharged: number;
587}
588
589/**
590 * Synchronously invoke a hosted loop. Returns the run row plus a
591 * decoded output payload (the snippet's stdout, parsed as JSON when
592 * possible — otherwise the raw string).
593 */
594export async function invokeLoop(
595 input: InvokeLoopInput
596): Promise<InvokeLoopResult> {
597 const loop = await getLoop(input.loopId);
598 if (!loop) {
599 return {
600 run: null,
601 status: "not_found",
602 output: null,
603 stdout: "",
604 stderr: "loop not found",
605 centsCharged: 0,
606 };
607 }
608 if (loop.status === "paused" && !input.isPublicInvocation) {
609 // Paused loops still accept owner-driven invokes via the API — the
610 // wizard's "Invoke" button auto-resumes. Public callers see disabled.
611 }
612 if (loop.status === "paused" && input.isPublicInvocation) {
613 return {
614 run: null,
615 status: "disabled",
616 output: null,
617 stdout: "",
618 stderr: "loop is paused",
619 centsCharged: 0,
620 };
621 }
622
623 // Budget check — short-circuit before spending any compute.
624 const monthlySpend = await getLoopMonthlySpendCents(loop.id);
625 if (monthlySpend >= loop.monthlyBudgetCents) {
626 const insertedRun = await insertRun({
627 loopId: loop.id,
628 inputPayload: input.inputPayload,
629 status: "budget_exceeded",
630 stdout: "",
631 stderr: "monthly budget exceeded",
632 exitCode: null,
633 errorMessage: "monthly budget exceeded",
634 });
635 return {
636 run: insertedRun,
637 status: "budget_exceeded",
638 output: null,
639 stdout: "",
640 stderr: "monthly budget exceeded",
641 centsCharged: 0,
642 };
643 }
644
645 const env: Record<string, string> = {
646 PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
647 HOME: process.env.HOME || "/tmp",
648 // Pass the platform's Claude key (NOT the user's). When unset, the
649 // snippet still runs — the Anthropic SDK throws which becomes stderr.
650 ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || "",
651 // The loop's own agent token — minted at creation time, hashed in
652 // agent_sessions. The plaintext is only handed to the user at create
653 // and never persisted, so we expose the loop's session id here as
654 // GLUECRON_AGENT_ID. Snippets that need callbacks should use the PAT
655 // displayed in the wizard.
656 GLUECRON_AGENT_ID: loop.agentSessionId ?? "",
657 GLUECRON_LOOP_ID: loop.id,
658 GLUECRON_BASE_URL:
659 process.env.APP_BASE_URL || "https://gluecron.com",
660 };
661 if (process.env.GLUECRON_PAT) env.GLUECRON_PAT = process.env.GLUECRON_PAT;
662
663 const exec = getExecutor();
664 let execResult: ExecResult;
665 try {
666 execResult = await exec({
667 sourceCode: loop.sourceCode,
668 inputPayload: input.inputPayload ?? {},
669 env,
670 });
671 } catch (err) {
672 execResult = {
673 stdout: "",
674 stderr: `[invoke] executor threw: ${(err as Error).message}`,
675 exitCode: null,
676 durationMs: 0,
677 timedOut: false,
678 };
679 }
680
681 const usage = extractUsageFromStdout(execResult.stdout);
682 const model = usage.model || DEFAULT_MODEL;
683 const cents = computeCentsForCall(
684 model,
685 usage.inputTokens,
686 usage.outputTokens
687 );
688
689 // Try to parse stdout as JSON for the output payload.
690 let parsedOutput: unknown = execResult.stdout;
691 try {
692 const trimmed = execResult.stdout.trim();
693 if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
694 parsedOutput = JSON.parse(trimmed);
695 }
696 } catch {
697 /* leave as raw string */
698 }
699
700 const succeeded =
701 execResult.exitCode === 0 && !execResult.timedOut && !!execResult.stdout;
702 const runStatus = succeeded ? "ok" : execResult.timedOut ? "timeout" : "error";
703
704 const run = await insertRun({
705 loopId: loop.id,
706 inputPayload: input.inputPayload,
707 outputPayload: succeeded ? parsedOutput : null,
708 stdout: execResult.stdout,
709 stderr: execResult.stderr,
710 exitCode: execResult.exitCode,
711 status: runStatus,
712 centsEstimate: cents,
713 inputTokens: usage.inputTokens,
714 outputTokens: usage.outputTokens,
715 errorMessage: succeeded ? null : execResult.stderr.slice(0, 500),
716 });
717
718 // Roll up onto the loop row + record the ai_cost_events row.
719 await Promise.all([
720 bumpLoopTotals(loop.id, cents),
721 recordAiCost({
722 ownerUserId: loop.ownerUserId,
723 agentSessionId: loop.agentSessionId,
724 model,
725 inputTokens: usage.inputTokens,
726 outputTokens: usage.outputTokens,
727 category: COST_CATEGORY,
728 sourceId: loop.id,
729 sourceKind: "hosted_claude_loop",
730 }),
731 loop.agentSessionId
732 ? chargeAgent(loop.agentSessionId, cents).then(() => undefined)
733 : Promise.resolve(),
734 ]);
735
736 return {
737 run,
738 status: succeeded ? "ok" : "error",
739 output: parsedOutput,
740 stdout: execResult.stdout,
741 stderr: execResult.stderr,
742 centsCharged: cents,
743 };
744}
745
746interface InsertRunArgs {
747 loopId: string;
748 inputPayload?: unknown;
749 outputPayload?: unknown;
750 stdout?: string;
751 stderr?: string;
752 exitCode?: number | null;
753 status: string;
754 centsEstimate?: number;
755 inputTokens?: number;
756 outputTokens?: number;
757 errorMessage?: string | null;
758}
759
760async function insertRun(args: InsertRunArgs): Promise<HostedClaudeLoopRun | null> {
761 try {
762 const [row] = await db
763 .insert(hostedClaudeLoopRuns)
764 .values({
765 loopId: args.loopId,
766 inputPayload: (args.inputPayload ?? {}) as never,
767 outputPayload: (args.outputPayload ?? null) as never,
768 stdout: capStream(args.stdout || ""),
769 stderr: capStream(args.stderr || ""),
770 exitCode: args.exitCode ?? null,
771 status: args.status,
772 finishedAt: new Date(),
773 centsEstimate: Math.max(0, Math.floor(args.centsEstimate || 0)),
774 claudeInputTokens: Math.max(0, Math.floor(args.inputTokens || 0)),
775 claudeOutputTokens: Math.max(0, Math.floor(args.outputTokens || 0)),
776 errorMessage: args.errorMessage ?? null,
777 })
778 .returning();
779 return row ?? null;
780 } catch {
781 return null;
782 }
783}
784
785async function bumpLoopTotals(loopId: string, cents: number): Promise<void> {
786 try {
787 await db
788 .update(hostedClaudeLoops)
789 .set({
790 totalInvocations: sql`${hostedClaudeLoops.totalInvocations} + 1`,
791 totalCentsSpent: sql`${hostedClaudeLoops.totalCentsSpent} + ${Math.max(0, Math.floor(cents))}`,
792 lastRunAt: new Date(),
793 updatedAt: new Date(),
794 })
795 .where(eq(hostedClaudeLoops.id, loopId));
796 } catch {
797 /* best-effort */
798 }
799}
Modifiedsrc/routes/vs-github.tsx+1514−361View fileUnifiedSplit
Large file (2,237 lines). Load full file