From 446ecfa449010a34024bb7285189b4cc22bb262d Mon Sep 17 00:00:00 2001 From: ethanshenly Date: Sun, 15 Feb 2026 12:25:46 -0500 Subject: [PATCH 1/6] gollum read endpoints --- src/cli/agents/ralph-serve-entry.ts | 1 + src/cli/agents/ralph-server.test.ts | 71 ++++++++++++++++++++++++++++- src/cli/agents/ralph-server.ts | 44 +++++++++++++++++- 3 files changed, 113 insertions(+), 3 deletions(-) diff --git a/src/cli/agents/ralph-serve-entry.ts b/src/cli/agents/ralph-serve-entry.ts index 9ae2ae3..0297449 100644 --- a/src/cli/agents/ralph-serve-entry.ts +++ b/src/cli/agents/ralph-serve-entry.ts @@ -77,6 +77,7 @@ export async function runRalphDaemon(argv: string[]) { port, bus, prefix, + workspace, onPrompt: async (prompt: string, runId: string) => { const publisher = taggedPublisher(bus, runId); const shortId = runId.slice(0, 16); diff --git a/src/cli/agents/ralph-server.test.ts b/src/cli/agents/ralph-server.test.ts index 9971ac6..8ebc386 100644 --- a/src/cli/agents/ralph-server.test.ts +++ b/src/cli/agents/ralph-server.test.ts @@ -1,11 +1,12 @@ import { test, expect, beforeEach, afterEach } from "bun:test"; -import { mkdirSync, rmSync } from "fs"; +import { mkdirSync, rmSync, writeFileSync } from "fs"; import { join } from "path"; import { createBus } from "./bus"; import { startRalphServer } from "./ralph-server"; import type { EventBus } from "./bus"; let tmpDir: string; +let workspaceDir: string; let bus: EventBus; let server: ReturnType; let baseUrl: string; @@ -15,12 +16,17 @@ beforeEach(() => { tmpDir = join("/tmp", `ralph-test-${crypto.randomUUID()}`); mkdirSync(join(tmpDir, "jobs"), { recursive: true }); mkdirSync(join(tmpDir, "runs"), { recursive: true }); + workspaceDir = join(tmpDir, "workspace"); + mkdirSync(join(workspaceDir, "docs"), { recursive: true }); + writeFileSync(join(workspaceDir, "README.md"), "# Hello\n"); + writeFileSync(join(workspaceDir, "docs", "guide.md"), "# Guide\n"); bus = createBus(); caffinateExitCalls = 0; server = startRalphServer({ port: 0, bus, prefix: tmpDir, + workspace: workspaceDir, onPrompt: async () => {}, onCaffinateExit: () => { caffinateExitCalls++; }, }); @@ -250,6 +256,69 @@ test("caffeinated waits until ALL jobs finish before exiting", async () => { expect(caffinateExitCalls).toBe(1); }); +// --- /files endpoint --- + +test("GET /files returns file content", async () => { + const res = await fetch(`${baseUrl}/files?path=README.md`); + expect(res.ok).toBe(true); + expect(res.headers.get("content-type")).toContain("text/plain"); + expect(await res.text()).toBe("# Hello\n"); +}); + +test("GET /files returns nested file", async () => { + const res = await fetch(`${baseUrl}/files?path=docs/guide.md`); + expect(res.ok).toBe(true); + expect(await res.text()).toBe("# Guide\n"); +}); + +test("GET /files rejects path traversal", async () => { + const res = await fetch(`${baseUrl}/files?path=../../../etc/passwd`); + expect(res.status).toBe(400); +}); + +test("GET /files returns 404 for missing file", async () => { + const res = await fetch(`${baseUrl}/files?path=nope.txt`); + expect(res.status).toBe(404); +}); + +test("GET /files returns 400 without path param", async () => { + const res = await fetch(`${baseUrl}/files`); + expect(res.status).toBe(400); +}); + +// --- /tree endpoint --- + +test("GET /tree lists workspace root", async () => { + const res = await fetch(`${baseUrl}/tree`); + expect(res.ok).toBe(true); + const entries = await res.json(); + const names = entries.map((e: { name: string }) => e.name); + expect(names).toContain("docs"); + expect(names).toContain("README.md"); + // Directories sort before files + expect(entries[0].type).toBe("dir"); +}); + +test("GET /tree lists subdirectory", async () => { + const res = await fetch(`${baseUrl}/tree?path=docs`); + expect(res.ok).toBe(true); + const entries = await res.json(); + expect(entries).toEqual([{ name: "guide.md", type: "file" }]); +}); + +test("GET /tree returns 404 for missing dir", async () => { + const res = await fetch(`${baseUrl}/tree?path=nope`); + expect(res.status).toBe(404); +}); + +test("GET /tree hides dotfiles", async () => { + writeFileSync(join(workspaceDir, ".hidden"), "secret"); + const res = await fetch(`${baseUrl}/tree`); + const entries = await res.json(); + const names = entries.map((e: { name: string }) => e.name); + expect(names).not.toContain(".hidden"); +}); + // --- /shutdown endpoint --- test("POST /shutdown returns ok and triggers process.exit", async () => { diff --git a/src/cli/agents/ralph-server.ts b/src/cli/agents/ralph-server.ts index bf7bdca..c103abf 100644 --- a/src/cli/agents/ralph-server.ts +++ b/src/cli/agents/ralph-server.ts @@ -1,4 +1,4 @@ -import { join } from "path"; +import { join, resolve } from "path"; import { mkdirSync, appendFileSync, existsSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from "fs"; import type { EventBus } from "./bus"; import type { RalphEvent } from "./events"; @@ -22,6 +22,8 @@ interface RalphServerOptions { onPrompt: (prompt: string, runId: string) => Promise; /** Called when caffeinated auto-exit triggers. Defaults to process.exit(0). */ onCaffinateExit?: () => void; + /** Workspace root for file-serving endpoints (/files, /tree). */ + workspace?: string; } const CORS_HEADERS = { @@ -72,7 +74,7 @@ function listJobFiles(jobsDir: string): JobFile[] { } export function startRalphServer(opts: RalphServerOptions) { - const { port, bus, prefix, onPrompt, onCaffinateExit } = opts; + const { port, bus, prefix, onPrompt, onCaffinateExit, workspace } = opts; const runsDir = join(prefix, "runs"); const jobsDir = join(prefix, "jobs"); mkdirSync(jobsDir, { recursive: true }); @@ -141,6 +143,42 @@ export function startRalphServer(opts: RalphServerOptions) { GET: () => Response.json({ status: "ok" }, { headers: CORS_HEADERS }), }, + // --- Workspace file access (read-only) --- + + "/files": { + GET: async (req) => { + if (!workspace) return Response.json({ error: "No workspace configured" }, { status: 501, headers: CORS_HEADERS }); + const filePath = new URL(req.url).searchParams.get("path"); + if (!filePath) return Response.json({ error: "Missing 'path' query param" }, { status: 400, headers: CORS_HEADERS }); + const resolved = resolve(workspace, filePath); + if (!resolved.startsWith(resolve(workspace) + "/")) { + return Response.json({ error: "Invalid path" }, { status: 400, headers: CORS_HEADERS }); + } + const file = Bun.file(resolved); + if (!(await file.exists())) return Response.json({ error: "Not found" }, { status: 404, headers: CORS_HEADERS }); + return new Response(await file.text(), { + headers: { ...CORS_HEADERS, "Content-Type": "text/plain; charset=utf-8" }, + }); + }, + }, + + "/tree": { + GET: (req) => { + if (!workspace) return Response.json({ error: "No workspace configured" }, { status: 501, headers: CORS_HEADERS }); + const dirPath = new URL(req.url).searchParams.get("path") ?? ""; + const resolved = resolve(workspace, dirPath); + if (!resolved.startsWith(resolve(workspace))) { + return Response.json({ error: "Invalid path" }, { status: 400, headers: CORS_HEADERS }); + } + if (!existsSync(resolved)) return Response.json({ error: "Not found" }, { status: 404, headers: CORS_HEADERS }); + const entries = readdirSync(resolved, { withFileTypes: true }) + .filter((e) => !e.name.startsWith(".")) + .map((e) => ({ name: e.name, type: e.isDirectory() ? "dir" : "file" })) + .sort((a, b) => a.type !== b.type ? (a.type === "dir" ? -1 : 1) : a.name.localeCompare(b.name)); + return Response.json(entries, { headers: CORS_HEADERS }); + }, + }, + "/caffinate": { POST: () => { caffeinated = true; @@ -400,6 +438,8 @@ export function startRalphServer(opts: RalphServerOptions) { console.log(`[ralph] POST /runs/:runId/interrupt — interrupt a run`); console.log(`[ralph] POST /runs/status — batch run status`); console.log(`[ralph] CRUD /jobs — job persistence`); + console.log(`[ralph] GET /files?path=... — read workspace file`); + console.log(`[ralph] GET /tree?path=... — list directory`); console.log(`[ralph] GET /health — health check`); console.log(`[ralph] POST /caffinate — enable auto-exit on idle`); console.log(`[ralph] POST /shutdown — graceful shutdown`); From 0374bcd906cfecd54bc308c082c2aa30cf05a7de Mon Sep 17 00:00:00 2001 From: ethanshenly Date: Sun, 15 Feb 2026 12:32:30 -0500 Subject: [PATCH 2/6] gollum write endpoints --- src/cli/agents/ralph-server.test.ts | 56 +++++++++++++++++++++++++++++ src/cli/agents/ralph-server.ts | 29 +++++++++++++-- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/cli/agents/ralph-server.test.ts b/src/cli/agents/ralph-server.test.ts index 8ebc386..baaf006 100644 --- a/src/cli/agents/ralph-server.test.ts +++ b/src/cli/agents/ralph-server.test.ts @@ -319,6 +319,62 @@ test("GET /tree hides dotfiles", async () => { expect(names).not.toContain(".hidden"); }); +// --- PUT /files endpoint --- + +test("PUT /files creates a new file", async () => { + const res = await fetch(`${baseUrl}/files?path=new.txt`, { + method: "PUT", + body: "hello world", + }); + expect(res.ok).toBe(true); + // Verify file was written + const get = await fetch(`${baseUrl}/files?path=new.txt`); + expect(await get.text()).toBe("hello world"); +}); + +test("PUT /files creates parent directories", async () => { + const res = await fetch(`${baseUrl}/files?path=sub/deep/new.txt`, { + method: "PUT", + body: "nested", + }); + expect(res.ok).toBe(true); + const get = await fetch(`${baseUrl}/files?path=sub/deep/new.txt`); + expect(await get.text()).toBe("nested"); +}); + +test("PUT /files rejects path traversal", async () => { + const res = await fetch(`${baseUrl}/files?path=../escape.txt`, { + method: "PUT", + body: "bad", + }); + expect(res.status).toBe(400); +}); + +test("PUT /files returns 400 without path param", async () => { + const res = await fetch(`${baseUrl}/files`, { method: "PUT", body: "x" }); + expect(res.status).toBe(400); +}); + +// --- DELETE /files endpoint --- + +test("DELETE /files removes a file", async () => { + const res = await fetch(`${baseUrl}/files?path=README.md`, { method: "DELETE" }); + expect(res.ok).toBe(true); + // Verify file is gone + const get = await fetch(`${baseUrl}/files?path=README.md`); + expect(get.status).toBe(404); +}); + +test("DELETE /files returns 404 for missing file", async () => { + const res = await fetch(`${baseUrl}/files?path=nope.txt`, { method: "DELETE" }); + expect(res.status).toBe(404); +}); + +test("DELETE /files rejects path traversal", async () => { + const res = await fetch(`${baseUrl}/files?path=../escape.txt`, { method: "DELETE" }); + expect(res.status).toBe(400); +}); + // --- /shutdown endpoint --- test("POST /shutdown returns ok and triggers process.exit", async () => { diff --git a/src/cli/agents/ralph-server.ts b/src/cli/agents/ralph-server.ts index c103abf..d8f3158 100644 --- a/src/cli/agents/ralph-server.ts +++ b/src/cli/agents/ralph-server.ts @@ -1,4 +1,4 @@ -import { join, resolve } from "path"; +import { join, resolve, dirname } from "path"; import { mkdirSync, appendFileSync, existsSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from "fs"; import type { EventBus } from "./bus"; import type { RalphEvent } from "./events"; @@ -143,7 +143,7 @@ export function startRalphServer(opts: RalphServerOptions) { GET: () => Response.json({ status: "ok" }, { headers: CORS_HEADERS }), }, - // --- Workspace file access (read-only) --- + // --- Workspace file access --- "/files": { GET: async (req) => { @@ -160,6 +160,31 @@ export function startRalphServer(opts: RalphServerOptions) { headers: { ...CORS_HEADERS, "Content-Type": "text/plain; charset=utf-8" }, }); }, + PUT: async (req) => { + if (!workspace) return Response.json({ error: "No workspace configured" }, { status: 501, headers: CORS_HEADERS }); + const filePath = new URL(req.url).searchParams.get("path"); + if (!filePath) return Response.json({ error: "Missing 'path' query param" }, { status: 400, headers: CORS_HEADERS }); + const resolved = resolve(workspace, filePath); + if (!resolved.startsWith(resolve(workspace) + "/")) { + return Response.json({ error: "Invalid path" }, { status: 400, headers: CORS_HEADERS }); + } + mkdirSync(dirname(resolved), { recursive: true }); + await Bun.write(resolved, await req.text()); + return Response.json({ ok: true }, { headers: CORS_HEADERS }); + }, + DELETE: async (req) => { + if (!workspace) return Response.json({ error: "No workspace configured" }, { status: 501, headers: CORS_HEADERS }); + const filePath = new URL(req.url).searchParams.get("path"); + if (!filePath) return Response.json({ error: "Missing 'path' query param" }, { status: 400, headers: CORS_HEADERS }); + const resolved = resolve(workspace, filePath); + if (!resolved.startsWith(resolve(workspace) + "/")) { + return Response.json({ error: "Invalid path" }, { status: 400, headers: CORS_HEADERS }); + } + if (!existsSync(resolved)) return Response.json({ error: "Not found" }, { status: 404, headers: CORS_HEADERS }); + unlinkSync(resolved); + return Response.json({ ok: true }, { headers: CORS_HEADERS }); + }, + OPTIONS: () => new Response(null, { status: 204, headers: CORS_HEADERS }), }, "/tree": { From 00c41213ea53dbbdb4da785df2ddca4c980d937c Mon Sep 17 00:00:00 2001 From: ethanshenly Date: Sun, 15 Feb 2026 12:47:27 -0500 Subject: [PATCH 3/6] small refactor --- src/cli/agents/ralph-server.ts | 76 ++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/src/cli/agents/ralph-server.ts b/src/cli/agents/ralph-server.ts index d8f3158..b0a3124 100644 --- a/src/cli/agents/ralph-server.ts +++ b/src/cli/agents/ralph-server.ts @@ -73,8 +73,27 @@ function listJobFiles(jobsDir: string): JobFile[] { return jobs.sort((a, b) => a.createdAt - b.createdAt); } +/** Validate workspace path param; returns resolved path or an error Response. */ +function resolveWorkspacePath( + resolvedWorkspace: string | undefined, + url: string, + allowRoot = false, +): { resolved: string } | Response { + if (!resolvedWorkspace) + return Response.json({ error: "No workspace configured" }, { status: 501, headers: CORS_HEADERS }); + const filePath = new URL(url).searchParams.get("path") ?? (allowRoot ? "" : null); + if (filePath === null) + return Response.json({ error: "Missing 'path' query param" }, { status: 400, headers: CORS_HEADERS }); + const resolved = resolve(resolvedWorkspace, filePath); + const prefix = allowRoot ? resolvedWorkspace : resolvedWorkspace + "/"; + if (!resolved.startsWith(prefix)) + return Response.json({ error: "Invalid path" }, { status: 400, headers: CORS_HEADERS }); + return { resolved }; +} + export function startRalphServer(opts: RalphServerOptions) { const { port, bus, prefix, onPrompt, onCaffinateExit, workspace } = opts; + const resolvedWorkspace = workspace ? resolve(workspace) : undefined; const runsDir = join(prefix, "runs"); const jobsDir = join(prefix, "jobs"); mkdirSync(jobsDir, { recursive: true }); @@ -147,41 +166,26 @@ export function startRalphServer(opts: RalphServerOptions) { "/files": { GET: async (req) => { - if (!workspace) return Response.json({ error: "No workspace configured" }, { status: 501, headers: CORS_HEADERS }); - const filePath = new URL(req.url).searchParams.get("path"); - if (!filePath) return Response.json({ error: "Missing 'path' query param" }, { status: 400, headers: CORS_HEADERS }); - const resolved = resolve(workspace, filePath); - if (!resolved.startsWith(resolve(workspace) + "/")) { - return Response.json({ error: "Invalid path" }, { status: 400, headers: CORS_HEADERS }); - } - const file = Bun.file(resolved); + const result = resolveWorkspacePath(resolvedWorkspace, req.url); + if (result instanceof Response) return result; + const file = Bun.file(result.resolved); if (!(await file.exists())) return Response.json({ error: "Not found" }, { status: 404, headers: CORS_HEADERS }); return new Response(await file.text(), { headers: { ...CORS_HEADERS, "Content-Type": "text/plain; charset=utf-8" }, }); }, PUT: async (req) => { - if (!workspace) return Response.json({ error: "No workspace configured" }, { status: 501, headers: CORS_HEADERS }); - const filePath = new URL(req.url).searchParams.get("path"); - if (!filePath) return Response.json({ error: "Missing 'path' query param" }, { status: 400, headers: CORS_HEADERS }); - const resolved = resolve(workspace, filePath); - if (!resolved.startsWith(resolve(workspace) + "/")) { - return Response.json({ error: "Invalid path" }, { status: 400, headers: CORS_HEADERS }); - } - mkdirSync(dirname(resolved), { recursive: true }); - await Bun.write(resolved, await req.text()); + const result = resolveWorkspacePath(resolvedWorkspace, req.url); + if (result instanceof Response) return result; + mkdirSync(dirname(result.resolved), { recursive: true }); + await Bun.write(result.resolved, await req.text()); return Response.json({ ok: true }, { headers: CORS_HEADERS }); }, DELETE: async (req) => { - if (!workspace) return Response.json({ error: "No workspace configured" }, { status: 501, headers: CORS_HEADERS }); - const filePath = new URL(req.url).searchParams.get("path"); - if (!filePath) return Response.json({ error: "Missing 'path' query param" }, { status: 400, headers: CORS_HEADERS }); - const resolved = resolve(workspace, filePath); - if (!resolved.startsWith(resolve(workspace) + "/")) { - return Response.json({ error: "Invalid path" }, { status: 400, headers: CORS_HEADERS }); - } - if (!existsSync(resolved)) return Response.json({ error: "Not found" }, { status: 404, headers: CORS_HEADERS }); - unlinkSync(resolved); + const result = resolveWorkspacePath(resolvedWorkspace, req.url); + if (result instanceof Response) return result; + if (!existsSync(result.resolved)) return Response.json({ error: "Not found" }, { status: 404, headers: CORS_HEADERS }); + unlinkSync(result.resolved); return Response.json({ ok: true }, { headers: CORS_HEADERS }); }, OPTIONS: () => new Response(null, { status: 204, headers: CORS_HEADERS }), @@ -189,19 +193,19 @@ export function startRalphServer(opts: RalphServerOptions) { "/tree": { GET: (req) => { - if (!workspace) return Response.json({ error: "No workspace configured" }, { status: 501, headers: CORS_HEADERS }); - const dirPath = new URL(req.url).searchParams.get("path") ?? ""; - const resolved = resolve(workspace, dirPath); - if (!resolved.startsWith(resolve(workspace))) { - return Response.json({ error: "Invalid path" }, { status: 400, headers: CORS_HEADERS }); - } - if (!existsSync(resolved)) return Response.json({ error: "Not found" }, { status: 404, headers: CORS_HEADERS }); - const entries = readdirSync(resolved, { withFileTypes: true }) + const result = resolveWorkspacePath(resolvedWorkspace, req.url, true); + if (result instanceof Response) return result; + if (!existsSync(result.resolved)) return Response.json({ error: "Not found" }, { status: 404, headers: CORS_HEADERS }); + const entries = readdirSync(result.resolved, { withFileTypes: true }) .filter((e) => !e.name.startsWith(".")) .map((e) => ({ name: e.name, type: e.isDirectory() ? "dir" : "file" })) - .sort((a, b) => a.type !== b.type ? (a.type === "dir" ? -1 : 1) : a.name.localeCompare(b.name)); + .sort((a, b) => { + if (a.type !== b.type) return a.type === "dir" ? -1 : 1; + return a.name.localeCompare(b.name); + }); return Response.json(entries, { headers: CORS_HEADERS }); }, + OPTIONS: () => new Response(null, { status: 204, headers: CORS_HEADERS }), }, "/caffinate": { @@ -463,7 +467,7 @@ export function startRalphServer(opts: RalphServerOptions) { console.log(`[ralph] POST /runs/:runId/interrupt — interrupt a run`); console.log(`[ralph] POST /runs/status — batch run status`); console.log(`[ralph] CRUD /jobs — job persistence`); - console.log(`[ralph] GET /files?path=... — read workspace file`); + console.log(`[ralph] CRUD /files?path=... — workspace file access`); console.log(`[ralph] GET /tree?path=... — list directory`); console.log(`[ralph] GET /health — health check`); console.log(`[ralph] POST /caffinate — enable auto-exit on idle`); From d1f6457a310c33f901abb276c5695efdb7ea2cf9 Mon Sep 17 00:00:00 2001 From: ethanshenly Date: Sun, 15 Feb 2026 13:04:57 -0500 Subject: [PATCH 4/6] logs endpoint --- src/cli/agents/ralph-server.test.ts | 30 +++++++++++++++++++++++++++++ src/cli/agents/ralph-server.ts | 21 +++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/cli/agents/ralph-server.test.ts b/src/cli/agents/ralph-server.test.ts index baaf006..cf43310 100644 --- a/src/cli/agents/ralph-server.test.ts +++ b/src/cli/agents/ralph-server.test.ts @@ -5,6 +5,14 @@ import { createBus } from "./bus"; import { startRalphServer } from "./ralph-server"; import type { EventBus } from "./bus"; +function gitInit(cwd: string) { + Bun.spawnSync(["git", "init", "-b", "main"], { cwd }); + Bun.spawnSync(["git", "config", "user.email", "test@test.com"], { cwd }); + Bun.spawnSync(["git", "config", "user.name", "Test"], { cwd }); + Bun.spawnSync(["git", "add", "."], { cwd }); + Bun.spawnSync(["git", "commit", "-m", "initial"], { cwd }); +} + let tmpDir: string; let workspaceDir: string; let bus: EventBus; @@ -20,6 +28,7 @@ beforeEach(() => { mkdirSync(join(workspaceDir, "docs"), { recursive: true }); writeFileSync(join(workspaceDir, "README.md"), "# Hello\n"); writeFileSync(join(workspaceDir, "docs", "guide.md"), "# Guide\n"); + gitInit(workspaceDir); bus = createBus(); caffinateExitCalls = 0; server = startRalphServer({ @@ -375,6 +384,27 @@ test("DELETE /files rejects path traversal", async () => { expect(res.status).toBe(400); }); +// --- /log endpoint --- + +test("GET /log returns commit history", async () => { + const res = await fetch(`${baseUrl}/log`); + expect(res.ok).toBe(true); + const commits = await res.json(); + expect(commits.length).toBeGreaterThanOrEqual(1); + expect(commits[0]).toHaveProperty("hash"); + expect(commits[0]).toHaveProperty("author"); + expect(commits[0]).toHaveProperty("date"); + expect(commits[0]).toHaveProperty("message"); + expect(commits[0].message).toBe("initial"); +}); + +test("GET /log respects limit param", async () => { + const res = await fetch(`${baseUrl}/log?limit=1`); + expect(res.ok).toBe(true); + const commits = await res.json(); + expect(commits).toHaveLength(1); +}); + // --- /shutdown endpoint --- test("POST /shutdown returns ok and triggers process.exit", async () => { diff --git a/src/cli/agents/ralph-server.ts b/src/cli/agents/ralph-server.ts index b0a3124..70b0e43 100644 --- a/src/cli/agents/ralph-server.ts +++ b/src/cli/agents/ralph-server.ts @@ -208,6 +208,25 @@ export function startRalphServer(opts: RalphServerOptions) { OPTIONS: () => new Response(null, { status: 204, headers: CORS_HEADERS }), }, + "/log": { + GET: (req) => { + if (!resolvedWorkspace) return Response.json({ error: "No workspace configured" }, { status: 501, headers: CORS_HEADERS }); + const rawLimit = new URL(req.url).searchParams.get("limit") ?? "20"; + const limit = Math.min(Math.max(1, parseInt(rawLimit, 10) || 20), 200); + const result = Bun.spawnSync( + ["git", "log", `--max-count=${limit}`, "--pretty=format:%H%n%an%n%aI%n%s%x00"], + { cwd: resolvedWorkspace }, + ); + if (result.exitCode !== 0) return Response.json({ error: "git log failed" }, { status: 500, headers: CORS_HEADERS }); + const commits = result.stdout.toString("utf-8").trim().split("\0").filter(Boolean).map((entry) => { + const [hash, author, date, message] = entry.split("\n"); + return { hash, author, date, message }; + }); + return Response.json(commits, { headers: CORS_HEADERS }); + }, + OPTIONS: () => new Response(null, { status: 204, headers: CORS_HEADERS }), + }, + "/caffinate": { POST: () => { caffeinated = true; @@ -331,7 +350,6 @@ export function startRalphServer(opts: RalphServerOptions) { // --- Prompt (modified to accept jobId) --- "/prompt": { POST: async (req) => { - debugger; let body: { prompt?: string; jobId?: string }; try { body = await req.json(); @@ -469,6 +487,7 @@ export function startRalphServer(opts: RalphServerOptions) { console.log(`[ralph] CRUD /jobs — job persistence`); console.log(`[ralph] CRUD /files?path=... — workspace file access`); console.log(`[ralph] GET /tree?path=... — list directory`); + console.log(`[ralph] GET /log?limit=... — git commit history`); console.log(`[ralph] GET /health — health check`); console.log(`[ralph] POST /caffinate — enable auto-exit on idle`); console.log(`[ralph] POST /shutdown — graceful shutdown`); From 8c9f65b391ac377cdc206d07eee39b919d51422e Mon Sep 17 00:00:00 2001 From: ethanshenly Date: Sun, 15 Feb 2026 13:17:30 -0500 Subject: [PATCH 5/6] needed to add workspace to run --- src/cli/handlers/run.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cli/handlers/run.ts b/src/cli/handlers/run.ts index a71527f..b1efe82 100644 --- a/src/cli/handlers/run.ts +++ b/src/cli/handlers/run.ts @@ -394,6 +394,7 @@ async function runWithRalph(prefix: string, workspacePath: string, options: Ralp port, bus, prefix, + workspace: workspacePath, onPrompt: async (prompt: string, runId: string) => { const publisher = taggedPublisher(bus, runId); const shortId = runId.slice(0, 16); From 041ec30f8168d0aa38c710e178e6aaef3401ac17 Mon Sep 17 00:00:00 2001 From: ethanshenly Date: Sun, 15 Feb 2026 13:32:03 -0500 Subject: [PATCH 6/6] docs --- docs/system/ralph-server.mdx | 88 ++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/docs/system/ralph-server.mdx b/docs/system/ralph-server.mdx index 502734d..e342c3b 100644 --- a/docs/system/ralph-server.mdx +++ b/docs/system/ralph-server.mdx @@ -78,6 +78,94 @@ events.onmessage = (msg) => { The server sends an SSE comment (`: keepalive`) every 5 seconds to prevent proxies and load balancers from closing idle connections. +### `GET /files` + +Returns the contents of a file in the workspace. + +``` +GET /files?path=README.md +``` + +**Response** (`200 OK`): The file content as `text/plain; charset=utf-8`. + +Returns `400` if the `path` param is missing or attempts directory traversal, `404` if the file does not exist, and `501` if no workspace is configured. + +### `PUT /files` + +Creates or overwrites a file in the workspace. Parent directories are created automatically. + +``` +PUT /files?path=docs/setup.md +``` + +**Request body:** Raw file content (plain text). + +**Response** (`200 OK`): + +```json +{ "ok": true } +``` + +### `DELETE /files` + +Removes a file from the workspace. + +``` +DELETE /files?path=docs/old-notes.md +``` + +**Response** (`200 OK`): + +```json +{ "ok": true } +``` + +Returns `404` if the file does not exist. + +### `GET /tree` + +Lists entries in a workspace directory. Directories are sorted before files, and dotfiles are hidden. + +``` +GET /tree +GET /tree?path=docs +``` + +**Response** (`200 OK`): + +```json +[ + { "name": "docs", "type": "dir" }, + { "name": "README.md", "type": "file" } +] +``` + +Without a `path` param, the workspace root is listed. Returns `404` if the directory does not exist. + +### `GET /log` + +Returns recent git commit history from the workspace. + +``` +GET /log +GET /log?limit=10 +``` + +**Response** (`200 OK`): + +```json +[ + { + "hash": "abc123...", + "author": "Agent", + "date": "2026-02-15T12:00:00-05:00", + "message": "Fix failing tests in utils" + } +] +``` + +The `limit` param controls how many commits to return (default: 20, max: 200). Returns `500` if the workspace is not a git repository. + ### `GET /health` Returns `{ "status": "ok" }`. Useful for readiness checks.