feat(ai): point the platform at any model, not just Anthropic #5561
9 changed files+798−54
Modified.env.example+25−0View fileUnifiedSplit
@@ -93,6 +93,31 @@ HEARTBEAT_REPORT_TOKEN=
9393# recording.
9494REPORTING_EPOCH=
9595ANTHROPIC_API_KEY=
96# ── Model provider ──────────────────────────────────────────────────────
97# The platform does not need AI to run: git, CI and merges never call a
98# model. These exist so the AI features are not tied to one vendor.
99#
100# Leave all four unset for the default (Anthropic, via ANTHROPIC_API_KEY).
101# Set AI_BASE_URL alone and the provider is inferred as "openai" — one
102# variable, rather than two that can disagree.
103#
104# "openai" means ANY OpenAI-compatible /chat/completions endpoint: Ollama,
105# LM Studio, vLLM, llama.cpp, OpenRouter, Together, Groq, Fireworks, Azure
106# OpenAI, or OpenAI itself — including a model on your own hardware with no
107# key and no egress.
108#
109# AI_PROVIDER=openai
110# AI_BASE_URL=http://localhost:11434
111# AI_MODEL=llama3
112#
113# AI_MODEL is REQUIRED with a non-Anthropic provider: the task router
114# otherwise emits Claude model ids, which that backend will reject.
115# AI_API_KEY is optional — local runtimes authenticate nothing, and no
116# Authorization header is sent when it is empty.
117AI_PROVIDER=
118AI_BASE_URL=
119AI_API_KEY=
120AI_MODEL=
96121# Email (Block A8). Provider=log just writes to stderr (safe default).
97122# Switch to "resend" in prod and set RESEND_API_KEY.
98123EMAIL_PROVIDER=log
Modified.github/workflows/heartbeat.yml+37−6View fileUnifiedSplit
@@ -26,12 +26,43 @@ name: Heartbeat — gluecron.com
2626
2727# SCHEDULE REMOVED 2026-08-22 — the 5-minute cron burned ~8,600 GitHub
2828# Actions minutes/month on a private repo, which is billed: the workflow
29# keeping us "safe" on GitHub was the thing charging us to stay. External
30# monitoring is replaced by owned infrastructure: jarvis-watchdog on the
31# Vapron box (149.28.119.158) probes gluecron.com every 5 minutes, reports
32# to /api/heartbeat/report, and pings the healthchecks.io dead-man. This
33# workflow remains manually dispatchable as a break-glass third opinion
34# until the repo is archived per docs/CUTOVER_RUNBOOK.md Phase 4.
29# keeping us "safe" on GitHub was the thing charging us to stay. (That spend
30# is the likely cause of the account-wide Actions billing failure that began
31# 2026-08-21 23:57 UTC and took CI down on every sibling repo for days.)
32#
33# WHAT REPLACED IT — corrected 2026-08-27 after verification on the boxes.
34#
35# The previous version of this comment said "jarvis-watchdog on the Vapron box
36# (149.28.119.158) probes gluecron.com every 5 minutes, reports to
37# /api/heartbeat/report, and pings the healthchecks.io dead-man."
38#
39# NONE OF THAT WAS TRUE. Checked on 158: jarvis-watchdog.timer is alive and
40# healthy, but /root/jarvis-watchdog.sh probes exactly two URLs and both are
41# the MASTER box's liveness port (:9212/health) — it has never contacted
42# gluecron.com. Grepping the unit dir, cron.d, /root/*.sh and /opt/vapron for
43# "heartbeat/report" returns zero hits, and there is no healthchecks.io
44# dead-man anywhere on that box. This comment described a watchdog nobody
45# built, and it was believed for five days — including by the session that
46# went looking for it. A comment asserting a monitor exists is not a monitor.
47#
48# WHAT ACTUALLY WATCHES gluecron.com: jarvis-fleet-check.timer on the master
49# (66.42.121.161) HTTP-probes every platform in config/platforms.json every
50# 10 minutes; gluecron is in that list. Two consecutive misses flag `error`,
51# which dispatches jarvis-self-heal and raises a notify() to ntfy and Web
52# Push. Verified live 2026-08-27: status healthy, score 95, HTTP 200.
53#
54# THE REMAINING STRUCTURAL GAP, stated because it is the reason this file
55# still exists: fleet-check runs ON the same box gluecron runs on. If
56# 66.42.121.161 dies, the probe dies with it and reports nothing. The only
57# off-box witness in the estate is the 158 watchdog — which watches the
58# master, so it reports that event one level up ("the master is down") rather
59# than "gluecron is down". A true gluecron-specific dead-man would be a second
60# probe from 158, NOT a re-armed cron here: re-arming this is what caused the
61# billing outage.
62#
63# This workflow remains manually dispatchable as a break-glass third opinion
64# until the repo is archived per docs/CUTOVER_RUNBOOK.md Phase 4. Do not give
65# it a schedule again without checking the Actions bill first.
3566on:
3667 workflow_dispatch: {}
3768
Addedsrc/__tests__/ai-provider.test.ts+227−0View fileUnifiedSplit
@@ -0,0 +1,227 @@
1/**
2 * The platform must run its AI features against a model that isn't Anthropic's.
3 *
4 * This is a developer-adoption property, not a resilience one. Git, CI and
5 * merges never call a model, so an Anthropic outage was never able to take the
6 * platform down. The question a developer evaluating Gluecron actually asks is
7 * "can I point this at my own model?", and until `ai-provider.ts` the answer
8 * was no without editing source.
9 *
10 * These tests pin the adapter's contract against the exact slice of the
11 * Anthropic surface the 53 calling files consume:
12 *
13 * client.messages.create({ model, max_tokens, messages, system? })
14 * → { content: [{ type: "text", text }], usage: { input_tokens, output_tokens } }
15 *
16 * If that translation drifts, every AI feature silently returns empty strings
17 * against a self-hosted model while appearing to work — the failure mode is a
18 * confident blank, not an error.
19 */
20
21import { describe, it, expect } from "bun:test";
22import {
23 completionsUrl,
24 createOpenAiCompatibleClient,
25 flattenContent,
26 fromOpenAiResponse,
27 toOpenAiBody,
28} from "../lib/ai-provider";
29
30describe("completionsUrl", () => {
31 it("accepts a bare host, a /v1 root, and a full endpoint alike", () => {
32 // An operator will paste whichever of these their runtime printed.
33 expect(completionsUrl("http://localhost:11434")).toBe(
34 "http://localhost:11434/v1/chat/completions"
35 );
36 expect(completionsUrl("http://localhost:11434/")).toBe(
37 "http://localhost:11434/v1/chat/completions"
38 );
39 expect(completionsUrl("https://api.groq.com/openai/v1")).toBe(
40 "https://api.groq.com/openai/v1/chat/completions"
41 );
42 expect(completionsUrl("https://x.test/v1/chat/completions")).toBe(
43 "https://x.test/v1/chat/completions"
44 );
45 });
46});
47
48describe("flattenContent", () => {
49 it("passes a plain string through", () => {
50 expect(flattenContent("hello")).toBe("hello");
51 });
52
53 it("joins text blocks and drops non-text ones", () => {
54 // Stringifying a non-text block would send "[object Object]" to the model,
55 // which produces a confident answer about garbage instead of an omission.
56 expect(
57 flattenContent([
58 { type: "text", text: "first" },
59 { type: "image", source: "…" } as never,
60 { type: "text", text: "second" },
61 ])
62 ).toBe("first\nsecond");
63 });
64});
65
66describe("toOpenAiBody", () => {
67 it("moves the Anthropic top-level system prompt into a system message", () => {
68 // Anthropic carries `system` as its own field; OpenAI wants it as the
69 // first message. Dropping it would discard the instructions that make
70 // most features work, while still returning plausible prose.
71 const body = toOpenAiBody({
72 model: "llama3",
73 max_tokens: 100,
74 system: "You are terse.",
75 messages: [{ role: "user", content: "hi" }],
76 });
77 expect(body.messages).toEqual([
78 { role: "system", content: "You are terse." },
79 { role: "user", content: "hi" },
80 ]);
81 expect(body.model).toBe("llama3");
82 expect(body.max_tokens).toBe(100);
83 });
84
85 it("omits temperature unless the caller set one", () => {
86 const body = toOpenAiBody({
87 model: "m",
88 max_tokens: 10,
89 messages: [{ role: "user", content: "x" }],
90 });
91 expect("temperature" in body).toBe(false);
92 const withTemp = toOpenAiBody({
93 model: "m",
94 max_tokens: 10,
95 temperature: 0.2,
96 messages: [{ role: "user", content: "x" }],
97 });
98 expect(withTemp.temperature).toBe(0.2);
99 });
100});
101
102describe("fromOpenAiResponse", () => {
103 it("produces the Anthropic content/usage shape callers destructure", () => {
104 const r = fromOpenAiResponse(
105 {
106 id: "cmpl-1",
107 model: "llama3",
108 choices: [
109 { message: { content: "the answer" }, finish_reason: "stop" },
110 ],
111 usage: { prompt_tokens: 12, completion_tokens: 5 },
112 },
113 "fallback"
114 );
115 expect(r.content[0].type).toBe("text");
116 expect(r.content[0].text).toBe("the answer");
117 expect(r.usage.input_tokens).toBe(12);
118 expect(r.usage.output_tokens).toBe(5);
119 expect(r.stop_reason).toBe("stop");
120 });
121
122 it("returns zeroed usage rather than NaN when the server omits counts", () => {
123 // Cost tracking sums these. A NaN propagates into the spend ledger and
124 // poisons every later total, and many local runtimes omit usage entirely.
125 const r = fromOpenAiResponse(
126 { choices: [{ message: { content: "hi" } }] },
127 "m"
128 );
129 expect(r.usage.input_tokens).toBe(0);
130 expect(r.usage.output_tokens).toBe(0);
131 expect(Number.isNaN(r.usage.input_tokens)).toBe(false);
132 });
133
134 it("survives a malformed or empty response without throwing", () => {
135 expect(fromOpenAiResponse({}, "m").content[0].text).toBe("");
136 expect(fromOpenAiResponse(null, "m").content[0].text).toBe("");
137 expect(fromOpenAiResponse({ choices: [] }, "m").content[0].text).toBe("");
138 });
139
140 it("reads the legacy `text` field some runtimes still return", () => {
141 const r = fromOpenAiResponse({ choices: [{ text: "legacy" }] }, "m");
142 expect(r.content[0].text).toBe("legacy");
143 });
144});
145
146describe("createOpenAiCompatibleClient", () => {
147 it("round-trips a call the way every AI feature invokes it", async () => {
148 let seenUrl = "";
149 let seenBody: any = null;
150 let seenAuth: string | undefined;
151
152 const client = createOpenAiCompatibleClient({
153 baseUrl: "http://localhost:11434",
154 apiKey: "local-key",
155 fetchImpl: (async (url: any, init: any) => {
156 seenUrl = String(url);
157 seenBody = JSON.parse(init.body);
158 seenAuth = init.headers.authorization;
159 return new Response(
160 JSON.stringify({
161 id: "x",
162 model: "llama3",
163 choices: [{ message: { content: "pong" }, finish_reason: "stop" }],
164 usage: { prompt_tokens: 3, completion_tokens: 1 },
165 }),
166 { status: 200, headers: { "content-type": "application/json" } }
167 );
168 }) as unknown as typeof fetch,
169 });
170
171 const msg = await client.messages.create({
172 model: "llama3",
173 max_tokens: 64,
174 system: "be brief",
175 messages: [{ role: "user", content: "ping" }],
176 });
177
178 expect(seenUrl).toBe("http://localhost:11434/v1/chat/completions");
179 expect(seenAuth).toBe("Bearer local-key");
180 expect(seenBody.messages[0]).toEqual({ role: "system", content: "be brief" });
181 expect(msg.content[0].text).toBe("pong");
182 expect(msg.usage.input_tokens).toBe(3);
183 });
184
185 it("sends no Authorization header when no key is configured", async () => {
186 // Ollama and llama.cpp authenticate nothing. Sending "Bearer undefined"
187 // makes some servers reject an otherwise valid local setup.
188 let hadAuth = true;
189 const client = createOpenAiCompatibleClient({
190 baseUrl: "http://localhost:11434",
191 fetchImpl: (async (_u: any, init: any) => {
192 hadAuth = "authorization" in init.headers;
193 return new Response(
194 JSON.stringify({ choices: [{ message: { content: "ok" } }] }),
195 { status: 200 }
196 );
197 }) as unknown as typeof fetch,
198 });
199 await client.messages.create({
200 model: "m",
201 max_tokens: 8,
202 messages: [{ role: "user", content: "x" }],
203 });
204 expect(hadAuth).toBe(false);
205 });
206
207 it("surfaces the server's error body, not just a bare status", async () => {
208 // Self-hosted runtimes put the real reason ("model not found") in the
209 // body. A bare 400 turns a one-line fix into an unactionable failure.
210 const client = createOpenAiCompatibleClient({
211 baseUrl: "http://localhost:11434",
212 fetchImpl: (async () =>
213 new Response('{"error":"model \'llama9\' not found"}', {
214 status: 404,
215 statusText: "Not Found",
216 })) as unknown as typeof fetch,
217 });
218
219 await expect(
220 client.messages.create({
221 model: "llama9",
222 max_tokens: 8,
223 messages: [{ role: "user", content: "x" }],
224 })
225 ).rejects.toThrow(/llama9.*not found|404/);
226 });
227});
Modifiedsrc/lib/ai-client.ts+60−5View fileUnifiedSplit
@@ -5,23 +5,68 @@
55
66import Anthropic from "@anthropic-ai/sdk";
77import { config } from "./config";
8import {
9 createOpenAiCompatibleClient,
10 type AiClientLike,
11} from "./ai-provider";
812
9let _client: Anthropic | null = null;
13let _client: AiClientLike | null = null;
1014
15/**
16 * The platform's one and only model client.
17 *
18 * Every AI feature funnels through here — 53 files — and this was the single
19 * place the Anthropic SDK was ever constructed. That made "am I locked to your
20 * vendor?" a one-function question rather than an architectural one, so the
21 * answer is now: set AI_BASE_URL and the whole platform speaks to your model
22 * instead. See ./ai-provider.ts for the adapter and what it reaches.
23 *
24 * The return type stays the Anthropic client shape because 53 call sites and
25 * nine `import type Anthropic` annotations depend on it. The adapter satisfies
26 * the slice they use; the cast is the seam.
27 */
1128export function getAnthropic(): Anthropic {
1229 if (!_client) {
13 if (!config.anthropicApiKey) {
14 throw new Error("ANTHROPIC_API_KEY is not set");
30 if (config.aiProvider === "openai") {
31 if (!config.aiBaseUrl) {
32 throw new Error(
33 "AI_PROVIDER is 'openai' but AI_BASE_URL is not set — point it at an OpenAI-compatible endpoint (e.g. http://localhost:11434)"
34 );
35 }
36 _client = createOpenAiCompatibleClient({
37 baseUrl: config.aiBaseUrl,
38 apiKey: config.aiApiKey,
39 });
40 } else {
41 if (!config.anthropicApiKey) {
42 throw new Error("ANTHROPIC_API_KEY is not set");
43 }
44 _client = new Anthropic({
45 apiKey: config.anthropicApiKey,
46 }) as unknown as AiClientLike;
1547 }
16 _client = new Anthropic({ apiKey: config.anthropicApiKey });
1748 }
18 return _client;
49 return _client as unknown as Anthropic;
1950}
2051
52/**
53 * Whether any model backend is reachable.
54 *
55 * Deliberately does NOT require an API key for the OpenAI path: a model served
56 * by Ollama or llama.cpp on the same box authenticates nothing. Requiring a key
57 * there would report "AI unavailable" on a working local install — the exact
58 * false negative that makes self-hosting feel broken.
59 */
2160export function isAiAvailable(): boolean {
61 if (config.aiProvider === "openai") return !!config.aiBaseUrl;
2262 return !!config.anthropicApiKey;
2363}
2464
65/** Which backend is in use — for honest reporting on status surfaces. */
66export function aiProviderName(): "anthropic" | "openai" {
67 return config.aiProvider;
68}
69
2570/**
2671 * Test-only: drop the cached client so the next getAnthropic() call
2772 * constructs a fresh one. Needed because the SDK captures a fetch
@@ -150,6 +195,9 @@ export function __resetModelOverridesForTests(): void {
150195 * on cost-appropriate models billed to their usage.
151196 */
152197export function internalModel(): string {
198 // Same reason as modelForTask: the internal tier is a Claude model id, and
199 // a non-Anthropic backend serves whatever AI_MODEL names.
200 if (config.aiProvider === "openai" && config.aiModel) return config.aiModel;
153201 maybeRefreshOverrides();
154202 return _overrides.internal ?? MODEL_OPUS;
155203}
@@ -211,6 +259,13 @@ const HAIKU_ALLOWLIST: ReadonlySet<AiTask> = new Set<AiTask>([
211259 * `AI_FORCE_SONNET=1` forces the standard tier for all tasks.
212260 */
213261export function modelForTask(task: AiTask): string {
262 // A non-Anthropic backend has never heard of "claude-sonnet-4-6", so every
263 // request would 400 on an unknown model. AI_MODEL is the single id that
264 // backend serves, and it wins over the whole task-routing ladder — the
265 // light/standard split is a Claude-pricing optimisation with no meaning
266 // against a local runtime serving exactly one model.
267 if (config.aiProvider === "openai" && config.aiModel) return config.aiModel;
268
214269 maybeRefreshOverrides();
215270 const standard = _overrides.standard ?? MODEL_SONNET;
216271 // Read at call time so the kill-switch works without a restart.
Addedsrc/lib/ai-provider.ts+209−0View fileUnifiedSplit
@@ -0,0 +1,209 @@
1/**
2 * Model-provider abstraction — "can I point this at my own model?"
3 *
4 * WHY THIS EXISTS. Every AI feature on the platform routes through
5 * `ai-client.getAnthropic()`, and that function constructed an Anthropic SDK
6 * client and nothing else. The platform never *needed* AI to run — git, CI and
7 * merges do not call a model — but a developer evaluating Gluecron asks a
8 * different question: not "does it survive an outage" but "am I locked to your
9 * vendor". Until now the honest answer was yes, you would have to edit source.
10 *
11 * THE SHAPE OF THE FIX. Because there is exactly one runtime construction of
12 * the SDK in the whole codebase, a provider only has to satisfy the small
13 * slice of the Anthropic surface the 53 calling files actually use:
14 *
15 * client.messages.create({ model, max_tokens, messages, system?, temperature? })
16 * → { content: [{ type: "text", text }], usage: { input_tokens, output_tokens } }
17 *
18 * So this module speaks that shape over an OpenAI-compatible `/chat/completions`
19 * endpoint. Nothing at any call site changes. One env var repoints the entire
20 * platform, which is the difference between a claim and a demo.
21 *
22 * WHAT THAT BUYS. The OpenAI chat-completions shape is the de-facto standard,
23 * so this one adapter reaches Ollama, LM Studio, vLLM, llama.cpp, OpenRouter,
24 * Together, Groq, Fireworks, Azure OpenAI and OpenAI itself — including models
25 * running entirely on the operator's own hardware, with no API key and no
26 * egress.
27 */
28
29/** Providers we can talk to. "openai" means any OpenAI-compatible endpoint. */
30export type AiProviderName = "anthropic" | "openai";
31
32/** Content blocks may arrive as a bare string or as Anthropic block objects. */
33type ContentBlock = { type?: string; text?: string } | string;
34
35export interface AiMessageRequest {
36 model: string;
37 max_tokens: number;
38 messages: Array<{ role: string; content: ContentBlock[] | string }>;
39 system?: string;
40 temperature?: number;
41 [key: string]: unknown;
42}
43
44export interface AiMessageResponse {
45 id: string;
46 model: string;
47 role: "assistant";
48 type: "message";
49 stop_reason: string | null;
50 content: Array<{ type: "text"; text: string }>;
51 usage: { input_tokens: number; output_tokens: number };
52}
53
54/** The slice of the Anthropic client the platform actually consumes. */
55export interface AiClientLike {
56 messages: { create(req: AiMessageRequest): Promise<AiMessageResponse> };
57}
58
59/**
60 * Flatten Anthropic-style content to plain text.
61 *
62 * Callers pass either a string or an array of blocks. Anything that is not a
63 * text block (an image, a tool_use) has no representation in a plain chat
64 * completion, so it is dropped rather than stringified — sending "[object
65 * Object]" to a model is worse than sending nothing, because it produces a
66 * confident answer about garbage instead of an obvious omission.
67 */
68export function flattenContent(content: ContentBlock[] | string): string {
69 if (typeof content === "string") return content;
70 if (!Array.isArray(content)) return "";
71 return content
72 .map((block) => {
73 if (typeof block === "string") return block;
74 if (block && block.type === "text" && typeof block.text === "string") {
75 return block.text;
76 }
77 return "";
78 })
79 .filter(Boolean)
80 .join("\n");
81}
82
83/** Translate an Anthropic-shaped request into an OpenAI chat-completions body. */
84export function toOpenAiBody(req: AiMessageRequest): Record<string, unknown> {
85 const messages: Array<{ role: string; content: string }> = [];
86 // Anthropic carries the system prompt as a top-level field; OpenAI wants it
87 // as the first message. Dropping it would silently discard the instructions
88 // that make most of these features work.
89 if (req.system) messages.push({ role: "system", content: req.system });
90 for (const m of req.messages) {
91 messages.push({
92 role: m.role === "assistant" ? "assistant" : "user",
93 content: flattenContent(m.content),
94 });
95 }
96 const body: Record<string, unknown> = {
97 model: req.model,
98 messages,
99 max_tokens: req.max_tokens,
100 };
101 if (typeof req.temperature === "number") body.temperature = req.temperature;
102 return body;
103}
104
105/** Translate an OpenAI chat-completions response back to the Anthropic shape. */
106export function fromOpenAiResponse(
107 raw: unknown,
108 fallbackModel: string
109): AiMessageResponse {
110 const r = (raw ?? {}) as Record<string, any>;
111 const choice = Array.isArray(r.choices) ? r.choices[0] : undefined;
112 const text: string =
113 (choice?.message?.content as string) ??
114 // Some servers (older llama.cpp builds) answer with `text` on the choice.
115 (choice?.text as string) ??
116 "";
117 const usage = (r.usage ?? {}) as Record<string, any>;
118 return {
119 id: typeof r.id === "string" ? r.id : "aiprov_unknown",
120 model: typeof r.model === "string" ? r.model : fallbackModel,
121 role: "assistant",
122 type: "message",
123 stop_reason: choice?.finish_reason ?? null,
124 content: [{ type: "text", text: typeof text === "string" ? text : "" }],
125 usage: {
126 // Cost tracking reads these. Absent counts become 0, not NaN — a NaN
127 // propagates into the spend ledger and poisons every later total.
128 input_tokens:
129 typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0,
130 output_tokens:
131 typeof usage.completion_tokens === "number"
132 ? usage.completion_tokens
133 : 0,
134 },
135 };
136}
137
138export interface OpenAiCompatibleOptions {
139 /** Root URL, with or without a trailing `/v1`. e.g. http://localhost:11434 */
140 baseUrl: string;
141 /** Optional — local runtimes such as Ollama need no key. */
142 apiKey?: string;
143 /** Injected in tests so no network is required. */
144 fetchImpl?: typeof fetch;
145 /** Request timeout in ms. */
146 timeoutMs?: number;
147}
148
149/** Normalise a base URL to a full chat-completions endpoint. */
150export function completionsUrl(baseUrl: string): string {
151 const trimmed = baseUrl.replace(/\/+$/, "");
152 if (/\/chat\/completions$/.test(trimmed)) return trimmed;
153 if (/\/v\d+$/.test(trimmed)) return `${trimmed}/chat/completions`;
154 return `${trimmed}/v1/chat/completions`;
155}
156
157/**
158 * A client satisfying `AiClientLike` against any OpenAI-compatible server.
159 */
160export function createOpenAiCompatibleClient(
161 opts: OpenAiCompatibleOptions
162): AiClientLike {
163 const doFetch = opts.fetchImpl ?? fetch;
164 const url = completionsUrl(opts.baseUrl);
165 const timeoutMs = opts.timeoutMs ?? 120_000;
166
167 return {
168 messages: {
169 async create(req: AiMessageRequest): Promise<AiMessageResponse> {
170 const headers: Record<string, string> = {
171 "content-type": "application/json",
172 };
173 if (opts.apiKey) headers.authorization = `Bearer ${opts.apiKey}`;
174
175 const controller = new AbortController();
176 const timer = setTimeout(() => controller.abort(), timeoutMs);
177 let res: Response;
178 try {
179 res = await doFetch(url, {
180 method: "POST",
181 headers,
182 body: JSON.stringify(toOpenAiBody(req)),
183 signal: controller.signal,
184 });
185 } finally {
186 clearTimeout(timer);
187 }
188
189 if (!res.ok) {
190 // Include the body: a bare status turns "your model name is wrong"
191 // into an unactionable 400, and self-hosted runtimes put the real
192 // reason in the body.
193 let detail = "";
194 try {
195 detail = (await res.text()).slice(0, 500);
196 } catch {
197 detail = "";
198 }
199 throw new Error(
200 `AI provider request failed (${res.status} ${res.statusText})` +
201 (detail ? `: ${detail}` : "")
202 );
203 }
204
205 return fromOpenAiResponse(await res.json(), req.model);
206 },
207 },
208 };
209}
Modifiedsrc/lib/concurrency.ts+15−0View fileUnifiedSplit
@@ -44,3 +44,18 @@ export async function mapWithConcurrency<T, R>(
4444 * collapsing a 300-item serial walk into ~25 sequential waves.
4545 */
4646export const DB_FANOUT_LIMIT = 12;
47
48/**
49 * In-flight cap for per-repo work that spawns git subprocesses.
50 *
51 * Lower than DB_FANOUT_LIMIT because the constraint is different: database
52 * fan-out waits on network round-trips and a connection pool, while git work
53 * competes for local CPU and disk. The dashboard fanned out over every repo an
54 * account owns with `Promise.all` — five git operations each, so a 47-repo
55 * account launched roughly 250 concurrent subprocesses. They did not run in
56 * parallel so much as contend, and nav-audit measured the page at 6943ms.
57 *
58 * Eight keeps the box responsive to other requests while still collapsing a
59 * long serial walk into a handful of waves.
60 */
61export const GIT_FANOUT_LIMIT = 8;
Modifiedsrc/lib/config.ts+34−0View fileUnifiedSplit
@@ -92,6 +92,40 @@ export const config = {
9292 get anthropicApiKey() {
9393 return process.env.ANTHROPIC_API_KEY || "";
9494 },
95 /**
96 * Model provider. "anthropic" (default) or "openai" for ANY
97 * OpenAI-compatible endpoint — Ollama, LM Studio, vLLM, llama.cpp,
98 * OpenRouter, Together, Groq, Azure OpenAI, OpenAI itself.
99 *
100 * Inferred rather than required: setting AI_BASE_URL without an Anthropic
101 * key is unambiguous intent, and making an operator set two variables to
102 * express one decision is how misconfiguration happens.
103 */
104 get aiProvider(): "anthropic" | "openai" {
105 const v = (process.env.AI_PROVIDER || "").trim().toLowerCase();
106 if (v === "openai" || v === "openai-compatible") return "openai";
107 if (v === "anthropic") return "anthropic";
108 if (!process.env.ANTHROPIC_API_KEY && process.env.AI_BASE_URL) {
109 return "openai";
110 }
111 return "anthropic";
112 },
113 /** Root URL of an OpenAI-compatible server, e.g. http://localhost:11434 */
114 get aiBaseUrl() {
115 return (process.env.AI_BASE_URL || "").trim();
116 },
117 /** Key for the OpenAI-compatible provider. Local runtimes need none. */
118 get aiApiKey() {
119 return process.env.AI_API_KEY || "";
120 },
121 /**
122 * Model id for the OpenAI-compatible provider. Required when using one:
123 * the platform's task router returns Claude model ids, which mean nothing
124 * to another backend.
125 */
126 get aiModel() {
127 return (process.env.AI_MODEL || "").trim();
128 },
95129 /** Email provider: "log" (dev), "resend", or "http" (the estate rail). */
96130 get emailProvider() {
97131 const v = (process.env.EMAIL_PROVIDER || "log").toLowerCase();
Modifiedsrc/lib/import-helper.ts+52−3View fileUnifiedSplit
@@ -309,12 +309,61 @@ export async function importOneRepo(
309309 };
310310 }
311311
312 // Record where this repo came from, so it cannot silently rot.
313 //
314 // THE BUG THIS CLOSES. Import cloned the objects and inserted the repo row
315 // and stopped. It never created a `repo_mirrors` row, so nothing ever
316 // synced again — while the imported description still said "mirror of
317 // <upstream>". The result is a repo that claims to track an upstream,
318 // looks live, and is frozen at the moment of import. Found on
319 // ccantynz/Vapron: imported 15 Jul, no human commit after 20 Jul, and the
320 // only later activity was this platform's own autorepair bot. Anyone
321 // reading it would reasonably believe it was current.
322 //
323 // That is the difference between "we host a copy of your GitHub repo" and
324 // "you can leave GitHub": a snapshot is a migration you cannot trust,
325 // whereas a live mirror is one you can verify before cutting over.
326 //
327 // CREDENTIALS. The stored upstream is the CLEAN url — never
328 // buildCloneUrl's token-bearing form, which would persist a plaintext
329 // credential in the database for anyone with table access. A private
330 // upstream therefore cannot be fetched unattended, so its mirror is
331 // created DISABLED rather than enabled-and-permanently-failing: a config
332 // that exists and honestly reports "off" beats one that retries forever
333 // and reports an error nobody reads.
334 //
335 // Best-effort: a mirror-config failure must never fail an import whose
336 // objects and row are already committed.
337 try {
338 const { upsertMirror } = await import("./mirrors");
339 const res = await upsertMirror({
340 repositoryId: created.id,
341 upstreamUrl: cloneUrl,
342 isEnabled: !isPrivate,
343 });
344 if (!res.ok) {
345 console.warn(
346 `[import] mirror config not created for ${ownerUsername}/${safeName}: ${res.error}`
347 );
348 }
349 } catch (err) {
350 console.warn(
351 `[import] mirror config threw for ${ownerUsername}/${safeName}:`,
352 err instanceof Error ? err.message : err
353 );
354 }
355
356 const syncNote = isPrivate
357 ? " · upstream recorded, auto-sync off (private upstream needs credentials)"
358 : " · auto-sync on";
359
312360 return {
313361 status: "success",
314362 name: safeName,
315 notes: adopted
316 ? `Adopted an orphaned clone from a previous failed import (${commitCount} commits)`
317 : `Cloned + indexed (${commitCount} commits)`,
363 notes:
364 (adopted
365 ? `Adopted an orphaned clone from a previous failed import (${commitCount} commits)`
366 : `Cloned + indexed (${commitCount} commits)`) + syncNote,
318367 };
319368 } catch (err) {
320369 return {
Modifiedsrc/routes/dashboard.tsx+139−40View fileUnifiedSplit
@@ -26,6 +26,7 @@ import {
2626 gateRuns,
2727 issues,
2828 pullRequests,
29 stars,
2930} from "../db/schema";
3031import { Layout } from "../views/layout";
3132import { RepoHeader, RepoNav } from "../views/components";
@@ -49,6 +50,8 @@ import {
4950 type AiSavingsLifetimeReport,
5051} from "../lib/ai-hours-saved";
5152import { totalOpenIssues } from "../lib/issue-counts";
53import { mapWithConcurrency, GIT_FANOUT_LIMIT } from "../lib/concurrency";
54import { cached, gitCache, type LRUCache } from "../lib/cache";
5255import { isSiteAdmin } from "../lib/admin";
5356import {
5457 buildRecentActivity,
@@ -315,7 +318,7 @@ dashboard.get("/dashboard", requireAuth, async (c) => {
315318 // scripts/agent-journey.ts creates a `journey-<14 digits>-<4 chars>` repo
316319 // per run and deletes it in its last step, but a run that dies mid-flight
317320 // leaves the repo behind — same defensive filter as /explore.
318 const repos = await db
321 let repos = await db
319322 .select()
320323 .from(repositories)
321324 .where(
@@ -326,6 +329,36 @@ dashboard.get("/dashboard", requireAuth, async (c) => {
326329 )
327330 .orderBy(desc(repositories.updatedAt));
328331
332 // Favourites first, then most-recently-active.
333 //
334 // The list was ordered purely by updatedAt, so the repos someone actually
335 // works in every day sank below whatever last received a push — including
336 // bot commits. On a 47-repo account that means scrolling to find your own
337 // main project, which is the complaint.
338 //
339 // Favourites reuse the existing `stars` table rather than introducing a
340 // second pinning concept: the star control already exists on every repo
341 // page, so a user who has starred anything gets the benefit immediately
342 // and there is one source of truth for "repos I care about".
343 let starredRepoIds = new Set<string>();
344 try {
345 const starRows = await db
346 .select({ repositoryId: stars.repositoryId })
347 .from(stars)
348 .where(eq(stars.userId, user.id));
349 starredRepoIds = new Set(starRows.map((r) => r.repositoryId));
350 } catch {
351 // A failed lookup must not cost the user their dashboard — fall back to
352 // the previous ordering rather than rendering an error.
353 }
354 // Stable partition: the query already returns updatedAt-descending, so
355 // preserving relative order inside each group keeps "most recent" intact
356 // within favourites and within the rest.
357 repos = [
358 ...repos.filter((r) => starredRepoIds.has(r.id)),
359 ...repos.filter((r) => !starredRepoIds.has(r.id)),
360 ];
361
329362 // "Open Issues" below used to sum repositories.issueCount, which is
330363 // incremented in eight places and decremented in none — closing an issue
331364 // never touches it. It is an issues-ever-created total, so the card was
@@ -336,47 +369,77 @@ dashboard.get("/dashboard", requireAuth, async (c) => {
336369 repos.reduce((s, r) => s + (r.issueCount || 0), 0)
337370 );
338371
339 // Compute health scores for all repos (in parallel)
340 const repoData = await Promise.all(
341 repos.map(async (repo) => {
342 let healthScore = 0;
343 let healthGrade = "?" as string;
344 let recentCommits = 0;
345 let branchCount = 0;
346 let ciConfig = null;
347
348 try {
349 if (await repoExists(user.username, repo.name)) {
350 const ref =
351 (await getDefaultBranch(user.username, repo.name)) || "main";
352 const [health, commits, branches, ci] = await Promise.all([
353 computeHealthScore(user.username, repo.name).catch(() => null),
354 listCommits(user.username, repo.name, ref, 5).catch(() => []),
355 listBranches(user.username, repo.name).catch(() => []),
356 detectCIConfig(user.username, repo.name, ref).catch(() => null),
357 ]);
358 if (health) {
359 healthScore = health.score;
360 healthGrade = health.grade;
372 // Per-repo health, cached and width-limited.
373 //
374 // This block was the dashboard's whole cost: `Promise.all` over every repo
375 // the account owns, each doing repoExists + getDefaultBranch + four more git
376 // operations. On a 47-repo account that is ~250 git subprocesses launched
377 // simultaneously, and nav-audit measured the page at 6943ms — the slowest
378 // surface on the platform, and the first one a signed-in user sees.
379 //
380 // Two changes, no behaviour difference:
381 // - Cache the bundle in gitCache under the repo's cache prefix, keyed by
382 // updatedAt so a push supersedes it immediately. `invalidateRepoCache`
383 // already clears `owner/repo:` on push, so this inherits invalidation
384 // rather than inventing a second scheme that could go stale.
385 // - Cap the fan-out. These tasks contend for CPU and disk, so running 250
386 // at once is slower than running eight at a time, and it stops one page
387 // load from starving the rest of the server.
388 const repoData = await mapWithConcurrency(repos, GIT_FANOUT_LIMIT, async (repo) => {
389 const stats = await cached(
390 gitCache as unknown as LRUCache<{
391 healthScore: number;
392 healthGrade: string;
393 recentCommits: number;
394 branchCount: number;
395 ciConfig: Awaited<ReturnType<typeof detectCIConfig>> | null;
396 }>,
397 `${user.username}/${repo.name}:dashboard-stats:${
398 repo.updatedAt ? new Date(repo.updatedAt).getTime() : 0
399 }`,
400 async () => {
401 let healthScore = 0;
402 let healthGrade = "?" as string;
403 let recentCommits = 0;
404 let branchCount = 0;
405 let ciConfig: Awaited<ReturnType<typeof detectCIConfig>> | null = null;
406
407 try {
408 if (await repoExists(user.username, repo.name)) {
409 const ref =
410 (await getDefaultBranch(user.username, repo.name)) || "main";
411 const [health, commits, branches, ci] = await Promise.all([
412 computeHealthScore(user.username, repo.name).catch(() => null),
413 listCommits(user.username, repo.name, ref, 5).catch(() => []),
414 listBranches(user.username, repo.name).catch(() => []),
415 detectCIConfig(user.username, repo.name, ref).catch(() => null),
416 ]);
417 if (health) {
418 healthScore = health.score;
419 healthGrade = health.grade;
420 }
421 recentCommits = commits.length;
422 branchCount = branches.length;
423 ciConfig = ci;
361424 }
362 recentCommits = commits.length;
363 branchCount = branches.length;
364 ciConfig = ci;
425 } catch {
426 // Best effort, per repo — one unreadable repo must never blank the
427 // whole dashboard.
365428 }
366 } catch {
367 // best effort
429
430 return { healthScore, healthGrade, recentCommits, branchCount, ciConfig };
368431 }
432 );
369433
370 return {
371 repo,
372 healthScore,
373 healthGrade,
374 recentCommits,
375 branchCount,
376 ciConfig,
377 };
378 })
379 );
434 return {
435 repo,
436 healthScore: stats.healthScore,
437 healthGrade: stats.healthGrade,
438 recentCommits: stats.recentCommits,
439 branchCount: stats.branchCount,
440 ciConfig: stats.ciConfig,
441 };
442 });
380443
381444 // Block L9 — AI hours-saved counter. Pull both window + lifetime in
382445 // parallel; both helpers swallow DB errors so the dashboard always renders.
@@ -814,7 +877,14 @@ git push -u gluecron main</code></pre>
814877 ) : (
815878 <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: var(--space-4); margin-bottom: var(--space-8)">
816879 {repoData.map(({ repo, healthScore, healthGrade, recentCommits, branchCount, ciConfig }) => (
817 <div class="card" style="padding: 0; overflow: hidden">
880 <div
881 class="card"
882 style={`padding: 0; overflow: hidden${
883 starredRepoIds.has(repo.id)
884 ? "; border-color: var(--accent)"
885 : ""
886 }`}
887 >
818888 {/* Health bar at top */}
819889 <div
820890 style={`height: 4px; background: ${gradeColor(healthGrade)}; width: ${healthScore}%; transition: width 0.3s`}
@@ -822,8 +892,37 @@ git push -u gluecron main</code></pre>
822892 <div style="padding: var(--space-4)">
823893 <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px">
824894 <div style="flex: 1; min-width: 0">
825 <h3 style="font-size: 16px; margin-bottom: 2px">
895 <h3 style="font-size: 16px; margin-bottom: 2px; display: flex; align-items: center; gap: 6px">
826896 <a href={`/${user.username}/${repo.name}`}>{repo.name}</a>
897 {/* Toggling here re-sorts the list on the next load, so
898 the ordering is something you control rather than
899 something that happens to you. */}
900 <form
901 method="post"
902 action={`/${user.username}/${repo.name}/star`}
903 style="display: inline; line-height: 1"
904 >
905 <button
906 type="submit"
907 title={
908 starredRepoIds.has(repo.id)
909 ? "Remove from favourites"
910 : "Add to favourites — pins it to the top"
911 }
912 aria-label={
913 starredRepoIds.has(repo.id)
914 ? `Remove ${repo.name} from favourites`
915 : `Add ${repo.name} to favourites`
916 }
917 style={`background: none; border: 0; cursor: pointer; padding: 0; font-size: 14px; line-height: 1; color: ${
918 starredRepoIds.has(repo.id)
919 ? "var(--accent)"
920 : "var(--text-muted)"
921 }`}
922 >
923 {starredRepoIds.has(repo.id) ? "★" : "☆"}
924 </button>
925 </form>
827926 </h3>
828927 {repo.description && (
829928 <p class="dash-card-desc" title={repo.description}>
830929
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts