Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions docs/system/ralph-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/cli/agents/ralph-serve-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
157 changes: 156 additions & 1 deletion src/cli/agents/ralph-server.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
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";

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;
let server: ReturnType<typeof startRalphServer>;
let baseUrl: string;
Expand All @@ -15,12 +24,18 @@ 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");
gitInit(workspaceDir);
bus = createBus();
caffinateExitCalls = 0;
server = startRalphServer({
port: 0,
bus,
prefix: tmpDir,
workspace: workspaceDir,
onPrompt: async () => {},
onCaffinateExit: () => { caffinateExitCalls++; },
});
Expand Down Expand Up @@ -250,6 +265,146 @@ 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");
});

// --- 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);
});

// --- /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 () => {
Expand Down
Loading
Loading