From c9fc834f4e80a94c40a0d23ea0bfe5f7bffc12e2 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 09:53:24 -0700 Subject: [PATCH 1/4] Fix crash log file not found error in spike Use getConfig("dataDir") and ensure directory exists before writing crash log, fixing ENOENT error when spike crashes. Co-Authored-By: Claude Haiku 4.5 --- packages/spike/src/discord/crash.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/spike/src/discord/crash.ts b/packages/spike/src/discord/crash.ts index 470b87d..d7bda1a 100644 --- a/packages/spike/src/discord/crash.ts +++ b/packages/spike/src/discord/crash.ts @@ -1,5 +1,12 @@ +import { mkdirSync } from "node:fs" +import { getConfig } from "../config" import { log } from "../log" +const crashLogDir = getConfig("dataDir") +mkdirSync(crashLogDir, { recursive: true }) + +const crashLogPath = `${crashLogDir}/crash.log` + export const logCrash = async (error: unknown) => { try { const stack = error instanceof Error ? error.stack : "" @@ -8,7 +15,7 @@ export const logCrash = async (error: unknown) => { const crashLog = `Spike crashed at ${new Date().toISOString()}:\n${message}\n${stack ?? ""}\n` // overwrite the crash log file - const file = Bun.file(`${process.env.DATA_DIR}/crash.log`) + const file = Bun.file(crashLogPath) file.write(crashLog) } catch (writeError) { log({ type: "error", error: writeError, context: "writing crash log" }) @@ -33,7 +40,7 @@ export const alertAboutCrashLog = async (client: any) => { const clearCrashLog = async () => { try { - const file = Bun.file(`${process.env.DATA_DIR}/crash.log`) + const file = Bun.file(crashLogPath) if (!(await file.exists())) return const contents = await file.text() await file.write("") From d12ee5c52dfe7def7a504764d2f92eaafee1fc0c Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 10:02:17 -0700 Subject: [PATCH 2/4] Fix Discord message rendering: remove stray blockquote character The empty blockquote line after the repo name was rendering as a literal ">" in Discord. Add a trailing space so it renders as a blank line within the blockquote instead. Co-Authored-By: Claude Haiku 4.5 --- packages/spike/src/bridge/webhook-handler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spike/src/bridge/webhook-handler.ts b/packages/spike/src/bridge/webhook-handler.ts index c58601b..cbcbc9f 100644 --- a/packages/spike/src/bridge/webhook-handler.ts +++ b/packages/spike/src/bridge/webhook-handler.ts @@ -116,8 +116,8 @@ class PRHandler { let message = ` > ### [${pullRequest.title}](<${pullRequest.html_url}>) > **${repositoryFullName}** - > - ${body +> +${body .split("\n") .map((line) => `> ${line}`) .join("\n")} From 6555be012abde0047ea9eac998a63cbb175281f8 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 10:25:47 -0700 Subject: [PATCH 3/4] Add richer metadata to logs and expandable detail panel Enriches webhook logs with sender and ref for better visibility into who triggered events and what they affected (e.g. which branch). PR logs now include the title, comment logs include the first 200 characters of the comment body. Adds an expandable detail row in the log viewer UI that shows all metadata as key-value pairs when clicked, keeping the summary line clean. Co-Authored-By: Claude Haiku 4.5 --- packages/spike/src/bridge/webhook-handler.ts | 4 +- packages/spike/src/log.ts | 9 ++- packages/spike/src/server/index.tsx | 4 +- packages/spike/src/server/logs.tsx | 59 ++++++++++++++++---- 4 files changed, 60 insertions(+), 16 deletions(-) diff --git a/packages/spike/src/bridge/webhook-handler.ts b/packages/spike/src/bridge/webhook-handler.ts index c58601b..5ea4666 100644 --- a/packages/spike/src/bridge/webhook-handler.ts +++ b/packages/spike/src/bridge/webhook-handler.ts @@ -60,7 +60,7 @@ class PRHandler { await this.handleStateChange(pullRequest, repository) } - log({ type: "pr", action, pr: payload.number, repo: repository.full_name, user: pullRequest.user.login }) + log({ type: "pr", action, pr: payload.number, repo: repository.full_name, user: pullRequest.user.login, title: pullRequest.title }) } static async handleOpened(pullRequest: Gitea.PullRequest, repository: Gitea.Repository) { @@ -153,7 +153,7 @@ class CommentHandler { await this.handleDeleted(comment) } - log({ type: "comment", action, pr: issue.number, repo: repository.full_name, user: comment.user.login }) + log({ type: "comment", action, pr: issue.number, repo: repository.full_name, user: comment.user.login, body: comment.body.slice(0, 200) }) } static async handleCreated(issue: Gitea.Issue, comment: Gitea.Comment, repo: string) { diff --git a/packages/spike/src/log.ts b/packages/spike/src/log.ts index 8310a8c..e6b1669 100644 --- a/packages/spike/src/log.ts +++ b/packages/spike/src/log.ts @@ -3,10 +3,10 @@ import { basename } from "node:path" import { getConfig } from "./config" export type LogEvent = - | { type: "webhook"; eventType: string; repo: string } + | { type: "webhook"; eventType: string; repo: string; sender?: string; ref?: string } | { type: "webhook-ignored"; eventType: string; repo: string } - | { type: "pr"; action: string; pr: number; repo: string; user: string } - | { type: "comment"; action: string; pr: number; repo: string; user: string } + | { type: "pr"; action: string; pr: number; repo: string; user: string; title?: string } + | { type: "comment"; action: string; pr: number; repo: string; user: string; body?: string } | { type: "comment-skipped"; pr: number; repo: string; commentId: number } | { type: "review"; action: string; pr: number; repo: string; user: string } | { type: "thread-auto-created"; pr: number; repo: string } @@ -96,6 +96,9 @@ onLog((event) => { if ("repo" in event) parts.push(dim(event.repo)) if ("pr" in event) parts.push(cyan(`#${event.pr}`)) if ("user" in event) parts.push(dim(event.user)) + if ("sender" in event && event.sender) parts.push(dim(event.sender)) + if ("ref" in event && event.ref) parts.push(dim(event.ref)) + if ("title" in event && event.title) parts.push(event.title) if ("detail" in event) parts.push(event.detail) if ("command" in event) parts.push(event.command) if ("eventType" in event) parts.push(event.eventType) diff --git a/packages/spike/src/server/index.tsx b/packages/spike/src/server/index.tsx index 7ad741e..ff596be 100644 --- a/packages/spike/src/server/index.tsx +++ b/packages/spike/src/server/index.tsx @@ -18,7 +18,9 @@ const server = serve({ const payload = await req.json() const eventType = req.headers.get("X-Gitea-Event") || "unknown" const repo = (payload as any)?.repository?.full_name || "unknown" - log({ type: "webhook", eventType, repo }) + const sender = (payload as any)?.sender?.login as string | undefined + const ref = (payload as any)?.ref as string | undefined + log({ type: "webhook", eventType, repo, sender, ref }) try { await handleGiteaWebhook(payload, eventType as any) diff --git a/packages/spike/src/server/logs.tsx b/packages/spike/src/server/logs.tsx index 0a2975f..c464524 100644 --- a/packages/spike/src/server/logs.tsx +++ b/packages/spike/src/server/logs.tsx @@ -17,7 +17,19 @@ const typeColors: Record = { error: "#f87171", } -function EventRow({ event }: { event: StoredLogEvent }) { +const summaryFields = ["type", "ts"] as const + +function getEventMeta(event: StoredLogEvent): Record { + const meta: Record = {} + for (const [key, value] of Object.entries(event)) { + if (summaryFields.includes(key as any)) continue + if (value === undefined) continue + meta[key] = typeof value === "string" ? value : String(value) + } + return meta +} + +function EventRow({ event, index }: { event: StoredLogEvent; index: number }) { const color = typeColors[event.type] || "#9ca3af" const time = event.ts.slice(11, 19) @@ -35,14 +47,41 @@ function EventRow({ event }: { event: StoredLogEvent }) { details.push(msg) } + const meta = getEventMeta(event) + const hasExtra = Object.keys(meta).length > 0 + return ( - - {time} - - {event.type} - - {details.join(" ")} - + <> + + + {hasExtra && +} + + {time} + + {event.type} + + {details.join(" ")} + + {hasExtra && ( + + + +
+ {Object.entries(meta).map(([key, value]) => ( +
+ {key} + {value} +
+ ))} +
+ + + )} + ) } @@ -154,8 +193,8 @@ export function LogsPage(req: Request) { {/* Events table */} - {events.map((event) => ( - + {events.map((event, i) => ( + ))}
From f750663eca8018c20c4501270cdc772c8de1f2bb Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 10:58:58 -0700 Subject: [PATCH 4/4] Add content negotiation for raw JSONL logs endpoint The /logs endpoint now returns different formats based on Accept headers: - Browsers (Accept: text/html) get the interactive HTML log viewer - Agents/fetch requests get raw JSONL when no Accept header includes text/html - Without a file parameter, agents get a JSON array of available log files Also improved security: invalid filenames now throw errors (500) instead of silently failing (404). --- packages/spike/src/log.ts | 19 ++++++++++++------- packages/spike/src/server/index.tsx | 21 +++++++++++++++++++-- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/packages/spike/src/log.ts b/packages/spike/src/log.ts index e6b1669..a0ed52a 100644 --- a/packages/spike/src/log.ts +++ b/packages/spike/src/log.ts @@ -59,17 +59,22 @@ export function listLogFiles(): string[] { } export function readLogFile(filename: string): StoredLogEvent[] { - // Reject path traversal attempts (e.g. "../../../etc/passwd") - if (filename !== basename(filename)) return [] + const raw = readLogFileRaw(filename) + if (!raw) return [] + return raw + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as StoredLogEvent) +} + +export function readLogFileRaw(filename: string): string | undefined { + if (filename !== basename(filename)) throw new Error("Invalid filename") const path = `${logsDir}/${filename}` try { return readFileSync(path, "utf-8") - .trim() - .split("\n") - .filter(Boolean) - .map((line) => JSON.parse(line) as StoredLogEvent) } catch { - return [] + return } } diff --git a/packages/spike/src/server/index.tsx b/packages/spike/src/server/index.tsx index ff596be..7f1a7dd 100644 --- a/packages/spike/src/server/index.tsx +++ b/packages/spike/src/server/index.tsx @@ -1,7 +1,7 @@ import { serve } from "bun" import { handleGiteaWebhook } from "../bridge" import { startDiscord } from "../discord" -import { log } from "../log" +import { listLogFiles, log, readLogFileRaw } from "../log" import { LogsPage } from "./logs" await startDiscord() @@ -32,7 +32,24 @@ const server = serve({ }, }, "/logs": { - GET: (req) => LogsPage(req), + GET: (req) => { + const accept = req.headers.get("Accept") || "" + const wantsRaw = !accept.includes("text/html") + if (!wantsRaw) return LogsPage(req) + + const file = new URL(req.url).searchParams.get("file") + if (!file) { + const files = listLogFiles() + return Response.json(files) + } + try { + const raw = readLogFileRaw(file) + if (!raw) return new Response("Not found", { status: 404 }) + return new Response(raw, { headers: { "Content-Type": "application/jsonl" } }) + } catch { + return new Response("Internal server error", { status: 500 }) + } + }, }, "/discord/auth": async () => { const permissions = 536870912 // from https://discord.com/developers/applications