Skip to content
Merged
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
11 changes: 7 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
Multi-agent orchestrator that runs parallel Claude Code agents in git worktrees. Tasks submitted via REST API, monitored via SSE, agents auto-commit and merge to main.

## Architecture
- **src/index.ts** — CLI entry point (Commander.js)
- **src/index.ts** — Server entry point (Commander.js)
- **src/cli.ts** — CLI client commands (submit, list, status, diff, logs, stats, workers, search, cancel, retry, watch)
- **src/server.ts** — Hono REST API + SSE
- **src/scheduler.ts** — Task queue, priority dispatch, retry logic, stale worker recovery
- **src/agent-runner.ts** — Multi-agent CLI spawning (Claude, Codex, generic), cost tracking, code review
Expand All @@ -29,15 +30,15 @@ node dist/index.js --repo /path/to/repo --workers 5 --port 8080
```

```bash
# Run tests (255 tests across 6 suites)
# Run tests (282 tests across 8 suites)
node --import tsx --test src/__tests__/*.test.ts
```

## Development Rules
- Always use `.js` extensions in TypeScript imports (ESM)
- Run `npx tsc` after changes to verify compilation
- Only modify core files — do NOT create standalone utility modules
- Core files: server.ts, scheduler.ts, store.ts, agent-runner.ts, worktree-pool.ts, index.ts, types.ts, logger.ts
- Core files: server.ts, scheduler.ts, store.ts, agent-runner.ts, worktree-pool.ts, index.ts, cli.ts, types.ts, logger.ts
- Keep changes minimal and focused — one concern per change
- Always `git add -A && git commit` after successful changes

Expand Down Expand Up @@ -139,7 +140,9 @@ pending → running → success (branch merged to main)
- `src/__tests__/scheduler.test.ts` — Submit, cancel, stats, queue position
- `src/__tests__/agent-runner.test.ts` — Cost estimation, code review, system prompt, CLI dispatch
- `src/__tests__/server.test.ts` — API input validation (prompt, timeout, priority, tags, webhookUrl)
- `src/__tests__/cli.test.ts` — CLI commands, fetch mocking, output formatting, error handling
- `src/__tests__/cli.test.ts` — CLI commands (all 12), fetch mocking, output formatting, error handling
- `src/__tests__/logger.test.ts` — Structured logger: JSON format, level filtering, stream routing
- `src/__tests__/index.test.ts` — Entry point validation: repo, workers, port, system prompt (subprocess)

## Repository
- **GitHub**: `agent-next/cc-manager` (private)
Expand Down
60 changes: 60 additions & 0 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,66 @@ describe("cli", () => {
});
});

// ─── watch ───────────────────────────────────────────────────────────────────

describe("watch command", () => {
function mockSSEFetch(chunks: string[]) {
fetchCalls = [];
let chunkIndex = 0;
(globalThis as any).fetch = async (url: string | URL, init?: RequestInit) => {
fetchCalls.push({ url: String(url), init });
return {
ok: true,
status: 200,
body: {
getReader: () => ({
read: async () => {
if (chunkIndex < chunks.length) {
const encoder = new TextEncoder();
return { done: false, value: encoder.encode(chunks[chunkIndex++]) };
}
return { done: true, value: undefined };
},
}),
},
};
};
}

it("connects to /api/events", async () => {
mockSSEFetch([]);
await run("watch");

assert.ok(fetchCalls[0].url.includes("/api/events"));
});

it("parses SSE data lines and logs events", async () => {
const event = JSON.stringify({ type: "task_started", taskId: "t1", status: "running" });
mockSSEFetch([`data: ${event}\n\n`]);
await run("watch");

const output = logs.join("\n");
assert.ok(output.includes("task_started"), "should log event type");
assert.ok(output.includes("t1"), "should log task ID");
});

it("skips malformed SSE data", async () => {
mockSSEFetch(["data: not-valid-json\n\n"]);
await run("watch");

// Should not throw, just silently skip
assert.ok(true);
});

it("prints connection message", async () => {
mockSSEFetch([]);
await run("watch");

const output = logs.join("\n");
assert.ok(output.includes("Watching events"), "should show connection message");
});
});

// ─── api() Content-Type header ──────────────────────────────────────────────

describe("api() helper behavior", () => {
Expand Down
122 changes: 122 additions & 0 deletions src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";

// =============================================================================
// index.ts entry point tests — subprocess validation of CLI args and startup
// =============================================================================

const exec = promisify(execFile);

async function runServer(...args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
try {
const { stdout, stderr } = await exec("node", ["--import", "tsx", "src/index.ts", ...args], {
timeout: 5000,
env: { ...process.env, NODE_NO_WARNINGS: "1" },
});
return { stdout, stderr, exitCode: 0 };
} catch (err: any) {
return { stdout: err.stdout ?? "", stderr: err.stderr ?? "", exitCode: err.code ?? 1 };
}
}

describe("index.ts entry point", () => {

// ─── required options ───────────────────────────────────────────────────────

describe("required options", () => {
it("exits with error when --repo is missing", async () => {
const result = await runServer();
assert.notStrictEqual(result.exitCode, 0);
assert.ok(result.stderr.includes("--repo"), "should mention --repo in error");
});
});

// ─── repo validation ────────────────────────────────────────────────────────

describe("repo validation", () => {
it("exits with error for nonexistent repo path", async () => {
const result = await runServer("--repo", "/nonexistent/path/to/repo");
assert.notStrictEqual(result.exitCode, 0);
assert.ok(result.stderr.includes("not a git repository"), "should report invalid repo");
});

it("exits with error for directory without .git", async () => {
const tmp = mkdtempSync(join(tmpdir(), "cc-test-"));
try {
const result = await runServer("--repo", tmp);
assert.notStrictEqual(result.exitCode, 0);
assert.ok(result.stderr.includes("not a git repository"));
} finally {
rmSync(tmp, { recursive: true });
}
});
});

// ─── workers validation ─────────────────────────────────────────────────────

describe("workers validation", () => {
let fakeRepo: string;

// Create a minimal fake git repo for validation tests that pass repo check
fakeRepo = mkdtempSync(join(tmpdir(), "cc-test-repo-"));
mkdirSync(join(fakeRepo, ".git"), { recursive: true });

it("exits with error for --workers 0", async () => {
const result = await runServer("--repo", fakeRepo, "--workers", "0");
assert.notStrictEqual(result.exitCode, 0);
assert.ok(result.stderr.includes("--workers must be between 1 and 20"));
});

it("exits with error for --workers 25", async () => {
const result = await runServer("--repo", fakeRepo, "--workers", "25");
assert.notStrictEqual(result.exitCode, 0);
assert.ok(result.stderr.includes("--workers must be between 1 and 20"));
});

it("exits with error for --workers abc", async () => {
const result = await runServer("--repo", fakeRepo, "--workers", "abc");
assert.notStrictEqual(result.exitCode, 0);
assert.ok(result.stderr.includes("--workers must be between 1 and 20"));
});
});

// ─── port validation ────────────────────────────────────────────────────────

describe("port validation", () => {
let fakeRepo: string;
fakeRepo = mkdtempSync(join(tmpdir(), "cc-test-repo-"));
mkdirSync(join(fakeRepo, ".git"), { recursive: true });

it("exits with error for --port 80", async () => {
const result = await runServer("--repo", fakeRepo, "--port", "80");
assert.notStrictEqual(result.exitCode, 0);
assert.ok(result.stderr.includes("--port must be between 1024 and 65535"));
});

it("exits with error for --port 99999", async () => {
const result = await runServer("--repo", fakeRepo, "--port", "99999");
assert.notStrictEqual(result.exitCode, 0);
assert.ok(result.stderr.includes("--port must be between 1024 and 65535"));
});
});

// ─── system prompt file ─────────────────────────────────────────────────────

describe("system prompt file", () => {
it("exits with error for nonexistent --system-prompt-file", async () => {
const fakeRepo = mkdtempSync(join(tmpdir(), "cc-test-repo-"));
mkdirSync(join(fakeRepo, ".git"), { recursive: true });
try {
const result = await runServer("--repo", fakeRepo, "--system-prompt-file", "/nonexistent/file.txt");
assert.notStrictEqual(result.exitCode, 0);
} finally {
rmSync(fakeRepo, { recursive: true });
}
});
});
});
145 changes: 145 additions & 0 deletions src/__tests__/logger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { log, setLogLevel } from "../logger.js";

// =============================================================================
// Logger tests — verify structured JSON output, level filtering, stream routing
// =============================================================================

let stdoutWrites: string[] = [];
let stderrWrites: string[] = [];
const originalStdoutWrite = process.stdout.write;
const originalStderrWrite = process.stderr.write;

function captureStreams() {
stdoutWrites = [];
stderrWrites = [];
process.stdout.write = ((chunk: string) => {
stdoutWrites.push(chunk);
return true;
}) as typeof process.stdout.write;
process.stderr.write = ((chunk: string) => {
stderrWrites.push(chunk);
return true;
}) as typeof process.stderr.write;
}

function restoreStreams() {
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
}

describe("logger", () => {
beforeEach(() => {
setLogLevel("info"); // reset to default
captureStreams();
});

afterEach(() => {
restoreStreams();
});

// ─── JSON format ────────────────────────────────────────────────────────────

describe("JSON format", () => {
it("outputs valid JSON with ts, level, and msg fields", () => {
log("info", "hello world");

assert.strictEqual(stdoutWrites.length, 1);
const entry = JSON.parse(stdoutWrites[0]);
assert.strictEqual(entry.level, "info");
assert.strictEqual(entry.msg, "hello world");
assert.ok(entry.ts, "should have timestamp");
});

it("includes extra data fields", () => {
log("info", "task start", { taskId: "abc", agent: "claude" });

const entry = JSON.parse(stdoutWrites[0]);
assert.strictEqual(entry.taskId, "abc");
assert.strictEqual(entry.agent, "claude");
});

it("produces ISO 8601 timestamp", () => {
log("info", "check ts");

const entry = JSON.parse(stdoutWrites[0]);
assert.ok(/^\d{4}-\d{2}-\d{2}T/.test(entry.ts), "should be ISO format");
});

it("appends newline after JSON", () => {
log("info", "newline check");

assert.ok(stdoutWrites[0].endsWith("\n"));
});
});

// ─── stream routing ─────────────────────────────────────────────────────────

describe("stream routing", () => {
it("writes info to stdout", () => {
log("info", "info msg");
assert.strictEqual(stdoutWrites.length, 1);
assert.strictEqual(stderrWrites.length, 0);
});

it("writes warn to stdout", () => {
log("warn", "warn msg");
assert.strictEqual(stdoutWrites.length, 1);
assert.strictEqual(stderrWrites.length, 0);
});

it("writes error to stderr", () => {
log("error", "error msg");
assert.strictEqual(stderrWrites.length, 1);
assert.strictEqual(stdoutWrites.length, 0);
});

it("writes debug to stdout when level is debug", () => {
setLogLevel("debug");
log("debug", "debug msg");
assert.strictEqual(stdoutWrites.length, 1);
assert.strictEqual(stderrWrites.length, 0);
});
});

// ─── level filtering ────────────────────────────────────────────────────────

describe("level filtering", () => {
it("suppresses debug at default info level", () => {
log("debug", "should not appear");
assert.strictEqual(stdoutWrites.length, 0);
assert.strictEqual(stderrWrites.length, 0);
});

it("emits debug when level set to debug", () => {
setLogLevel("debug");
log("debug", "visible");
assert.strictEqual(stdoutWrites.length, 1);
});

it("suppresses info when level set to warn", () => {
setLogLevel("warn");
log("info", "hidden");
assert.strictEqual(stdoutWrites.length, 0);
});

it("emits warn when level set to warn", () => {
setLogLevel("warn");
log("warn", "visible");
assert.strictEqual(stdoutWrites.length, 1);
});

it("suppresses warn when level set to error", () => {
setLogLevel("error");
log("warn", "hidden");
assert.strictEqual(stdoutWrites.length, 0);
});

it("emits error at any level", () => {
setLogLevel("error");
log("error", "always visible");
assert.strictEqual(stderrWrites.length, 1);
});
});
});