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

prod-signals.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.

prod-signals.tsBlame412 lines · 1 contributor
a6d8fd5Claude1/**
2 * Block K9 — Production + test signal ingestion.
3 *
4 * Crontech (prod runtime), Gatetest (test runner), Sentry (external APM),
5 * and manual callers post per-commit error signals back into Gluecron so
6 * commits / PRs can be annotated with real-world failure data and so the
7 * autonomous agent loops (fix, heal_bot) have something concrete to chase.
8 *
9 * Shape follows the commit-statuses template: pure helpers at the top,
10 * DB helpers under the divider. DB helpers are defensive — never throw,
11 * errors route to console.error so the caller's primary flow survives a
12 * bad DB hop.
13 */
14
15import { createHash } from "node:crypto";
16import { and, desc, eq, gte, sql } from "drizzle-orm";
17import { db } from "../db";
18
19// NOTE: `prodSignals` is declared in src/db/schema.ts by the main thread
20// (see the snippet delivered alongside this file). We resolve it lazily so
21// the pure helpers in this module remain importable even if a session
22// lands the code before the schema edit is merged. Once schema.ts exports
23// `prodSignals`, the runtime import resolves on first DB call.
24let _prodSignals: any;
25async function prodSignalsTable(): Promise<any> {
26 if (_prodSignals) return _prodSignals;
27 const schema = await import("../db/schema");
28 _prodSignals = (schema as any).prodSignals;
29 if (!_prodSignals) {
30 throw new Error(
31 "prodSignals table not exported from db/schema.ts (Block K9). " +
32 "See lib/prod-signals.ts header for the snippet to paste."
33 );
34 }
35 return _prodSignals;
36}
37// Re-export for convenience in route code that wants the symbol directly.
38export async function _getProdSignalsTable() {
39 return prodSignalsTable();
40}
41
42export type SignalSource = "crontech" | "gatetest" | "sentry" | "manual";
43
44export const SIGNAL_SOURCES: SignalSource[] = [
45 "crontech",
46 "gatetest",
47 "sentry",
48 "manual",
49];
50
51export type SignalKind =
52 | "runtime_error"
53 | "test_failure"
54 | "deploy_failure"
55 | "performance"
56 | "security";
57
58export type SignalSeverity = "info" | "warning" | "error" | "critical";
59export type SignalStatus = "open" | "dismissed" | "resolved";
60
61const MESSAGE_MAX = 4000;
62const STACK_MAX = 16_000;
63const FRAME_MAX = 512;
64const HASH_LEN = 16;
65
66/** Git short-sha / full-sha sanity check. Hex, 7–64. */
67export function isValidSha(sha: string | null | undefined): boolean {
68 if (!sha) return false;
69 if (typeof sha !== "string") return false;
70 if (sha.length < 7 || sha.length > 64) return false;
71 return /^[a-f0-9]+$/i.test(sha);
72}
73
74/**
75 * Normalise an error message for grouping. Collapses whitespace, strips
76 * volatile fragments (hex pointers, line/col tails, numeric-only tokens)
77 * so two occurrences of the same bug from different runs hash identically.
78 */
79function normaliseMessage(msg: string): string {
80 return msg
81 .trim()
82 .replace(/\s+/g, " ")
83 // Strip hex pointers like 0x7fffabcd
84 .replace(/0x[0-9a-f]+/gi, "0x_")
85 // Strip trailing :line:col noise
86 .replace(/:\d+:\d+\b/g, ":_:_")
87 .slice(0, MESSAGE_MAX);
88}
89
90/**
91 * Extract the top, user-meaningful stack frame. Skips blank lines and any
92 * line containing `node_modules`. Returns "" on malformed / empty input.
93 * Capped at FRAME_MAX chars.
94 */
95export function extractTopFrame(stackTrace: string | null | undefined): string {
96 if (!stackTrace || typeof stackTrace !== "string") return "";
97 const lines = stackTrace.split(/\r?\n/);
98 for (const raw of lines) {
99 const line = raw.trim();
100 if (!line) continue;
101 if (line.includes("node_modules")) continue;
102 return line.slice(0, FRAME_MAX);
103 }
104 return "";
105}
106
107/**
108 * Stable 16-hex-char fingerprint of `message + " @ " + topFrame` used as
109 * the grouping key for count-bumping. Deterministic across processes.
110 */
111export function hashError(
112 message: string | null | undefined,
113 topStackFrame: string | null | undefined
114): string {
115 const m = normaliseMessage(String(message ?? ""));
116 const f = String(topStackFrame ?? "").trim().slice(0, FRAME_MAX);
117 const h = createHash("sha256").update(`${m} @ ${f}`).digest("hex");
118 return h.slice(0, HASH_LEN);
119}
120
121/** Allow-list the source tag. Unknown / empty → "manual". */
122export function sanitiseSource(source: unknown): SignalSource {
123 if (typeof source !== "string") return "manual";
124 const s = source.trim().toLowerCase();
125 if ((SIGNAL_SOURCES as string[]).includes(s)) return s as SignalSource;
126 return "manual";
127}
128
129const VALID_KINDS: SignalKind[] = [
130 "runtime_error",
131 "test_failure",
132 "deploy_failure",
133 "performance",
134 "security",
135];
136
137export function sanitiseKind(kind: unknown): SignalKind {
138 if (typeof kind !== "string") return "runtime_error";
139 const k = kind.trim().toLowerCase();
140 if ((VALID_KINDS as string[]).includes(k)) return k as SignalKind;
141 return "runtime_error";
142}
143
144const VALID_SEVERITIES: SignalSeverity[] = [
145 "info",
146 "warning",
147 "error",
148 "critical",
149];
150
151export function sanitiseSeverity(sev: unknown): SignalSeverity {
152 if (typeof sev !== "string") return "error";
153 const s = sev.trim().toLowerCase();
154 if ((VALID_SEVERITIES as string[]).includes(s)) return s as SignalSeverity;
155 return "error";
156}
157
158// ---------- DB helpers ----------
159
160export interface RecordSignalInput {
161 repositoryId: string;
162 commitSha: string;
163 source: SignalSource | string;
164 kind: SignalKind | string;
165 message: string;
166 stackTrace?: string | null;
167 deployId?: string | null;
168 environment?: string | null;
169 severity?: SignalSeverity | string | null;
170 samplePayload?: string | null;
171}
172
173export interface RecordSignalResult {
174 id: string;
175 status: SignalStatus;
176 count: number;
177 bumped: boolean;
178}
179
180/**
181 * Insert or bump-count a signal. Keyed on (repository_id, error_hash).
182 * Repeated posts of the same bug increment `count` and push `last_seen`
183 * forward without creating new rows — cheap idempotency for noisy
184 * runtimes. Defensive: returns null on any DB failure.
185 */
186export async function recordSignal(
187 input: RecordSignalInput
188): Promise<RecordSignalResult | null> {
189 try {
190 if (!isValidSha(input.commitSha)) return null;
191 const sha = input.commitSha.toLowerCase();
192 const source = sanitiseSource(input.source);
193 const kind = sanitiseKind(input.kind);
194 const severity = sanitiseSeverity(input.severity);
195 const message = (input.message || "").slice(0, MESSAGE_MAX);
196 const stackTrace = input.stackTrace
197 ? input.stackTrace.slice(0, STACK_MAX)
198 : null;
199 const topFrame = extractTopFrame(stackTrace);
200 const errorHash = hashError(message, topFrame);
201 const prodSignals = await prodSignalsTable();
202
203 // Check for existing row with the same (repo, hash).
204 const [existing] = await db
205 .select()
206 .from(prodSignals)
207 .where(
208 and(
209 eq(prodSignals.repositoryId, input.repositoryId),
210 eq(prodSignals.errorHash, errorHash)
211 )
212 )
213 .limit(1);
214
215 if (existing) {
216 const [bumped] = await db
217 .update(prodSignals)
218 .set({
219 count: (existing.count || 0) + 1,
220 lastSeen: new Date(),
221 // Most recent commit wins — if the bug keeps happening on newer
222 // shas the view should reflect that.
223 commitSha: sha,
224 })
225 .where(eq(prodSignals.id, existing.id))
226 .returning();
227 if (!bumped) return null;
228 return {
229 id: bumped.id,
230 status: (bumped.status || "open") as SignalStatus,
231 count: bumped.count || 1,
232 bumped: true,
233 };
234 }
235
236 const [row] = await db
237 .insert(prodSignals)
238 .values({
239 repositoryId: input.repositoryId,
240 commitSha: sha,
241 errorHash,
242 source,
243 kind,
244 severity,
245 message,
246 stackTrace,
247 deployId: input.deployId || null,
248 environment: input.environment || null,
249 samplePayload: input.samplePayload || null,
250 })
251 .returning();
252 if (!row) return null;
253 return {
254 id: row.id,
255 status: (row.status || "open") as SignalStatus,
256 count: row.count || 1,
257 bumped: false,
258 };
259 } catch (err) {
260 console.error("[prod-signals] recordSignal:", err);
261 return null;
262 }
263}
264
265export async function listSignalsForCommit(
266 repositoryId: string,
267 commitSha: string,
268 limit = 50
269): Promise<any[]> {
270 try {
271 if (!isValidSha(commitSha)) return [];
272 const prodSignals = await prodSignalsTable();
273 return await db
274 .select()
275 .from(prodSignals)
276 .where(
277 and(
278 eq(prodSignals.repositoryId, repositoryId),
279 eq(prodSignals.commitSha, commitSha.toLowerCase())
280 )
281 )
282 .orderBy(desc(prodSignals.lastSeen))
283 .limit(limit);
284 } catch (err) {
285 console.error("[prod-signals] listSignalsForCommit:", err);
286 return [];
287 }
288}
289
290export async function listOpenSignalsForRepo(
291 repositoryId: string,
292 limit = 100
293): Promise<any[]> {
294 try {
295 const prodSignals = await prodSignalsTable();
296 return await db
297 .select()
298 .from(prodSignals)
299 .where(
300 and(
301 eq(prodSignals.repositoryId, repositoryId),
302 eq(prodSignals.status, "open")
303 )
304 )
305 .orderBy(desc(prodSignals.lastSeen))
306 .limit(limit);
307 } catch (err) {
308 console.error("[prod-signals] listOpenSignalsForRepo:", err);
309 return [];
310 }
311}
312
313/**
314 * PR-scoped listing. Pragmatic v1: rather than shelling out to `git log
315 * base..head`, we return signals in the repo created after the PR's
316 * creation timestamp. Noisy-but-useful — the agent fix loop filters
317 * further by commit_sha once it has the PR's commit list from git.
318 */
319export async function listSignalsForPr(
320 repositoryId: string,
321 _baseSha: string,
322 _headSha: string,
323 prCreatedAt: Date | null = null,
324 limit = 50
325): Promise<any[]> {
326 try {
327 const since = prCreatedAt || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
328 const prodSignals = await prodSignalsTable();
329 return await db
330 .select()
331 .from(prodSignals)
332 .where(
333 and(
334 eq(prodSignals.repositoryId, repositoryId),
335 gte(prodSignals.createdAt, since)
336 )
337 )
338 .orderBy(desc(prodSignals.lastSeen))
339 .limit(limit);
340 } catch (err) {
341 console.error("[prod-signals] listSignalsForPr:", err);
342 return [];
343 }
344}
345
346export async function dismissSignal(id: string): Promise<boolean> {
347 try {
348 const prodSignals = await prodSignalsTable();
349 const [row] = await db
350 .update(prodSignals)
351 .set({ status: "dismissed" })
352 .where(eq(prodSignals.id, id))
353 .returning();
354 return !!row;
355 } catch (err) {
356 console.error("[prod-signals] dismissSignal:", err);
357 return false;
358 }
359}
360
361export async function resolveSignal(
362 id: string,
363 resolvedByCommit?: string | null
364): Promise<boolean> {
365 try {
366 const commit =
367 resolvedByCommit && isValidSha(resolvedByCommit)
368 ? resolvedByCommit.toLowerCase()
369 : null;
370 const prodSignals = await prodSignalsTable();
371 const [row] = await db
372 .update(prodSignals)
373 .set({
374 status: "resolved",
375 resolvedAt: new Date(),
376 resolvedByCommit: commit,
377 })
378 .where(eq(prodSignals.id, id))
379 .returning();
380 return !!row;
381 } catch (err) {
382 console.error("[prod-signals] resolveSignal:", err);
383 return false;
384 }
385}
386
387/** Count total signals in a repo — for badges / nav. Defensive. */
388export async function countOpenSignals(repositoryId: string): Promise<number> {
389 try {
390 const prodSignals = await prodSignalsTable();
391 const [r] = await db
392 .select({ n: sql<number>`count(*)::int` })
393 .from(prodSignals)
394 .where(
395 and(
396 eq(prodSignals.repositoryId, repositoryId),
397 eq(prodSignals.status, "open")
398 )
399 );
400 return Number(r?.n || 0);
401 } catch {
402 return 0;
403 }
404}
405
406export const __internal = {
407 MESSAGE_MAX,
408 STACK_MAX,
409 FRAME_MAX,
410 HASH_LEN,
411 normaliseMessage,
412};