Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

hosted-claude-loop.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

hosted-claude-loop.tsBlame858 lines · 2 contributors
c6db5eeClaude1/**
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. */
a5705cfClaude65const DEFAULT_MODEL = "claude-sonnet-4-6";
c6db5eeClaude66
68eccaaccantynz-alt67/**
68 * Floor charged per invocation from PLATFORM-OBSERVED wall clock.
69 *
70 * The monthly budget check is only as trustworthy as the numbers feeding it,
71 * and those came entirely from `extractUsageFromStdout` — that is, from a
72 * `usage` block printed by the snippet itself. As the comment on the env
73 * block below says outright, `loop.sourceCode` is arbitrary user-supplied
74 * code that any registered account can run. A snippet that simply never
75 * prints a usage block was costed at 0 cents, so its monthly spend never
76 * advanced and the cap could not stop it: unlimited invocations, each
77 * burning up to LOOP_EXEC_TIMEOUT_MS of platform compute, and platform
78 * Anthropic credits too wherever HOSTED_LOOPS_PLATFORM_KEY=1.
79 *
80 * Self-reported usage is still used when it is HIGHER — it is the better
81 * estimate of real token spend, and there is no incentive to overstate it.
82 * The floor only removes the option of reporting nothing. Duration is
83 * measured by the executor, so a snippet cannot forge it.
84 *
85 * Both knobs are env-tunable, read at access time, so the rate can be
86 * adjusted without a deploy. Defaults: 1 cent minimum per run, plus a cent
87 * per 10 seconds — with the smallest $5/month budget that still allows 500+
88 * invocations before the cap bites.
89 */
90function loopFloorSettings(): { minCents: number; msPerCent: number } {
91 const min = Number(process.env.HOSTED_LOOP_MIN_CENTS_PER_RUN ?? 1);
92 const per = Number(process.env.HOSTED_LOOP_MS_PER_CENT ?? 10_000);
93 return {
94 minCents: Number.isFinite(min) && min >= 0 ? Math.floor(min) : 1,
95 msPerCent: Number.isFinite(per) && per > 0 ? per : 10_000,
96 };
97}
98
99/**
100 * Minimum cents to charge for one invocation, given its measured duration.
101 * Never negative; always at least the configured per-run minimum.
102 */
103export function invocationFloorCents(durationMs: number): number {
104 const { minCents, msPerCent } = loopFloorSettings();
105 const ms =
106 Number.isFinite(durationMs) && durationMs > 0 ? durationMs : 0;
107 return Math.max(minCents, Math.ceil(ms / msPerCent));
108}
109
c6db5eeClaude110// ---------------------------------------------------------------------------
111// Pure helpers
112// ---------------------------------------------------------------------------
113
114/**
115 * Slugify a loop name into the URL-safe suffix for `endpoint_path`.
116 * Lowercase alphanum + dashes, trimmed, capped at 40 chars.
117 */
118export function slugifyLoopName(name: string): string {
119 return (name || "")
120 .toLowerCase()
121 .replace(/[^a-z0-9]+/g, "-")
122 .replace(/^-+|-+$/g, "")
123 .slice(0, 40);
124}
125
126/** 6-char hex suffix to disambiguate duplicate slugs. */
127function randomSuffix(): string {
128 const bytes = crypto.getRandomValues(new Uint8Array(3));
129 return Array.from(bytes)
130 .map((b) => b.toString(16).padStart(2, "0"))
131 .join("");
132}
133
134/**
135 * Build a unique endpoint_path from a name. Always begins with
136 * `/claude-loops/`. Suffix is appended even on a clean slug so the path
137 * stays unguessable.
138 */
139export function buildEndpointPath(name: string): string {
140 const slug = slugifyLoopName(name) || "loop";
141 return `/claude-loops/${slug}-${randomSuffix()}`;
142}
143
144/**
145 * Truncate a string at `cap` bytes (UTF-8 safe via TextEncoder length).
146 * Appends a `[truncated]` marker when chopping.
147 */
148export function capStream(s: string, cap: number = RUN_LOG_CAP_BYTES): string {
149 if (!s) return "";
150 if (s.length <= cap) return s;
151 return s.slice(0, cap) + "\n[truncated]";
152}
153
154/**
155 * Best-effort parser: find the FIRST JSON object printed in stdout that
156 * contains a `usage` block with input/output tokens. Returns zeros when
157 * nothing matches. The intent is to encourage snippets to emit something
158 * like `console.log(JSON.stringify({ usage: response.usage, ... }))`.
159 */
160export function extractUsageFromStdout(stdout: string): {
161 inputTokens: number;
162 outputTokens: number;
163 model: string | null;
164} {
165 if (!stdout) return { inputTokens: 0, outputTokens: 0, model: null };
166 // Try whole-stdout-is-json first.
167 const tryParse = (chunk: string) => {
168 try {
169 const parsed = JSON.parse(chunk);
170 if (parsed && typeof parsed === "object") return parsed;
171 } catch {
172 /* ignore */
173 }
174 return null;
175 };
176 const candidates: unknown[] = [];
177 const whole = tryParse(stdout.trim());
178 if (whole) candidates.push(whole);
179 // Fall back to line-by-line.
180 for (const line of stdout.split(/\n/)) {
181 const t = line.trim();
182 if (!t.startsWith("{")) continue;
183 const got = tryParse(t);
184 if (got) candidates.push(got);
185 }
186 for (const c of candidates) {
187 const obj = c as Record<string, unknown>;
188 const usage = obj.usage as Record<string, unknown> | undefined;
189 if (
190 usage &&
191 typeof usage.input_tokens === "number" &&
192 typeof usage.output_tokens === "number"
193 ) {
194 return {
195 inputTokens: usage.input_tokens,
196 outputTokens: usage.output_tokens,
197 model: typeof obj.model === "string" ? obj.model : null,
198 };
199 }
200 }
201 return { inputTokens: 0, outputTokens: 0, model: null };
202}
203
204// ---------------------------------------------------------------------------
205// Default snippet
206// ---------------------------------------------------------------------------
207
208/** Pre-filled template shown in the wizard's textarea. */
209export const DEFAULT_LOOP_TEMPLATE = `import Anthropic from "@anthropic-ai/sdk";
210
211const client = new Anthropic();
212const input = JSON.parse(process.env.INPUT || "{}");
213const repo = input.repo || "ccantynz-alt/Gluecron.com";
214
215const result = await client.messages.create({
a5705cfClaude216 model: "claude-sonnet-4-6",
c6db5eeClaude217 max_tokens: 1024,
218 messages: [
219 { role: "user", content: \`Summarise repo \${repo} in 3 bullets\` },
220 ],
221});
222
223const text = (result.content[0] && "text" in result.content[0])
224 ? result.content[0].text
225 : "";
226
227console.log(JSON.stringify({
228 summary: text,
229 model: result.model,
230 usage: result.usage,
231}));
232`;
233
234// ---------------------------------------------------------------------------
235// Executor test seam — bypassed in tests to skip the actual subprocess.
236// ---------------------------------------------------------------------------
237
238export interface ExecResult {
239 stdout: string;
240 stderr: string;
241 exitCode: number | null;
242 durationMs: number;
243 timedOut: boolean;
244}
245
246export interface ExecArgs {
247 sourceCode: string;
248 inputPayload: unknown;
249 env: Record<string, string>;
250}
251
252export type LoopExecutor = (args: ExecArgs) => Promise<ExecResult>;
253
254let executorOverride: LoopExecutor | null = null;
255
256/** Test seam — pass a fake executor to short-circuit Bun.spawn. */
257export function __setExecutorForTests(fn: LoopExecutor | null): void {
258 executorOverride = fn;
259}
260
261/**
262 * Spawn the user's snippet in a fresh Bun subprocess. The snippet is
263 * written to a tempdir and removed when we're done. The subprocess
264 * sees only the env vars we pass — `process.env` is NOT inherited.
265 */
266async function defaultExecutor(args: ExecArgs): Promise<ExecResult> {
267 const started = Date.now();
268 const dir = await mkdtemp(join(tmpdir(), "gluecron-cldploy-"));
269 const file = join(dir, "loop.mjs");
270 await writeFile(file, args.sourceCode, "utf8");
271
272 let timedOut = false;
273 let killTimer: ReturnType<typeof setTimeout> | null = null;
274 let escalateTimer: ReturnType<typeof setTimeout> | null = null;
275 let proc: ReturnType<typeof Bun.spawn> | null = null;
276 try {
277 proc = Bun.spawn(["bun", "run", "--no-install", file], {
278 cwd: dir,
279 stdout: "pipe",
280 stderr: "pipe",
281 env: {
282 ...args.env,
283 INPUT: JSON.stringify(args.inputPayload ?? {}),
284 },
285 });
286
287 killTimer = setTimeout(() => {
288 timedOut = true;
289 try {
290 proc?.kill("SIGTERM");
291 } catch {
292 /* ignore */
293 }
294 escalateTimer = setTimeout(() => {
295 try {
296 proc?.kill("SIGKILL");
297 } catch {
298 /* ignore */
299 }
300 }, KILL_GRACE_MS);
301 }, LOOP_EXEC_TIMEOUT_MS);
302
303 const stdoutP = proc.stdout
304 ? new Response(proc.stdout as ReadableStream).text().catch(() => "")
305 : Promise.resolve("");
306 const stderrP = proc.stderr
307 ? new Response(proc.stderr as ReadableStream).text().catch(() => "")
308 : Promise.resolve("");
309 const [stdout, stderr] = await Promise.all([stdoutP, stderrP]);
310 const exitCode = await proc.exited;
311
312 return {
313 stdout,
314 stderr: timedOut
315 ? `${stderr}\n[killed after ${LOOP_EXEC_TIMEOUT_MS}ms timeout]`
316 : stderr,
317 exitCode,
318 durationMs: Date.now() - started,
319 timedOut,
320 };
321 } catch (err) {
322 return {
323 stdout: "",
324 stderr: `[hosted-claude-loop] spawn failed: ${(err as Error).message}`,
325 exitCode: null,
326 durationMs: Date.now() - started,
327 timedOut: false,
328 };
329 } finally {
330 if (killTimer) clearTimeout(killTimer);
331 if (escalateTimer) clearTimeout(escalateTimer);
332 await rm(dir, { recursive: true, force: true }).catch(() => undefined);
333 }
334}
335
336function getExecutor(): LoopExecutor {
337 return executorOverride ?? defaultExecutor;
338}
339
340// ---------------------------------------------------------------------------
341// CRUD — every helper swallows DB errors and returns null/[]/false.
342// ---------------------------------------------------------------------------
343
344export interface CreateLoopInput {
345 ownerUserId: string;
346 name: string;
347 sourceCode: string;
348 /** Monthly cap in CENTS. UI shows $5/$25/$100 → 500/2500/10000. */
349 monthlyBudgetCents?: number;
350 isPublic?: boolean;
351}
352
353export interface CreateLoopResult {
354 loop: HostedClaudeLoop;
355 /** Plaintext `agt_…` agent token — returned once, never persisted. */
356 agentToken: string | null;
357}
358
359/**
360 * Create a hosted loop. Mints an agent session in the same call so the
361 * snippet can call back into Gluecron MCP using the returned token.
362 */
363export async function createLoop(
364 input: CreateLoopInput
365): Promise<CreateLoopResult | null> {
366 const name = (input.name || "").trim();
367 if (!name) return null;
368 const source = (input.sourceCode || "").trim();
369 if (!source) return null;
370
371 const monthlyBudgetCents =
372 typeof input.monthlyBudgetCents === "number" &&
373 input.monthlyBudgetCents > 0
374 ? Math.floor(input.monthlyBudgetCents)
375 : 500;
376
377 // Mint an agent session so the user's snippet can reach back into the
378 // MCP surface. Daily budget = monthly cap / 30 (round up).
379 const dailyBudget = Math.max(1, Math.ceil(monthlyBudgetCents / 30));
380 const agent = await createAgentSession({
381 ownerUserId: input.ownerUserId,
382 // Suffix avoids the (owner_user_id, name) UNIQUE collision in
383 // agent_sessions when a user creates two loops with the same name.
384 name: `loop-${slugifyLoopName(name) || "loop"}-${randomSuffix()}`,
385 budgetCentsPerDay: dailyBudget,
386 });
387
388 try {
389 // Try a handful of unique endpoint_paths in case of a slug collision.
390 for (let attempt = 0; attempt < 5; attempt++) {
391 const endpointPath = buildEndpointPath(name);
392 try {
393 const [row] = await db
394 .insert(hostedClaudeLoops)
395 .values({
396 ownerUserId: input.ownerUserId,
397 name,
398 sourceCode: source,
399 endpointPath,
400 agentSessionId: agent?.session.id ?? null,
401 status: "paused",
402 isPublic: Boolean(input.isPublic),
403 monthlyBudgetCents,
404 })
405 .returning();
406 if (!row) continue;
407 return { loop: row, agentToken: agent?.token ?? null };
408 } catch {
409 // 23505 unique conflict on endpoint_path — retry with a new suffix.
410 continue;
411 }
412 }
413 return null;
414 } catch {
415 return null;
416 }
417}
418
419export async function getLoop(loopId: string): Promise<HostedClaudeLoop | null> {
420 try {
421 const [row] = await db
422 .select()
423 .from(hostedClaudeLoops)
424 .where(eq(hostedClaudeLoops.id, loopId))
425 .limit(1);
426 return row ?? null;
427 } catch {
428 return null;
429 }
430}
431
432export async function getLoopByEndpointPath(
433 endpointPath: string
434): Promise<HostedClaudeLoop | null> {
435 try {
436 const [row] = await db
437 .select()
438 .from(hostedClaudeLoops)
439 .where(eq(hostedClaudeLoops.endpointPath, endpointPath))
440 .limit(1);
441 return row ?? null;
442 } catch {
443 return null;
444 }
445}
446
447export async function listLoopsForOwner(
448 ownerUserId: string
449): Promise<HostedClaudeLoop[]> {
450 try {
451 return await db
452 .select()
453 .from(hostedClaudeLoops)
454 .where(eq(hostedClaudeLoops.ownerUserId, ownerUserId))
455 .orderBy(desc(hostedClaudeLoops.updatedAt));
456 } catch {
457 return [];
458 }
459}
460
461export async function listRunsForLoop(
462 loopId: string,
463 limit: number = 50
464): Promise<HostedClaudeLoopRun[]> {
465 try {
466 const n = Math.max(1, Math.min(500, Math.floor(limit)));
467 return await db
468 .select()
469 .from(hostedClaudeLoopRuns)
470 .where(eq(hostedClaudeLoopRuns.loopId, loopId))
471 .orderBy(desc(hostedClaudeLoopRuns.startedAt))
472 .limit(n);
473 } catch {
474 return [];
475 }
476}
477
478async function setLoopStatus(
479 loopId: string,
480 ownerUserId: string,
481 status: "paused" | "running" | "errored"
482): Promise<boolean> {
483 try {
484 const result = await db
485 .update(hostedClaudeLoops)
486 .set({ status, updatedAt: new Date() })
487 .where(
488 and(
489 eq(hostedClaudeLoops.id, loopId),
490 eq(hostedClaudeLoops.ownerUserId, ownerUserId)
491 )
492 )
493 .returning({ id: hostedClaudeLoops.id });
494 return result.length > 0;
495 } catch {
496 return false;
497 }
498}
499
500export async function pauseLoop(
501 loopId: string,
502 ownerUserId: string
503): Promise<boolean> {
504 return setLoopStatus(loopId, ownerUserId, "paused");
505}
506
507export async function resumeLoop(
508 loopId: string,
509 ownerUserId: string
510): Promise<boolean> {
511 return setLoopStatus(loopId, ownerUserId, "running");
512}
513
514export async function deleteLoop(
515 loopId: string,
516 ownerUserId: string
517): Promise<boolean> {
518 try {
519 const result = await db
520 .delete(hostedClaudeLoops)
521 .where(
522 and(
523 eq(hostedClaudeLoops.id, loopId),
524 eq(hostedClaudeLoops.ownerUserId, ownerUserId)
525 )
526 )
527 .returning({ id: hostedClaudeLoops.id });
528 return result.length > 0;
529 } catch {
530 return false;
531 }
532}
533
534export interface UpdateLoopInput {
535 name?: string;
536 sourceCode?: string;
537 monthlyBudgetCents?: number;
538 isPublic?: boolean;
539}
540
541export async function updateLoop(
542 loopId: string,
543 ownerUserId: string,
544 patch: UpdateLoopInput
545): Promise<HostedClaudeLoop | null> {
546 const set: Partial<HostedClaudeLoop> = { updatedAt: new Date() };
547 if (typeof patch.name === "string" && patch.name.trim()) {
548 set.name = patch.name.trim();
549 }
550 if (typeof patch.sourceCode === "string" && patch.sourceCode.trim()) {
551 set.sourceCode = patch.sourceCode.trim();
552 }
553 if (
554 typeof patch.monthlyBudgetCents === "number" &&
555 patch.monthlyBudgetCents > 0
556 ) {
557 set.monthlyBudgetCents = Math.floor(patch.monthlyBudgetCents);
558 }
559 if (typeof patch.isPublic === "boolean") {
560 set.isPublic = patch.isPublic;
561 }
562 try {
563 const [row] = await db
564 .update(hostedClaudeLoops)
565 .set(set)
566 .where(
567 and(
568 eq(hostedClaudeLoops.id, loopId),
569 eq(hostedClaudeLoops.ownerUserId, ownerUserId)
570 )
571 )
572 .returning();
573 return row ?? null;
574 } catch {
575 return null;
576 }
577}
578
579// ---------------------------------------------------------------------------
580// Budget reads
581// ---------------------------------------------------------------------------
582
583/**
584 * Aggregate the loop's lifetime spend (cents) from
585 * `hosted_claude_loops.total_cents_spent`. Cheap O(1) lookup — we keep
586 * the running counter in the loop row so the meter doesn't have to scan
587 * every run on every check.
588 */
589export async function getLoopMonthlySpendCents(loopId: string): Promise<number> {
590 try {
591 const [row] = await db
592 .select({ cents: hostedClaudeLoops.totalCentsSpent })
593 .from(hostedClaudeLoops)
594 .where(eq(hostedClaudeLoops.id, loopId))
595 .limit(1);
596 return row?.cents ?? 0;
597 } catch {
598 return 0;
599 }
600}
601
602// ---------------------------------------------------------------------------
603// Invocation
604// ---------------------------------------------------------------------------
605
606export interface InvokeLoopInput {
607 loopId: string;
608 inputPayload?: unknown;
609 /** Set `true` when invoked from the public endpoint so we record an
610 * is_public=true flag for audit. Defaults to false. */
611 isPublicInvocation?: boolean;
612}
613
614export interface InvokeLoopResult {
615 run: HostedClaudeLoopRun | null;
616 /** `ok` — ran successfully (exit 0). `error` — non-zero exit or
617 * timeout. `budget_exceeded` — short-circuited by the monthly cap.
618 * `not_found` — loop missing. `disabled` — loop marked paused. */
619 status:
620 | "ok"
621 | "error"
622 | "budget_exceeded"
623 | "not_found"
624 | "disabled";
625 output: unknown;
626 stdout: string;
627 stderr: string;
628 /** Cents charged for this single invocation. */
629 centsCharged: number;
630}
631
632/**
633 * Synchronously invoke a hosted loop. Returns the run row plus a
634 * decoded output payload (the snippet's stdout, parsed as JSON when
635 * possible — otherwise the raw string).
636 */
637export async function invokeLoop(
638 input: InvokeLoopInput
639): Promise<InvokeLoopResult> {
640 const loop = await getLoop(input.loopId);
641 if (!loop) {
642 return {
643 run: null,
644 status: "not_found",
645 output: null,
646 stdout: "",
647 stderr: "loop not found",
648 centsCharged: 0,
649 };
650 }
651 if (loop.status === "paused" && !input.isPublicInvocation) {
652 // Paused loops still accept owner-driven invokes via the API — the
653 // wizard's "Invoke" button auto-resumes. Public callers see disabled.
654 }
655 if (loop.status === "paused" && input.isPublicInvocation) {
656 return {
657 run: null,
658 status: "disabled",
659 output: null,
660 stdout: "",
661 stderr: "loop is paused",
662 centsCharged: 0,
663 };
664 }
665
666 // Budget check — short-circuit before spending any compute.
667 const monthlySpend = await getLoopMonthlySpendCents(loop.id);
668 if (monthlySpend >= loop.monthlyBudgetCents) {
669 const insertedRun = await insertRun({
670 loopId: loop.id,
671 inputPayload: input.inputPayload,
672 status: "budget_exceeded",
673 stdout: "",
674 stderr: "monthly budget exceeded",
675 exitCode: null,
676 errorMessage: "monthly budget exceeded",
677 });
678 return {
679 run: insertedRun,
680 status: "budget_exceeded",
681 output: null,
682 stdout: "",
683 stderr: "monthly budget exceeded",
684 centsCharged: 0,
685 };
686 }
687
4c08073ccantynz-alt688 // `loop.sourceCode` is arbitrary user-supplied code — creating a loop needs
689 // only requireAuth, so any registered account can run whatever it likes
690 // here, and stdout is captured and handed back to the caller. Anything put
691 // in this env is therefore readable by that user with one console.log.
692 //
693 // Two secrets used to be here:
694 // - GLUECRON_PAT: the OPERATOR's admin-scoped token. There was never a
695 // reason to expose it; snippets that need to call back use their own
696 // token from the wizard. Removed outright.
697 // - ANTHROPIC_API_KEY: the platform's billing key. Handing it to
698 // untrusted code lets any user drain the account or use it elsewhere.
699 // Now off by default and only injected when an operator explicitly
700 // opts in via HOSTED_LOOPS_PLATFORM_KEY=1, so an existing trusted
701 // deployment can keep working while the default is safe.
c6db5eeClaude702 const env: Record<string, string> = {
703 PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
704 HOME: process.env.HOME || "/tmp",
705 // The loop's own agent token — minted at creation time, hashed in
706 // agent_sessions. The plaintext is only handed to the user at create
707 // and never persisted, so we expose the loop's session id here as
708 // GLUECRON_AGENT_ID. Snippets that need callbacks should use the PAT
709 // displayed in the wizard.
710 GLUECRON_AGENT_ID: loop.agentSessionId ?? "",
711 GLUECRON_LOOP_ID: loop.id,
712 GLUECRON_BASE_URL:
713 process.env.APP_BASE_URL || "https://gluecron.com",
714 };
4c08073ccantynz-alt715 if (process.env.HOSTED_LOOPS_PLATFORM_KEY === "1") {
716 env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || "";
717 }
c6db5eeClaude718
719 const exec = getExecutor();
720 let execResult: ExecResult;
721 try {
722 execResult = await exec({
723 sourceCode: loop.sourceCode,
724 inputPayload: input.inputPayload ?? {},
725 env,
726 });
727 } catch (err) {
728 execResult = {
729 stdout: "",
730 stderr: `[invoke] executor threw: ${(err as Error).message}`,
731 exitCode: null,
732 durationMs: 0,
733 timedOut: false,
734 };
735 }
736
737 const usage = extractUsageFromStdout(execResult.stdout);
738 const model = usage.model || DEFAULT_MODEL;
68eccaaccantynz-alt739 // Self-reported when it is higher, platform-observed floor otherwise — see
740 // invocationFloorCents. Applies to failed and timed-out runs too: those
741 // consumed compute exactly like successful ones, and a snippet that always
742 // fails must not be free to invoke forever.
743 const cents = Math.max(
744 computeCentsForCall(model, usage.inputTokens, usage.outputTokens),
745 invocationFloorCents(execResult.durationMs)
c6db5eeClaude746 );
747
748 // Try to parse stdout as JSON for the output payload.
749 let parsedOutput: unknown = execResult.stdout;
750 try {
751 const trimmed = execResult.stdout.trim();
752 if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
753 parsedOutput = JSON.parse(trimmed);
754 }
755 } catch {
756 /* leave as raw string */
757 }
758
759 const succeeded =
760 execResult.exitCode === 0 && !execResult.timedOut && !!execResult.stdout;
761 const runStatus = succeeded ? "ok" : execResult.timedOut ? "timeout" : "error";
762
763 const run = await insertRun({
764 loopId: loop.id,
765 inputPayload: input.inputPayload,
766 outputPayload: succeeded ? parsedOutput : null,
767 stdout: execResult.stdout,
768 stderr: execResult.stderr,
769 exitCode: execResult.exitCode,
770 status: runStatus,
771 centsEstimate: cents,
772 inputTokens: usage.inputTokens,
773 outputTokens: usage.outputTokens,
774 errorMessage: succeeded ? null : execResult.stderr.slice(0, 500),
775 });
776
777 // Roll up onto the loop row + record the ai_cost_events row.
778 await Promise.all([
779 bumpLoopTotals(loop.id, cents),
780 recordAiCost({
781 ownerUserId: loop.ownerUserId,
782 agentSessionId: loop.agentSessionId,
783 model,
784 inputTokens: usage.inputTokens,
785 outputTokens: usage.outputTokens,
786 category: COST_CATEGORY,
787 sourceId: loop.id,
788 sourceKind: "hosted_claude_loop",
789 }),
790 loop.agentSessionId
791 ? chargeAgent(loop.agentSessionId, cents).then(() => undefined)
792 : Promise.resolve(),
793 ]);
794
795 return {
796 run,
797 status: succeeded ? "ok" : "error",
798 output: parsedOutput,
799 stdout: execResult.stdout,
800 stderr: execResult.stderr,
801 centsCharged: cents,
802 };
803}
804
805interface InsertRunArgs {
806 loopId: string;
807 inputPayload?: unknown;
808 outputPayload?: unknown;
809 stdout?: string;
810 stderr?: string;
811 exitCode?: number | null;
812 status: string;
813 centsEstimate?: number;
814 inputTokens?: number;
815 outputTokens?: number;
816 errorMessage?: string | null;
817}
818
819async function insertRun(args: InsertRunArgs): Promise<HostedClaudeLoopRun | null> {
820 try {
821 const [row] = await db
822 .insert(hostedClaudeLoopRuns)
823 .values({
824 loopId: args.loopId,
825 inputPayload: (args.inputPayload ?? {}) as never,
826 outputPayload: (args.outputPayload ?? null) as never,
827 stdout: capStream(args.stdout || ""),
828 stderr: capStream(args.stderr || ""),
829 exitCode: args.exitCode ?? null,
830 status: args.status,
831 finishedAt: new Date(),
832 centsEstimate: Math.max(0, Math.floor(args.centsEstimate || 0)),
833 claudeInputTokens: Math.max(0, Math.floor(args.inputTokens || 0)),
834 claudeOutputTokens: Math.max(0, Math.floor(args.outputTokens || 0)),
835 errorMessage: args.errorMessage ?? null,
836 })
837 .returning();
838 return row ?? null;
839 } catch {
840 return null;
841 }
842}
843
844async function bumpLoopTotals(loopId: string, cents: number): Promise<void> {
845 try {
846 await db
847 .update(hostedClaudeLoops)
848 .set({
849 totalInvocations: sql`${hostedClaudeLoops.totalInvocations} + 1`,
850 totalCentsSpent: sql`${hostedClaudeLoops.totalCentsSpent} + ${Math.max(0, Math.floor(cents))}`,
851 lastRunAt: new Date(),
852 updatedAt: new Date(),
853 })
854 .where(eq(hostedClaudeLoops.id, loopId));
855 } catch {
856 /* best-effort */
857 }
858}