diff --git a/.github/workflows/onboarding-test.yml b/.github/workflows/onboarding-test.yml new file mode 100644 index 000000000..704514a8b --- /dev/null +++ b/.github/workflows/onboarding-test.yml @@ -0,0 +1,55 @@ +name: Onboarding Integration Test + +on: + pull_request: + paths: + - 'packages/**' + - 'scripts/setup.sh' + - 'tests/integration/**' + - '.github/workflows/onboarding-test.yml' + push: + branches: + - main + +jobs: + onboarding-test: + name: Test Fresh Onboarding + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build test image + working-directory: tests/integration + run: docker compose build + + - name: Run onboarding test + id: test + working-directory: tests/integration + run: | + docker compose up --abort-on-container-exit --exit-code-from onboarding-test + + - name: Extract metrics + if: always() + run: | + # Extract onboarding time from container logs + docker logs ao-onboarding-test 2>&1 | grep "Total onboarding time" || echo "Metrics not available" + + - name: Cleanup + if: always() + working-directory: tests/integration + run: docker compose down -v + + - name: Upload test logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: onboarding-test-logs + path: | + tests/integration/*.log + retention-days: 7 diff --git a/.gitignore b/.gitignore index 06c421974..9a9ed1648 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +.pnpm-store/ .next/ *.tsbuildinfo coverage/ diff --git a/CLAUDE.orchestrator.md b/CLAUDE.orchestrator.md index 46673a437..133132734 100644 --- a/CLAUDE.orchestrator.md +++ b/CLAUDE.orchestrator.md @@ -1,48 +1,68 @@ -# Orchestrator Agent — Agent Orchestrator +# CLAUDE.orchestrator.md - Agent Orchestrator You are the **orchestrator agent** for the agent-orchestrator project. You manage parallel Claude Code agents that build this very tool (dog-fooding). -## Your Role +## Project Info -You plan, delegate, and monitor — you do NOT implement. Spawn worker sessions for implementation tasks, monitor their progress, and intervene when they need help. - -## Project - -| Property | Value | -|----------|-------| -| Repo | ComposioHQ/agent-orchestrator (GitHub) | -| Issue Tracker | Linear (AO team) | -| Default Branch | `main` | -| Session Prefix | `ao` | -| Session Naming | `ao-1`, `ao-2`, etc. | +- **Repo**: ComposioHQ/agent-orchestrator (GitHub) +- **Issue Tracker**: Linear (AO team) +- **Main Branch**: `main` +- **Session Prefix**: `ao` +- **Session Naming**: `ao-1`, `ao-2`, etc. +- **Metadata Dir**: `~/.ao-sessions/` +- **Worktrees**: `~/.worktrees/ao/` ## Quick Start ```bash -ao status # See all sessions -ao spawn ao AO-1 # Spawn one session for a Linear ticket -ao batch-spawn ao AO-1 AO-2 AO-3 # Spawn multiple -ao send ao-1 "fix the CI failure" # Send message to worker -ao session ls -p ao # List sessions -ao session cleanup -p ao # Remove merged sessions -ao open ao # Open all in terminal tabs +# See all sessions +~/claude-status + +# Spawn sessions for Linear tickets +~/claude-batch-spawn ao AO-1 AO-2 AO-3 + +# Spawn single session (new iTerm2 tab) +~/claude-spawn ao AO-1 + +# List ao sessions +~/claude-ao-session ls + +# Attach to a session +~/claude-ao-session attach ao-1 + +# Kill a session +~/claude-ao-session kill ao-1 + +# Cleanup completed work (merged PRs / done tickets) +~/claude-ao-session cleanup ``` -## Key Commands +## Agent Hierarchy -| Task | Command | -|------|---------| -| See all sessions | `ao status` | -| Batch spawn | `ao batch-spawn ao AO-1 AO-2 AO-3` | -| Single spawn | `ao spawn ao AO-1` | -| List sessions | `ao session ls -p ao` | -| Kill session | `ao session kill ao-3` | -| Cleanup | `ao session cleanup -p ao` | -| Send message | `ao send ao-1 "your message"` | -| Open all tabs | `ao open ao` | -| PR review fixes | `ao review-check ao` | -| Peek at screen | `tmux capture-pane -t "ao-1" -p -S -30` | -| Status as JSON | `ao status --json` | +``` +~/agent-orchestrator/ <- YOU (Orchestrator) +└── ao agents <- Managed via ~/claude-ao-session + ├── ao-1 (~/.worktrees/ao/ao-1) + ├── ao-2 (~/.worktrees/ao/ao-2) + └── ao-N +``` + +## Commands Reference + +| Task | Command | +| ---------------------- | ------------------------------------------------------------ | +| **See all sessions** | `~/claude-status` | +| **Batch spawn** | `~/claude-batch-spawn ao AO-1 AO-2 AO-3` | +| **Single spawn** | `~/claude-spawn ao AO-1` | +| **List sessions** | `~/claude-ao-session ls` | +| **Attach** | `~/claude-ao-session attach ao-1` | +| **Kill** | `~/claude-ao-session kill ao-1` | +| **Cleanup** | `~/claude-ao-session cleanup` | +| **Open all tabs** | `~/claude-open-all ao` | +| **PR review fixes** | `~/claude-review-check ao` | +| **Peek at screen** | `tmux capture-pane -t "ao-1" -p -S -30` | +| **Send message** | `~/send-to-session ao-1 "your message"` | +| **Spawn with context** | `~/claude-spawn-with-context ao AO-1 /tmp/prompt.txt --open` | ## Typical Workflows @@ -50,92 +70,138 @@ ao open ao # Open all in terminal tabs ```bash # 1. Check what's already running -ao status +~/claude-status # 2. Spawn sessions (auto-deduplicates) -ao batch-spawn ao AO-1 AO-2 AO-3 +~/claude-batch-spawn ao AO-1 AO-2 AO-3 -# 3. Open all in terminal -ao open ao +# 3. Open all in iTerm2 +~/claude-open-all ao ``` ### Check Progress ```bash -ao status # Full dashboard -ao session ls -p ao # Quick list -tmux capture-pane -t "ao-1" -p -S -30 # Peek at session +~/claude-status # Quick overview +~/claude-ao-session ls # AO sessions only +tmux capture-pane -t "ao-1" -p -S -30 # Peek at session ``` ### Ask a Session to Do Something ```bash # Short message -ao send ao-1 "address the unresolved comments on your PR" +~/send-to-session ao-1 "address the unresolved comments on your PR" -# Long instructions from file -ao send ao-1 -f /tmp/detailed-instructions.txt -``` - -### Handle PR Reviews - -```bash -# Automatic: scan all PRs and trigger agents to fix -ao review-check ao - -# Manual: send targeted instruction -ao send ao-2 "address the review comments on your PR" +# Long prompt via file +cat > /tmp/prompt.txt << 'PROMPT' +Your detailed instructions here... +PROMPT +~/claude-spawn-with-context ao AO-1 /tmp/prompt.txt --open ``` ### Cleanup ```bash -ao session cleanup -p ao --dry-run # Preview what would be killed -ao session cleanup -p ao # Actually clean up -ao session kill ao-3 # Kill specific session +~/claude-ao-session cleanup # Kills sessions with merged PRs / completed tickets +~/claude-ao-session kill ao-3 # Kill specific session ``` -## Session Lifecycle +## Session Data + +### Metadata Files + +Each session has a flat file at `~/.ao-sessions/ao-N`: ``` -ao spawn ao AO-123 - | - v -[Worktree created from origin/main] - | - v -[Feature branch: feat/AO-123] - | - v -[tmux session: ao-N, agent launched with issue context] - | - v -[Agent works: implement -> test -> PR -> push] - | - v -[ao status shows PR/CI/review state] - | - v -[PR merged] --> ao session cleanup removes session +worktree=/Users/equinox/.worktrees/ao/ao-1 +branch=feat/AO-1 +status=starting +issue=https://linear.app/composio/issue/AO-1 +pr=https://github.com/ComposioHQ/agent-orchestrator/pull/5 ``` -## How to Behave +### Environment Variables (inside sessions) -### Always Do +- `AO_SESSION` — e.g., `ao-1` +- `LINEAR_API_KEY` — required for cleanup to check ticket status -1. **Check before spawning** — Run `ao status` first. Never spawn duplicates. -2. **Use `ao send` for messages** — Handles busy detection and delivery verification. -3. **Delegate, don't duplicate** — Send short instructions. Workers have `gh`, git, and full repo access. -4. **Batch when possible** — `ao batch-spawn` has built-in duplicate detection. -5. **Clean up after merges** — `ao session cleanup -p ao` removes completed sessions. +## Repo Structure -### Never Do +``` +agent-orchestrator/ +├── scripts/ # All orchestrator scripts +│ ├── claude-ao-session # Session manager for this project +│ ├── claude-status # Unified CLI dashboard +│ ├── claude-batch-spawn # Spawn multiple sessions +│ ├── claude-spawn # Spawn single session (new tab) +│ ├── claude-dashboard # HTML dashboard with live PR status +│ ├── claude-open-all # Open iTerm2 tabs for sessions +│ ├── claude-review-check # Trigger PR review fixes +│ ├── claude-bugbot-fix # Fix bugbot comments +│ ├── claude-session-status # Health monitor +│ ├── claude-spawn-with-context # Spawn with custom prompt file +│ ├── claude-spawn-on-branch # Spawn on existing branch +│ ├── claude-spawn-with-prompt # Spawn + deliver prompt after ready +│ ├── get-claude-session-info # Extract session metadata from tmux +│ ├── open-tmux-session # Switch to terminal tab +│ ├── open-iterm-tab # iTerm2 tab management +│ ├── notify-session # iTerm2 notifications +│ ├── send-to-session # Smart message delivery to sessions +│ ├── claude-integrator-session # Example: Linear-based session manager +│ └── claude-splitly-session # Example: GitHub Issues session manager +├── CLAUDE.orchestrator.md # This file (orchestrator instructions) +├── CLAUDE.md # Repo instructions for contributors +└── README.md # Project README +``` -1. **Never write code** — Spawn a worker session for any implementation task. -2. **Never use legacy scripts** — No `~/claude-batch-spawn`, `~/claude-status`, `~/send-to-session`, etc. Use `ao` CLI. -3. **Never use raw tmux** — Don't `tmux send-keys` directly. Use `ao send`. -4. **Never spawn for trivial tasks** — Answer questions directly. Only spawn for implementation. -5. **Never duplicate work** — If a session exists for an issue, send it a message instead. +## Architecture + +### Session Lifecycle + +``` +spawn → tmux session created → Claude started → working on ticket + ↓ +metadata file written (branch, issue, status) + ↓ +agent creates PR → metadata updated (pr=URL) + ↓ +dashboard shows PR status, CI, review state + ↓ +PR merged → cleanup kills session, archives metadata +``` + +### Activity Detection + +The dashboard detects if agents are working/idle/exited by: + +1. Checking Claude's JSONL session file modification time and last message type +2. Walking the process tree from tmux pane PID to find `claude` processes +3. Polling every 5 seconds via `/api/sessions` endpoint + +### Key Design Principles + +1. **tmux-based** — persistence, detach/attach, scriptability +2. **Flat metadata files** — `key=value` format, easy to parse and update +3. **Worktree isolation** — each session gets its own git worktree +4. **Project-agnostic shared scripts** — core scripts take project as argument +5. **Project-specific session managers** — each project gets its own (e.g., `claude-ao-session`) + +## Roadmap + +1. **Generalize** — Remove remaining hardcoded project names from shared scripts +2. **Configuration** — `orchestrator.yaml` defining projects, repos, branches, issue trackers +3. **Installation** — Install script that symlinks scripts to `~/` or adds to PATH +4. **Documentation** — Comprehensive README with setup guide and examples +5. **Terminal-agnostic** — Replace iTerm2 AppleScript with generic terminal support + +## Tips + +1. **Delegate, don't duplicate** — When asking a session to fix PR comments, just send "address the unresolved comments on your PR". The session has `gh` access. +2. **Check before spawning** — `~/claude-status` to avoid duplicate sessions. +3. **Detach, don't kill** — `Ctrl-b d` detaches from tmux. Session keeps running. +4. **Peek without attaching** — `tmux capture-pane -t "ao-1" -p -S -30` +5. **Verify message delivery** — After sending to a session, check for thinking indicators, not just `[Pasted text]`. ## Linear Integration diff --git a/SETUP.md b/SETUP.md index 34c22d14c..90fc998a2 100644 --- a/SETUP.md +++ b/SETUP.md @@ -102,7 +102,7 @@ The wizard will prompt you for: 1. **Data directory** - Where to store session metadata (default: `~/.agent-orchestrator`) 2. **Worktree directory** - Where to create isolated workspaces (default: `~/.worktrees`) -3. **Dashboard port** - Web interface port (default: `3000`) +3. **Dashboard port** - Web interface port (default: `9847`) 4. **Runtime plugin** - Session runtime (default: `tmux`) 5. **Agent plugin** - AI coding assistant (default: `claude-code`) 6. **Workspace plugin** - Workspace isolation method (default: `worktree`) @@ -148,7 +148,7 @@ The absolute minimum needed: ```yaml dataDir: ~/.agent-orchestrator worktreeDir: ~/.worktrees -port: 3000 +port: 9847 projects: my-app: @@ -439,7 +439,7 @@ echo $LINEAR_API_KEY ### "Port 3000 already in use" -**Problem:** Another service is using port 3000. +**Problem:** Another service is using port 9847. **Solution:** @@ -447,7 +447,7 @@ echo $LINEAR_API_KEY # Change port in agent-orchestrator.yaml port: 3001 -# Or find and kill the process using port 3000 +# Or find and kill the process using port 9847 lsof -ti:3000 | xargs kill ``` @@ -730,7 +730,7 @@ projects: Three ways: -1. **Dashboard** - `ao start` then visit http://localhost:3000 +1. **Dashboard** - `ao start` then visit http://localhost:9847 2. **CLI status** - `ao status` (text-based dashboard) 3. **Attach to session** - `ao open ` (live terminal) diff --git a/agent-orchestrator.yaml b/agent-orchestrator.yaml index 5dbaebc83..6bc8d8358 100644 --- a/agent-orchestrator.yaml +++ b/agent-orchestrator.yaml @@ -2,7 +2,6 @@ dataDir: ~/.ao-sessions worktreeDir: ~/.worktrees/ao -port: 3000 defaults: runtime: tmux diff --git a/examples/auto-merge.yaml b/examples/auto-merge.yaml index 445bf4b11..28b2d37f0 100644 --- a/examples/auto-merge.yaml +++ b/examples/auto-merge.yaml @@ -3,7 +3,6 @@ dataDir: ~/.agent-orchestrator worktreeDir: ~/.worktrees -port: 3000 projects: my-app: diff --git a/examples/codex-integration.yaml b/examples/codex-integration.yaml index a44c17700..a25964f91 100644 --- a/examples/codex-integration.yaml +++ b/examples/codex-integration.yaml @@ -3,7 +3,6 @@ dataDir: ~/.agent-orchestrator worktreeDir: ~/.worktrees -port: 3000 defaults: agent: codex # Use Codex instead of Claude Code diff --git a/examples/linear-team.yaml b/examples/linear-team.yaml index a7d3152d8..2c141a491 100644 --- a/examples/linear-team.yaml +++ b/examples/linear-team.yaml @@ -3,7 +3,6 @@ dataDir: ~/.agent-orchestrator worktreeDir: ~/.worktrees -port: 3000 projects: my-app: diff --git a/examples/multi-project.yaml b/examples/multi-project.yaml index 31db276e9..6e731612b 100644 --- a/examples/multi-project.yaml +++ b/examples/multi-project.yaml @@ -3,7 +3,6 @@ dataDir: ~/.agent-orchestrator worktreeDir: ~/.worktrees -port: 3000 defaults: runtime: tmux diff --git a/examples/simple-github.yaml b/examples/simple-github.yaml index 7d58b8ef5..1730e63d6 100644 --- a/examples/simple-github.yaml +++ b/examples/simple-github.yaml @@ -3,7 +3,6 @@ dataDir: ~/.agent-orchestrator worktreeDir: ~/.worktrees -port: 3000 projects: my-app: diff --git a/packages/cli/__tests__/lib/metadata.test.ts b/packages/cli/__tests__/lib/metadata.test.ts deleted file mode 100644 index 353d13f21..000000000 --- a/packages/cli/__tests__/lib/metadata.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { - mkdtempSync, - mkdirSync, - writeFileSync, - existsSync, - readFileSync, - readdirSync, - rmSync, -} from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { - readMetadata, - writeMetadata, - archiveMetadata, - listSessionFiles, - findSessionForIssue, -} from "../../src/lib/metadata.js"; - -let tmpDir: string; - -beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), "ao-test-")); -}); - -afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }); -}); - -describe("readMetadata", () => { - it("returns null for non-existent file", () => { - expect(readMetadata(join(tmpDir, "nonexistent"))).toBeNull(); - }); - - it("parses key=value format", () => { - const file = join(tmpDir, "session-1"); - writeFileSync( - file, - "worktree=/home/user/.worktrees/app/session-1\nbranch=feat/INT-123\nstatus=working\nissue=INT-123\n", - ); - const meta = readMetadata(file); - expect(meta).not.toBeNull(); - expect(meta!.worktree).toBe("/home/user/.worktrees/app/session-1"); - expect(meta!.branch).toBe("feat/INT-123"); - expect(meta!.status).toBe("working"); - expect(meta!.issue).toBe("INT-123"); - }); - - it("handles values containing equals signs", () => { - const file = join(tmpDir, "session-2"); - writeFileSync(file, "summary=key=value pair in desc\n"); - const meta = readMetadata(file); - expect(meta).not.toBeNull(); - expect(meta!.summary).toBe("key=value pair in desc"); - }); - - it("ignores empty lines", () => { - const file = join(tmpDir, "session-3"); - writeFileSync(file, "branch=main\n\nstatus=idle\n\n"); - const meta = readMetadata(file); - expect(meta!.branch).toBe("main"); - expect(meta!.status).toBe("idle"); - }); - - it("handles PR URLs with embedded numbers", () => { - const file = join(tmpDir, "session-4"); - writeFileSync(file, "pr=https://github.com/org/repo/pull/42\nbranch=feat/fix\n"); - const meta = readMetadata(file); - expect(meta!.pr).toBe("https://github.com/org/repo/pull/42"); - }); -}); - -describe("writeMetadata", () => { - it("creates metadata file with key=value pairs", () => { - const file = join(tmpDir, "subdir", "session-1"); - writeMetadata(file, { - worktree: "/path/to/worktree", - branch: "feat/test", - status: "starting", - }); - expect(existsSync(file)).toBe(true); - const content = readFileSync(file, "utf-8"); - expect(content).toContain("worktree=/path/to/worktree\n"); - expect(content).toContain("branch=feat/test\n"); - expect(content).toContain("status=starting\n"); - }); - - it("skips undefined and null values", () => { - const file = join(tmpDir, "session-2"); - writeMetadata(file, { - branch: "main", - status: "working", - pr: undefined, - issue: undefined, - }); - const content = readFileSync(file, "utf-8"); - expect(content).not.toContain("pr="); - expect(content).not.toContain("issue="); - expect(content).toContain("branch=main"); - }); - - it("creates parent directories if needed", () => { - const file = join(tmpDir, "deep", "nested", "dir", "session-1"); - writeMetadata(file, { branch: "main", status: "idle" }); - expect(existsSync(file)).toBe(true); - }); -}); - -describe("archiveMetadata", () => { - it("moves metadata to archive dir with timestamp", () => { - const sessionDir = join(tmpDir, "sessions"); - mkdirSync(sessionDir, { recursive: true }); - writeFileSync(join(sessionDir, "app-1"), "branch=main\n"); - - archiveMetadata(sessionDir, "app-1"); - - expect(existsSync(join(sessionDir, "app-1"))).toBe(false); - const archiveDir = join(sessionDir, "archive"); - expect(existsSync(archiveDir)).toBe(true); - const archived = readdirSync(archiveDir); - expect(archived.length).toBe(1); - expect(archived[0]).toMatch(/^app-1_\d{4}-\d{2}-\d{2}T/); - }); - - it("does nothing for non-existent metadata", () => { - archiveMetadata(join(tmpDir, "sessions"), "nonexistent"); - // Should not throw - }); -}); - -describe("listSessionFiles", () => { - it("returns empty array for non-existent directory", async () => { - const result = await listSessionFiles(join(tmpDir, "nonexistent")); - expect(result).toEqual([]); - }); - - it("returns session files, excluding dotfiles and archive", async () => { - const sessionDir = join(tmpDir, "sessions"); - mkdirSync(sessionDir); - mkdirSync(join(sessionDir, "archive")); - writeFileSync(join(sessionDir, "app-1"), ""); - writeFileSync(join(sessionDir, "app-2"), ""); - writeFileSync(join(sessionDir, ".hidden"), ""); - - const result = await listSessionFiles(sessionDir); - expect(result.sort()).toEqual(["app-1", "app-2"]); - }); -}); - -describe("findSessionForIssue", () => { - it("finds session by issue ID match", async () => { - const sessionDir = join(tmpDir, "sessions"); - mkdirSync(sessionDir); - writeFileSync(join(sessionDir, "app-1"), "branch=feat/INT-100\nissue=INT-100\n"); - writeFileSync(join(sessionDir, "app-2"), "branch=feat/INT-200\nissue=INT-200\n"); - - const result = await findSessionForIssue(sessionDir, "INT-200", ["app-1", "app-2"]); - expect(result).toBe("app-2"); - }); - - it("returns null when no match found", async () => { - const sessionDir = join(tmpDir, "sessions"); - mkdirSync(sessionDir); - writeFileSync(join(sessionDir, "app-1"), "branch=main\nissue=INT-100\n"); - - const result = await findSessionForIssue(sessionDir, "INT-999", ["app-1"]); - expect(result).toBeNull(); - }); - - it("is case-insensitive", async () => { - const sessionDir = join(tmpDir, "sessions"); - mkdirSync(sessionDir); - writeFileSync(join(sessionDir, "app-1"), "issue=int-100\n"); - - const result = await findSessionForIssue(sessionDir, "INT-100", ["app-1"]); - expect(result).toBe("app-1"); - }); - - it("only matches sessions that are in the tmux list", async () => { - const sessionDir = join(tmpDir, "sessions"); - mkdirSync(sessionDir); - writeFileSync(join(sessionDir, "app-1"), "issue=INT-100\n"); - writeFileSync(join(sessionDir, "app-2"), "issue=INT-200\n"); - - // app-2 is NOT in tmux sessions list - const result = await findSessionForIssue(sessionDir, "INT-200", ["app-1"]); - expect(result).toBeNull(); - }); - - it("filters by project when projectId is specified", async () => { - const sessionDir = join(tmpDir, "sessions"); - mkdirSync(sessionDir); - // Project A has INT-100 - writeFileSync(join(sessionDir, "app-1"), "issue=INT-100\nproject=project-a\n"); - // Project B also has INT-100 (different session) - writeFileSync(join(sessionDir, "backend-1"), "issue=INT-100\nproject=project-b\n"); - - // Should only find project-a's session - const resultA = await findSessionForIssue( - sessionDir, - "INT-100", - ["app-1", "backend-1"], - "project-a", - ); - expect(resultA).toBe("app-1"); - - // Should only find project-b's session - const resultB = await findSessionForIssue( - sessionDir, - "INT-100", - ["app-1", "backend-1"], - "project-b", - ); - expect(resultB).toBe("backend-1"); - - // Without projectId filter, should find first match - const resultAny = await findSessionForIssue(sessionDir, "INT-100", ["app-1", "backend-1"]); - expect(resultAny).toBe("app-1"); - }); - - it("excludes sessions without project field when projectId filter is specified", async () => { - const sessionDir = join(tmpDir, "sessions"); - mkdirSync(sessionDir); - // Legacy session without project field - writeFileSync(join(sessionDir, "legacy-1"), "issue=INT-100\n"); - // Current session with project field - writeFileSync(join(sessionDir, "app-1"), "issue=INT-100\nproject=project-a\n"); - - // Should NOT find legacy session when filtering by project - const result = await findSessionForIssue( - sessionDir, - "INT-100", - ["legacy-1", "app-1"], - "project-a", - ); - expect(result).toBe("app-1"); - - // Without projectId filter, should find one of the sessions (order not guaranteed) - const resultAny = await findSessionForIssue(sessionDir, "INT-100", ["legacy-1", "app-1"]); - expect(["legacy-1", "app-1"]).toContain(resultAny); - }); -}); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index dd8b19a8d..dc603abf5 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -4,7 +4,7 @@ import { existsSync } from "node:fs"; import chalk from "chalk"; import type { Command } from "commander"; import { loadConfig } from "@composio/ao-core"; -import { findWebDir } from "../lib/web-dir.js"; +import { findWebDir, buildDashboardEnv } from "../lib/web-dir.js"; import { cleanNextCache, findRunningDashboardPid, findProcessWebDir, waitForPortFree } from "../lib/dashboard-rebuild.js"; export function registerDashboard(program: Command): void { @@ -16,7 +16,7 @@ export function registerDashboard(program: Command): void { .option("--rebuild", "Clean stale build artifacts and rebuild before starting") .action(async (opts: { port?: string; open?: boolean; rebuild?: boolean }) => { const config = loadConfig(); - const port = opts.port ? parseInt(opts.port, 10) : config.port; + const port = opts.port ? parseInt(opts.port, 10) : (config.port ?? 3000); if (isNaN(port) || port < 1 || port > 65535) { console.error(chalk.red("Invalid port number. Must be 1-65535.")); @@ -62,9 +62,12 @@ export function registerDashboard(program: Command): void { console.log(chalk.bold(`Starting dashboard on http://localhost:${port}\n`)); + const env = buildDashboardEnv(port, config.configPath); + const child = spawn("npx", ["next", "dev", "-p", String(port)], { cwd: webDir, stdio: ["inherit", "inherit", "pipe"], + env, }); const stderrChunks: string[] = []; diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index a95356b72..745ba0295 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -26,7 +26,7 @@ import { } from "@composio/ao-core"; import { exec, getTmuxSessions } from "../lib/shell.js"; import { getAgent } from "../lib/plugins.js"; -import { findWebDir } from "../lib/web-dir.js"; +import { findWebDir, buildDashboardEnv } from "../lib/web-dir.js"; import { cleanNextCache } from "../lib/dashboard-rebuild.js"; /** @@ -71,15 +71,20 @@ function resolveProject( * Start dashboard server in the background. * Returns the child process handle for cleanup. */ -function startDashboard(port: number, webDir: string): ChildProcess { - const child = spawn("npx", ["next", "dev", "-p", String(port)], { +function startDashboard(port: number, webDir: string, configPath: string | null): ChildProcess { + const env = buildDashboardEnv(port, configPath); + + const child = spawn("pnpm", ["run", "dev"], { cwd: webDir, stdio: "inherit", detached: false, + env, }); child.on("error", (err) => { - console.error(chalk.red("Dashboard failed to start:"), err); + console.error(chalk.red("Dashboard failed to start:"), err.message); + // Emit synthetic exit so callers listening on "exit" can clean up + child.emit("exit", 1, null); }); return child; @@ -131,7 +136,7 @@ export function registerStart(program: Command): void { const config = loadConfig(); const { projectId, project } = resolveProject(config, projectArg); const sessionId = `${project.sessionPrefix}-orchestrator`; - const port = config.port; + const port = config.port ?? 3000; console.log(chalk.bold(`\nStarting orchestrator for ${chalk.cyan(project.name)}\n`)); @@ -151,7 +156,7 @@ export function registerStart(program: Command): void { } spinner.start("Starting dashboard"); - dashboardProcess = startDashboard(port, webDir); + dashboardProcess = startDashboard(port, webDir, config.configPath); spinner.succeed(`Dashboard starting on http://localhost:${port}`); console.log(chalk.dim(" (Dashboard will be ready in a few seconds)\n")); } @@ -317,7 +322,7 @@ export function registerStop(program: Command): void { const config = loadConfig(); const { projectId: _projectId, project } = resolveProject(config, projectArg); const sessionId = `${project.sessionPrefix}-orchestrator`; - const port = config.port; + const port = config.port ?? 3000; const sessionsDir = getSessionsDir(config.configPath, project.path); console.log(chalk.bold(`\nStopping orchestrator for ${chalk.cyan(project.name)}\n`)); diff --git a/packages/cli/src/lib/metadata.ts b/packages/cli/src/lib/metadata.ts deleted file mode 100644 index 38da04794..000000000 --- a/packages/cli/src/lib/metadata.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs"; -import { readdir } from "node:fs/promises"; -import { join, basename } from "node:path"; -import type { SessionMetadata } from "@composio/ao-core"; - -export function readMetadata(filePath: string): Partial | null { - if (!existsSync(filePath)) return null; - const content = readFileSync(filePath, "utf-8"); - const meta: Record = {}; - for (const line of content.split("\n")) { - const eq = line.indexOf("="); - if (eq > 0) { - meta[line.slice(0, eq)] = line.slice(eq + 1); - } - } - return meta as Partial; -} - -export function writeMetadata(filePath: string, meta: Partial): void { - mkdirSync(join(filePath, ".."), { recursive: true }); - const lines = Object.entries(meta) - .filter(([, v]) => v !== undefined && v !== null) - .map(([k, v]) => `${k}=${v}`); - writeFileSync(filePath, lines.join("\n") + "\n"); -} - -export function archiveMetadata(sessionDir: string, sessionName: string): void { - const metaFile = join(sessionDir, sessionName); - if (!existsSync(metaFile)) return; - const archiveDir = join(sessionDir, "archive"); - mkdirSync(archiveDir, { recursive: true }); - const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); - renameSync(metaFile, join(archiveDir, `${sessionName}_${timestamp}`)); -} - -export async function listSessionFiles(sessionDir: string): Promise { - if (!existsSync(sessionDir)) return []; - const entries = await readdir(sessionDir); - return entries.filter((e) => !e.startsWith(".") && e !== "archive"); -} - -export async function findSessionForIssue( - sessionDir: string, - issueId: string, - tmuxSessions: string[], - projectId?: string, -): Promise { - const lower = issueId.toLowerCase(); - const files = await listSessionFiles(sessionDir); - for (const file of files) { - const name = basename(file); - if (!tmuxSessions.includes(name)) continue; - const meta = readMetadata(join(sessionDir, file)); - // Skip sessions from other projects if projectId is specified - // Stricter check: sessions without a project field are also excluded - if (projectId && meta?.project !== projectId) continue; - if (meta?.issue && meta.issue.toLowerCase() === lower) { - return name; - } - } - return null; -} diff --git a/packages/cli/src/lib/web-dir.ts b/packages/cli/src/lib/web-dir.ts index 386150eb2..d378593e6 100644 --- a/packages/cli/src/lib/web-dir.ts +++ b/packages/cli/src/lib/web-dir.ts @@ -12,6 +12,26 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const require = createRequire(import.meta.url); +/** + * Build environment variables for spawning the dashboard process. + * Shared between `ao start` and `ao dashboard` to avoid duplication. + */ +export function buildDashboardEnv(port: number, configPath: string | null): Record { + const env: Record = { ...process.env } as Record; + + // Pass config path so dashboard uses the same config as the CLI + if (configPath) { + env["AO_CONFIG_PATH"] = configPath; + } + + // Set ports for client-side access (Next.js requires NEXT_PUBLIC_ prefix) + env["PORT"] = String(port); + env["NEXT_PUBLIC_TERMINAL_PORT"] = env["TERMINAL_PORT"] ?? "3001"; + env["NEXT_PUBLIC_DIRECT_TERMINAL_PORT"] = env["DIRECT_TERMINAL_PORT"] ?? "3003"; + + return env; +} + /** * Locate the @composio/ao-web package directory. * Uses createRequire for ESM-compatible require.resolve, with fallback diff --git a/packages/core/__tests__/config.test.ts b/packages/core/__tests__/config.test.ts new file mode 100644 index 000000000..b06da050c --- /dev/null +++ b/packages/core/__tests__/config.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, writeFileSync, rmSync, realpathSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { loadConfig, findConfigFile } from "../src/config.js"; + +describe("Config Loading", () => { + let testDir: string; + let originalCwd: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + // Create temp test directory + testDir = join(tmpdir(), `ao-test-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + + // Save original state + originalCwd = process.cwd(); + originalEnv = { ...process.env }; + + // Change to test directory + process.chdir(testDir); + }); + + afterEach(() => { + // Restore original state + process.chdir(originalCwd); + process.env = originalEnv; + + // Cleanup test directory + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // Best effort cleanup + } + }); + + describe("findConfigFile", () => { + it("should find config in current directory", () => { + const configPath = join(testDir, "agent-orchestrator.yaml"); + writeFileSync(configPath, "projects: {}"); + + const found = findConfigFile(); + // Use realpathSync to handle macOS /var -> /private/var symlink + expect(realpathSync(found!)).toBe(realpathSync(configPath)); + }); + + it("should prioritize AO_CONFIG_PATH env var", () => { + // Create config in a different location + const customDir = join(testDir, "custom"); + mkdirSync(customDir); + const customConfig = join(customDir, "custom-config.yaml"); + writeFileSync(customConfig, "projects: {}"); + + // Create config in current directory too + const localConfig = join(testDir, "agent-orchestrator.yaml"); + writeFileSync(localConfig, "projects: {}"); + + // Set env var to point to custom location + process.env["AO_CONFIG_PATH"] = customConfig; + + const found = findConfigFile(); + expect(found).toBe(customConfig); + }); + + it("should return null if no config found", () => { + const found = findConfigFile(); + expect(found).toBeNull(); + }); + }); + + describe("loadConfig", () => { + it("should load config from AO_CONFIG_PATH env var", () => { + const configPath = join(testDir, "test-config.yaml"); + writeFileSync( + configPath, + ` +port: 4000 +projects: + test-project: + repo: test/repo + path: ${testDir} + defaultBranch: main +`, + ); + + process.env["AO_CONFIG_PATH"] = configPath; + + const config = loadConfig(); + expect(config.port).toBe(4000); + expect(config.projects["test-project"]).toBeDefined(); + }); + + it("should load config from explicit path parameter", () => { + const configPath = join(testDir, "explicit-config.yaml"); + writeFileSync( + configPath, + ` +port: 5000 +projects: + explicit-project: + repo: test/repo + path: ${testDir} + defaultBranch: main +`, + ); + + const config = loadConfig(configPath); + expect(config.port).toBe(5000); + }); + + it("should throw error if config not found", () => { + expect(() => loadConfig()).toThrow("No agent-orchestrator.yaml found"); + }); + }); + + describe("Config Discovery Priority", () => { + it("should use explicit path over env var", () => { + const envConfig = join(testDir, "env-config.yaml"); + const explicitConfig = join(testDir, "explicit-config.yaml"); + + writeFileSync(envConfig, "port: 3001\nprojects: {}"); + writeFileSync(explicitConfig, "port: 3002\nprojects: {}"); + + process.env["AO_CONFIG_PATH"] = envConfig; + + const config = loadConfig(explicitConfig); + expect(config.port).toBe(3002); // Should use explicit, not env + }); + + it("should use env var over default search", () => { + const envConfig = join(testDir, "env-config.yaml"); + const localConfig = join(testDir, "agent-orchestrator.yaml"); + + writeFileSync(envConfig, "port: 3001\nprojects: {}"); + writeFileSync(localConfig, "port: 3002\nprojects: {}"); + + process.env["AO_CONFIG_PATH"] = envConfig; + + const config = loadConfig(); + expect(config.port).toBe(3001); // Should use env, not local + }); + }); +}); diff --git a/packages/core/src/__tests__/orchestrator-prompt.test.ts b/packages/core/src/__tests__/orchestrator-prompt.test.ts deleted file mode 100644 index f4ce3e5ba..000000000 --- a/packages/core/src/__tests__/orchestrator-prompt.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { generateOrchestratorPrompt } from "../orchestrator-prompt.js"; -import type { OrchestratorConfig, ProjectConfig } from "../types.js"; - -function makeProject(overrides?: Partial): ProjectConfig { - return { - name: "My App", - repo: "org/my-app", - path: "/tmp/my-app", - defaultBranch: "main", - sessionPrefix: "myapp", - ...overrides, - }; -} - -function makeConfig( - project?: Partial, - configOverrides?: Partial, -): OrchestratorConfig { - return { - port: 3000, - configPath: "/tmp/agent-orchestrator.yaml", - projects: { "my-app": makeProject(project) }, - ...configOverrides, - } as OrchestratorConfig; -} - -function generate( - project?: Partial, - configOverrides?: Partial, -): string { - const config = makeConfig(project, configOverrides); - return generateOrchestratorPrompt({ - config, - projectId: "my-app", - project: config.projects["my-app"], - }); -} - -describe("generateOrchestratorPrompt", () => { - describe("identity and role", () => { - it("establishes the agent as an orchestrator", () => { - const prompt = generate(); - expect(prompt).toContain("orchestrator agent"); - expect(prompt).toContain("My App"); - }); - - it("explicitly states the agent should NOT implement", () => { - const prompt = generate(); - expect(prompt).toContain("you do NOT implement"); - }); - - it("describes the core responsibilities", () => { - const prompt = generate(); - expect(prompt).toContain("Spawn"); - expect(prompt).toContain("Monitor"); - expect(prompt).toContain("Intervene"); - expect(prompt).toContain("Delegate"); - expect(prompt).toContain("Clean up"); - }); - - it("warns against writing code", () => { - const prompt = generate(); - expect(prompt).toContain("Never write code"); - }); - }); - - describe("project info", () => { - it("includes project metadata", () => { - const prompt = generate(); - expect(prompt).toContain("My App"); - expect(prompt).toContain("org/my-app"); - expect(prompt).toContain("`main`"); - expect(prompt).toContain("`myapp`"); - expect(prompt).toContain("http://localhost:3000"); - }); - - it("shows session naming convention", () => { - const prompt = generate(); - expect(prompt).toContain("`myapp-1`"); - expect(prompt).toContain("`myapp-2`"); - }); - - it("uses custom port from config", () => { - const prompt = generate({}, { port: 8080 } as Partial); - expect(prompt).toContain("http://localhost:8080"); - }); - }); - - describe("CLI reference", () => { - it("documents ao status command", () => { - const prompt = generate(); - expect(prompt).toContain("### ao status"); - expect(prompt).toContain("ao status"); - expect(prompt).toContain("--json"); - expect(prompt).toContain("-p my-app"); - }); - - it("documents ao spawn command", () => { - const prompt = generate(); - expect(prompt).toContain("### ao spawn"); - expect(prompt).toContain("ao spawn my-app"); - expect(prompt).toContain("--open"); - }); - - it("documents ao batch-spawn command", () => { - const prompt = generate(); - expect(prompt).toContain("### ao batch-spawn"); - expect(prompt).toContain("ao batch-spawn my-app"); - expect(prompt).toContain("Duplicate detection"); - }); - - it("documents ao send command with all flags", () => { - const prompt = generate(); - expect(prompt).toContain("### ao send"); - expect(prompt).toContain("ao send myapp-1"); - expect(prompt).toContain("-f"); - expect(prompt).toContain("--no-wait"); - expect(prompt).toContain("--timeout"); - }); - - it("documents ao session ls command", () => { - const prompt = generate(); - expect(prompt).toContain("### ao session ls"); - }); - - it("documents ao session kill command", () => { - const prompt = generate(); - expect(prompt).toContain("### ao session kill"); - expect(prompt).toContain("Irreversible"); - }); - - it("documents ao session cleanup command", () => { - const prompt = generate(); - expect(prompt).toContain("### ao session cleanup"); - expect(prompt).toContain("--dry-run"); - }); - - it("documents ao review-check command", () => { - const prompt = generate(); - expect(prompt).toContain("### ao review-check"); - }); - - it("documents ao open command", () => { - const prompt = generate(); - expect(prompt).toContain("### ao open"); - expect(prompt).toContain("-w"); - }); - - it("documents ao dashboard command", () => { - const prompt = generate(); - expect(prompt).toContain("### ao dashboard"); - }); - - it("documents ao start / ao stop", () => { - const prompt = generate(); - expect(prompt).toContain("### ao start"); - expect(prompt).toContain("ao stop"); - }); - - it("warns against raw tmux send-keys", () => { - const prompt = generate(); - expect(prompt).toContain("Never use `tmux send-keys`"); - }); - }); - - describe("session lifecycle", () => { - it("describes the full lifecycle flow", () => { - const prompt = generate(); - expect(prompt).toContain("Session Lifecycle"); - expect(prompt).toContain("Worktree created"); - expect(prompt).toContain("Feature branch"); - expect(prompt).toContain("tmux session"); - expect(prompt).toContain("Agent launched"); - expect(prompt).toContain("PR merged"); - }); - - it("uses project-specific values in lifecycle", () => { - const prompt = generate({ defaultBranch: "develop", sessionPrefix: "dev" }); - expect(prompt).toContain("origin/develop"); - expect(prompt).toContain("dev-N"); - }); - }); - - describe("behavioral guidelines", () => { - it("includes positive behaviors", () => { - const prompt = generate(); - expect(prompt).toContain("Check before spawning"); - expect(prompt).toContain("ao send"); - expect(prompt).toContain("Delegate, don't duplicate"); - expect(prompt).toContain("Batch when possible"); - expect(prompt).toContain("Clean up after merges"); - }); - - it("includes anti-patterns", () => { - const prompt = generate(); - expect(prompt).toContain("Never write code"); - expect(prompt).toContain("Never use legacy scripts"); - expect(prompt).toContain("Never use raw tmux"); - expect(prompt).toContain("Never spawn for trivial tasks"); - expect(prompt).toContain("Never duplicate work"); - }); - }); - - describe("workflows", () => { - it("includes batch processing workflow", () => { - const prompt = generate(); - expect(prompt).toContain("Process a Batch of Issues"); - expect(prompt).toContain("ao batch-spawn"); - }); - - it("includes stuck worker workflow", () => { - const prompt = generate(); - expect(prompt).toContain("Handle a Stuck Worker"); - expect(prompt).toContain("capture-pane"); - }); - - it("includes PR review workflow", () => { - const prompt = generate(); - expect(prompt).toContain("Handle PR Review Comments"); - expect(prompt).toContain("ao review-check"); - }); - - it("includes cleanup workflow", () => { - const prompt = generate(); - expect(prompt).toContain("Clean Up After Merge"); - expect(prompt).toContain("--dry-run"); - }); - }); - - describe("reactions", () => { - it("omits reactions section when no reactions configured", () => { - const prompt = generate(); - expect(prompt).not.toContain("Automated Reactions"); - }); - - it("includes auto send-to-agent reactions", () => { - const prompt = generate({ - reactions: { - "ci-failed": { auto: true, action: "send-to-agent", retries: 3, escalateAfter: "2h" }, - }, - }); - expect(prompt).toContain("Automated Reactions"); - expect(prompt).toContain("ci-failed"); - expect(prompt).toContain("retries: 3"); - expect(prompt).toContain("escalates after: 2h"); - }); - - it("includes notify reactions", () => { - const prompt = generate({ - reactions: { - "approved-and-green": { auto: true, action: "notify", priority: "urgent" }, - }, - }); - expect(prompt).toContain("approved-and-green"); - expect(prompt).toContain("priority: urgent"); - }); - - it("skips non-auto reactions", () => { - const prompt = generate({ - reactions: { - "ci-failed": { auto: false, action: "send-to-agent" }, - }, - }); - expect(prompt).not.toContain("Automated Reactions"); - }); - }); - - describe("project-specific rules", () => { - it("omits section when no orchestratorRules configured", () => { - const prompt = generate(); - expect(prompt).not.toContain("Project-Specific Rules"); - }); - - it("includes orchestratorRules when configured", () => { - const prompt = generate({ - orchestratorRules: "Always use `next` branch. Never push directly to main.", - }); - expect(prompt).toContain("Project-Specific Rules"); - expect(prompt).toContain("Always use `next` branch"); - expect(prompt).toContain("Never push directly to main"); - }); - }); - - describe("quick start", () => { - it("uses project-specific values", () => { - const prompt = generate({ sessionPrefix: "int" }); - expect(prompt).toContain("ao spawn my-app"); - expect(prompt).toContain("ao send int-1"); - expect(prompt).toContain("ao session ls -p my-app"); - expect(prompt).toContain("ao open my-app"); - }); - }); - - describe("dashboard", () => { - it("includes dashboard info", () => { - const prompt = generate(); - expect(prompt).toContain("Dashboard"); - expect(prompt).toContain("http://localhost:3000"); - expect(prompt).toContain("Server-Sent Events"); - }); - }); -}); diff --git a/packages/core/src/__tests__/utils.test.ts b/packages/core/src/__tests__/utils.test.ts new file mode 100644 index 000000000..e967c5a7d --- /dev/null +++ b/packages/core/src/__tests__/utils.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { readLastJsonlEntry } from "../utils.js"; + +describe("readLastJsonlEntry", () => { + let tmpDir: string; + + afterEach(() => { + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + }); + + function setup(content: string): string { + tmpDir = mkdtempSync(join(tmpdir(), "ao-utils-test-")); + const filePath = join(tmpDir, "test.jsonl"); + writeFileSync(filePath, content, "utf-8"); + return filePath; + } + + it("returns null for empty file", async () => { + const path = setup(""); + expect(await readLastJsonlEntry(path)).toBeNull(); + }); + + it("returns null for nonexistent file", async () => { + expect(await readLastJsonlEntry("/tmp/nonexistent-ao-test.jsonl")).toBeNull(); + }); + + it("reads last entry type from single-line JSONL", async () => { + const path = setup('{"type":"assistant","message":"hello"}\n'); + const result = await readLastJsonlEntry(path); + expect(result).not.toBeNull(); + expect(result!.lastType).toBe("assistant"); + }); + + it("reads last entry from multi-line JSONL", async () => { + const path = setup( + '{"type":"human","text":"hi"}\n{"type":"assistant","text":"hello"}\n{"type":"result","text":"done"}\n', + ); + const result = await readLastJsonlEntry(path); + expect(result!.lastType).toBe("result"); + }); + + it("handles trailing newlines", async () => { + const path = setup('{"type":"done"}\n\n\n'); + const result = await readLastJsonlEntry(path); + expect(result!.lastType).toBe("done"); + }); + + it("returns lastType null for entry without type field", async () => { + const path = setup('{"message":"no type"}\n'); + const result = await readLastJsonlEntry(path); + expect(result).not.toBeNull(); + expect(result!.lastType).toBeNull(); + }); + + it("returns null for invalid JSON", async () => { + const path = setup("not json at all\n"); + expect(await readLastJsonlEntry(path)).toBeNull(); + }); + + it("handles multi-byte UTF-8 characters in JSONL entries", async () => { + // Create a JSONL entry with multi-byte characters (CJK, emoji) + const entry = { type: "assistant", text: "日本語テスト 🎉 données résumé" }; + const path = setup(JSON.stringify(entry) + "\n"); + const result = await readLastJsonlEntry(path); + expect(result!.lastType).toBe("assistant"); + }); + + it("handles multi-byte UTF-8 at chunk boundaries", async () => { + // Create content larger than the 4096 byte chunk size with multi-byte + // characters that could straddle a boundary. Each 🎉 is 4 bytes. + const padding = '{"type":"padding","data":"' + "x".repeat(4080) + '"}\n'; + // The emoji-heavy last line will be at a chunk boundary + const lastLine = { type: "final", text: "🎉".repeat(100) }; + const path = setup(padding + JSON.stringify(lastLine) + "\n"); + const result = await readLastJsonlEntry(path); + expect(result!.lastType).toBe("final"); + }); + + it("returns modifiedAt as a Date", async () => { + const path = setup('{"type":"test"}\n'); + const result = await readLastJsonlEntry(path); + expect(result!.modifiedAt).toBeInstanceOf(Date); + }); +}); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 728752895..713b29b33 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -279,15 +279,15 @@ function applyDefaultReactions(config: OrchestratorConfig): OrchestratorConfig { * Search for config file in standard locations. * * Search order: - * 1. AO_CONFIG environment variable (if set) + * 1. AO_CONFIG_PATH environment variable (if set) * 2. Search up directory tree from CWD (like git) * 3. Explicit startDir (if provided) * 4. Home directory locations */ -function findConfigFile(startDir?: string): string | null { +export function findConfigFile(startDir?: string): string | null { // 1. Check environment variable override - if (process.env.AO_CONFIG) { - const envPath = resolve(process.env.AO_CONFIG); + if (process.env["AO_CONFIG_PATH"]) { + const envPath = resolve(process.env["AO_CONFIG_PATH"]); if (existsSync(envPath)) { return envPath; } @@ -357,6 +357,8 @@ export function findConfig(startDir?: string): string | null { /** Load and validate config from a YAML file */ export function loadConfig(configPath?: string): OrchestratorConfig { + // Priority: 1. Explicit param, 2. Search (including AO_CONFIG_PATH env var) + // findConfigFile handles AO_CONFIG_PATH validation, so delegate to it const path = configPath ?? findConfigFile(); if (!path) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 65ee602fb..9c8d60a08 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,6 +15,7 @@ export { validateConfig, getDefaultConfig, findConfig, + findConfigFile, } from "./config.js"; // Plugin registry diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index 7193b8cb2..632722962 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -99,6 +99,9 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta project: raw["project"], createdAt: raw["createdAt"], runtimeHandle: raw["runtimeHandle"], + dashboardPort: raw["dashboardPort"] ? Number(raw["dashboardPort"]) : undefined, + terminalWsPort: raw["terminalWsPort"] ? Number(raw["terminalWsPort"]) : undefined, + directTerminalWsPort: raw["directTerminalWsPort"] ? Number(raw["directTerminalWsPort"]) : undefined, }; } @@ -138,6 +141,12 @@ export function writeMetadata( if (metadata.project) data["project"] = metadata.project; if (metadata.createdAt) data["createdAt"] = metadata.createdAt; if (metadata.runtimeHandle) data["runtimeHandle"] = metadata.runtimeHandle; + if (metadata.dashboardPort !== undefined) + data["dashboardPort"] = String(metadata.dashboardPort); + if (metadata.terminalWsPort !== undefined) + data["terminalWsPort"] = String(metadata.terminalWsPort); + if (metadata.directTerminalWsPort !== undefined) + data["directTerminalWsPort"] = String(metadata.directTerminalWsPort); writeFileSync(path, serializeMetadata(data), "utf-8"); } diff --git a/packages/core/src/orchestrator-prompt.ts b/packages/core/src/orchestrator-prompt.ts index 2a109471d..656fd80c3 100644 --- a/packages/core/src/orchestrator-prompt.ts +++ b/packages/core/src/orchestrator-prompt.ts @@ -3,10 +3,6 @@ * * This file is imported into CLAUDE.local.md (gitignored) in the main checkout * to provide orchestrator-specific context when the orchestrator agent runs. - * - * The generated prompt is the orchestrator agent's primary reference. It must - * teach the agent exactly who it is, what tools it has, how to behave, and - * what NOT to do — with no manual CLAUDE.md customization required. */ import type { OrchestratorConfig, ProjectConfig } from "./types.js"; @@ -19,236 +15,128 @@ export interface OrchestratorPromptConfig { /** * Generate markdown content for CLAUDE.orchestrator.md. - * - * The generated prompt covers: - * 1. Identity and role — orchestrator, not a worker - * 2. Complete CLI reference — every `ao` command with flags and examples - * 3. Behavioral guidelines — when to spawn, delegate, monitor, intervene - * 4. Anti-patterns — what NOT to do - * 5. Project configuration — adapted to the specific project setup + * Provides orchestrator agent with context about available commands, + * session management workflows, and project configuration. */ export function generateOrchestratorPrompt(opts: OrchestratorPromptConfig): string { const { config, projectId, project } = opts; - const prefix = project.sessionPrefix; const sections: string[] = []; - // ========================================================================= - // IDENTITY - // ========================================================================= + // Header + sections.push(`# CLAUDE.orchestrator.md - ${project.name} Orchestrator - sections.push(`# Orchestrator Agent — ${project.name} +You are the **orchestrator agent** for the ${project.name} project. -You are the **orchestrator agent** for ${project.name}. You plan, delegate, and monitor — you do NOT implement. +Your role is to coordinate and manage worker agent sessions. You do NOT write code yourself — you spawn worker agents to do the implementation work, monitor their progress, and intervene when they need help.`); -## Your Role + // Project Info + sections.push(`## Project Info -You manage a fleet of parallel worker agents that do the actual coding: -- **Spawn** worker sessions for issues/tickets (each gets its own git worktree + tmux session) -- **Monitor** their progress via \`ao status\` and the dashboard -- **Intervene** when workers are stuck, CI fails, or reviewers request changes -- **Delegate** by sending messages to workers via \`ao send\` -- **Clean up** completed sessions after PRs are merged - -You are NOT a coding agent. Never implement features, fix bugs, or write code yourself. If something needs implementation, spawn a worker session for it.`); - - // ========================================================================= - // PROJECT INFO - // ========================================================================= - - sections.push(`## Project - -| Property | Value | -|----------|-------| -| Name | ${project.name} | -| Repository | ${project.repo} | -| Default Branch | \`${project.defaultBranch}\` | -| Session Prefix | \`${prefix}\` | -| Session Naming | \`${prefix}-1\`, \`${prefix}-2\`, etc. | -| Dashboard | http://localhost:${config.port} |`); - - // ========================================================================= - // QUICK START - // ========================================================================= +- **Name**: ${project.name} +- **Repository**: ${project.repo} +- **Default Branch**: ${project.defaultBranch} +- **Session Prefix**: ${project.sessionPrefix} +- **Local Path**: ${project.path} +- **Dashboard Port**: ${config.port ?? 3000}`); + // Quick Start sections.push(`## Quick Start \`\`\`bash -ao status # See all sessions -ao spawn ${projectId} ISSUE-123 # Spawn one session -ao batch-spawn ${projectId} ISSUE-1 ISSUE-2 # Spawn multiple -ao send ${prefix}-1 "fix the CI failure" # Send message to worker -ao session ls -p ${projectId} # List sessions -ao session cleanup -p ${projectId} # Remove merged sessions -ao open ${projectId} # Open all in terminal tabs +# See all sessions at a glance +ao status + +# Spawn sessions for issues (GitHub: #123, Linear: INT-1234, etc.) +ao spawn ${projectId} INT-1234 +ao batch-spawn ${projectId} INT-1 INT-2 INT-3 + +# List sessions +ao session ls -p ${projectId} + +# Send message to a session +ao send ${project.sessionPrefix}-1 "Your message here" + +# Kill a session +ao session kill ${project.sessionPrefix}-1 + +# Open all sessions in terminal tabs +ao open ${projectId} \`\`\``); - // ========================================================================= - // CLI REFERENCE — every command with full detail - // ========================================================================= + // Available Commands + sections.push(`## Available Commands - sections.push(buildCLIReference(projectId, project, config)); +| Command | Description | +|---------|-------------| +| \`ao status\` | Show all sessions with PR/CI/review status | +| \`ao spawn [issue]\` | Spawn a single worker agent session | +| \`ao batch-spawn \` | Spawn multiple sessions in parallel | +| \`ao session ls [-p project]\` | List all sessions (optionally filter by project) | +| \`ao session attach \` | Attach to a session's tmux window | +| \`ao session kill \` | Kill a specific session | +| \`ao session cleanup [-p project]\` | Kill completed/merged sessions | +| \`ao send \` | Send a message to a running session | +| \`ao dashboard\` | Start the web dashboard (http://localhost:${config.port ?? 3000}) | +| \`ao open \` | Open all project sessions in terminal tabs |`); - // ========================================================================= - // SESSION LIFECYCLE - // ========================================================================= + // Session Management + sections.push(`## Session Management - sections.push(`## Session Lifecycle +### Spawning Sessions -\`\`\` -ao spawn ${projectId} ISSUE-123 - | - v -[Worktree created from origin/${project.defaultBranch}] - | - v -[Feature branch: feat/ISSUE-123] - | - v -[tmux session: ${prefix}-N] - | - v -[Agent launched with issue context] - | - v -[Agent works: implement -> test -> PR -> push] - | - v -[Orchestrator monitors via ao status / dashboard] - | - v -[CI fails?] --yes--> reaction auto-sends fix instructions to agent - |no - v -[Review comments?] --yes--> reaction auto-forwards to agent - |no - v -[PR merged] --> ao session cleanup removes session -\`\`\` +When you spawn a session: +1. A git worktree is created from \`${project.defaultBranch}\` +2. A feature branch is created (e.g., \`feat/INT-1234\`) +3. A tmux session is started (e.g., \`${project.sessionPrefix}-1\`) +4. The agent is launched with context about the issue +5. Metadata is written to the project-specific sessions directory -Each worker session is fully isolated: -- Own git worktree (separate working directory) -- Own tmux session (can attach/detach independently) -- Own feature branch (no conflicts between workers) -- Metadata file tracking branch, PR, status, issue`); +### Monitoring Progress - // ========================================================================= - // BEHAVIORAL GUIDELINES - // ========================================================================= +Use \`ao status\` to see: +- Current session status (working, pr_open, review_pending, etc.) +- PR state (open/merged/closed) +- CI status (passing/failing/pending) +- Review decision (approved/changes_requested/pending) +- Unresolved comments count - sections.push(`## How to Behave +### Sending Messages -### Always Do - -1. **Check before spawning** — Run \`ao status\` first. Never create duplicate sessions for the same issue. -2. **Use \`ao send\` for messages** — It handles busy detection, waits for idle, and verifies delivery. Never use raw \`tmux send-keys\`. -3. **Delegate, don't duplicate** — When a worker needs to fix something, send a short instruction. Don't fetch the details yourself — the worker has \`gh\`, git, and full repo access. -4. **Batch when possible** — Use \`ao batch-spawn\` for multiple issues. It has built-in duplicate detection. -5. **Monitor, don't micromanage** — Check \`ao status\` periodically. Only intervene when a session is stuck or needs input. -6. **Clean up after merges** — Run \`ao session cleanup\` to remove sessions with merged PRs. -7. **Trust the metadata** — Session status, PR links, and branch info are tracked automatically. -8. **Verify message delivery** — \`ao send\` confirms delivery. If it reports uncertainty, check the session. - -### Never Do - -1. **Never write code** — You are the orchestrator. Spawn a worker for any implementation task. -2. **Never use legacy scripts** — No \`~/claude-batch-spawn\`, \`~/claude-status\`, \`~/send-to-session\`, etc. Use the \`ao\` CLI exclusively. -3. **Never use raw tmux commands** — Don't \`tmux send-keys\` directly. Use \`ao send\` which handles busy detection and delivery verification. -4. **Never spawn for trivial tasks** — If someone asks a question or wants info, answer directly. Only spawn workers for implementation tasks. -5. **Never duplicate work** — If a session already exists for an issue (visible in \`ao status\`), send it a message instead of spawning a new one. -6. **Never kill working sessions** — Check \`ao status\` activity before killing. Only kill sessions that are stuck/done.`); - - // ========================================================================= - // WORKFLOWS - // ========================================================================= - - sections.push(`## Common Workflows - -### Process a Batch of Issues +Send instructions to a running agent: \`\`\`bash -# 1. Check what's already running -ao status - -# 2. Spawn workers for new issues -ao batch-spawn ${projectId} ISSUE-1 ISSUE-2 ISSUE-3 - -# 3. Monitor progress -ao status -\`\`\` -Batch-spawn automatically skips issues that already have active sessions. - -### Handle a Stuck Worker -\`\`\`bash -# 1. Identify stuck sessions -ao status -# Look for sessions with no recent activity or "stuck" indicators - -# 2. Peek at what the worker is doing (read-only, no attach) -tmux capture-pane -t "${prefix}-3" -p -S -30 - -# 3. Send help -ao send ${prefix}-3 "You seem stuck on X. Try Y instead." - -# 4. If unrecoverable, kill and respawn -ao session kill ${prefix}-3 -ao spawn ${projectId} ISSUE-123 +ao send ${project.sessionPrefix}-1 "Please address the review comments on your PR" \`\`\` -### Handle PR Review Comments +### Cleanup + +Remove completed sessions: \`\`\`bash -# Option 1: Automatic — ao review-check scans all PRs and sends fix prompts -ao review-check ${projectId} - -# Option 2: Manual — send targeted instruction to a specific worker -ao send ${prefix}-2 "Address the review comments on your PR" -\`\`\` -Workers have full \`gh\` access. Keep messages short — don't fetch/paste review comments yourself. - -### Clean Up After Merge -\`\`\`bash -# Dry run first to see what would be cleaned -ao session cleanup -p ${projectId} --dry-run - -# Actually clean up -ao session cleanup -p ${projectId} -\`\`\` - -### Open Sessions in Terminal -\`\`\`bash -ao open ${projectId} # All sessions for this project -ao open ${prefix}-3 # Specific session -ao open all # Everything across all projects -ao open ${projectId} -w # In a new terminal window +ao session cleanup -p ${projectId} # Kill sessions where PR is merged or issue is closed \`\`\``); - // ========================================================================= - // DASHBOARD - // ========================================================================= - + // Dashboard sections.push(`## Dashboard -The web dashboard at **http://localhost:${config.port}** provides: -- Live session cards with real-time activity status -- PR table showing CI checks, review state, and merge readiness -- Attention zones: merge-ready, needs-response, working, done -- One-click actions: send message, kill session, merge PR -- Real-time updates via Server-Sent Events +The web dashboard runs at **http://localhost:${config.port ?? 3000}**. -Use the dashboard for at-a-glance overview, the CLI for detailed operations.`); - - // ========================================================================= - // REACTIONS (if configured) - // ========================================================================= +Features: +- Live session cards with activity status +- PR table with CI checks and review state +- Attention zones (merge ready, needs response, working, done) +- One-click actions (send message, kill, merge PR) +- Real-time updates via Server-Sent Events`); + // Reactions (if configured) if (project.reactions && Object.keys(project.reactions).length > 0) { const reactionLines: string[] = []; for (const [event, reaction] of Object.entries(project.reactions)) { if (reaction.auto && reaction.action === "send-to-agent") { reactionLines.push( - `- **${event}**: Auto-sends fix instructions to the agent (retries: ${reaction.retries ?? "none"}, escalates after: ${reaction.escalateAfter ?? "never"})`, + `- **${event}**: Auto-sends instruction to agent (retries: ${reaction.retries ?? "none"}, escalates after: ${reaction.escalateAfter ?? "never"})`, ); } else if (reaction.auto && reaction.action === "notify") { reactionLines.push( - `- **${event}**: Sends notification to human (priority: ${reaction.priority ?? "info"})`, + `- **${event}**: Notifies human (priority: ${reaction.priority ?? "info"})`, ); } } @@ -256,18 +144,63 @@ Use the dashboard for at-a-glance overview, the CLI for detailed operations.`); if (reactionLines.length > 0) { sections.push(`## Automated Reactions -These events are handled automatically — you do NOT need to intervene unless the auto-handling fails: +The system automatically handles these events: -${reactionLines.join("\n")} - -Reactions that auto-send to agents will retry and escalate to you if the agent doesn't fix the issue within the configured window.`); +${reactionLines.join("\n")}`); } } - // ========================================================================= - // PROJECT-SPECIFIC RULES (if any) - // ========================================================================= + // Workflows + sections.push(`## Common Workflows +### Bulk Issue Processing +1. Get list of issues from tracker (GitHub/Linear/etc.) +2. Use \`ao batch-spawn\` to spawn sessions for each issue +3. Monitor with \`ao status\` or the dashboard +4. Agents will fetch, implement, test, PR, and respond to reviews +5. Use \`ao session cleanup\` when PRs are merged + +### Handling Stuck Agents +1. Check \`ao status\` for sessions in "stuck" or "needs_input" state +2. Attach with \`ao session attach \` to see what they're doing +3. Send clarification or instructions with \`ao send '...'\` +4. Or kill and respawn with fresh context if needed + +### PR Review Flow +1. Agent creates PR and pushes +2. CI runs automatically +3. If CI fails: reaction auto-sends fix instructions to agent +4. If reviewers request changes: reaction auto-sends comments to agent +5. When approved + green: notify human to merge (unless auto-merge enabled) + +### Manual Intervention +When an agent needs human judgment: +1. You'll get a notification (desktop/slack/webhook) +2. Check the dashboard or \`ao status\` for details +3. Attach to the session if needed: \`ao session attach \` +4. Send instructions: \`ao send '...'\` +5. Or handle it yourself (merge PR, close issue, etc.)`); + + // Tips + sections.push(`## Tips + +1. **Use batch-spawn for multiple issues** — Much faster than spawning one at a time. + +2. **Check status before spawning** — Avoid creating duplicate sessions for issues already being worked on. + +3. **Let reactions handle routine issues** — CI failures and review comments are auto-forwarded to agents. + +4. **Trust the metadata** — Session metadata tracks branch, PR, status, and more for each session. + +5. **Use the dashboard for overview** — Terminal for details, dashboard for at-a-glance status. + +6. **Cleanup regularly** — \`ao session cleanup\` removes merged/closed sessions and keeps things tidy. + +7. **Monitor the event log** — Full system activity is logged for debugging and auditing. + +8. **Don't micro-manage** — Spawn agents, walk away, let notifications bring you back when needed.`); + + // Project-specific rules (if any) if (project.orchestratorRules) { sections.push(`## Project-Specific Rules @@ -276,194 +209,3 @@ ${project.orchestratorRules}`); return sections.join("\n\n"); } - -// ============================================================================= -// CLI REFERENCE BUILDER -// ============================================================================= - -/** - * Build a comprehensive CLI reference section documenting every `ao` command - * with all flags, options, and usage examples. - */ -function buildCLIReference( - projectId: string, - project: ProjectConfig, - config: OrchestratorConfig, -): string { - const prefix = project.sessionPrefix; - - return `## CLI Reference - -### ao status - -Show all sessions with branch, PR, CI, review status, and agent activity. - -\`\`\`bash -ao status # All projects -ao status -p ${projectId} # Filter to this project -ao status --json # Machine-readable JSON output -\`\`\` - -**Output columns**: Session, Branch, PR#, CI (pass/fail/pending), Review (approved/changes/pending), Threads (unresolved comment count), Activity (working/idle/waiting/exited), Age. - -Each session also shows the agent's auto-generated summary of what it's working on. - ---- - -### ao spawn - -Spawn a single worker agent session for an issue. - -\`\`\`bash -ao spawn ${projectId} ISSUE-123 # Spawn with issue -ao spawn ${projectId} # Spawn without issue (bare session) -ao spawn ${projectId} ISSUE-123 --open # Also open in terminal tab -\`\`\` - -**What happens**: Creates git worktree from \`origin/${project.defaultBranch}\`, creates feature branch (\`feat/ISSUE-123\`), starts tmux session (\`${prefix}-N\`), launches agent with composed prompt, writes metadata. - -**Issue format**: Accepts any identifier — GitHub issues (\`#42\`, \`42\`), Linear tickets (\`INT-1234\`), Jira keys (\`PROJ-567\`), etc. - ---- - -### ao batch-spawn - -Spawn sessions for multiple issues at once with duplicate detection. - -\`\`\`bash -ao batch-spawn ${projectId} ISSUE-1 ISSUE-2 ISSUE-3 -ao batch-spawn ${projectId} ISSUE-1 ISSUE-2 --open # Also open tabs -\`\`\` - -**Duplicate detection**: Skips issues that already have an active session (checks both existing sessions and within the current batch). Reports a summary of created/skipped/failed. - ---- - -### ao send - -Send a message to a running worker agent. Handles busy detection and delivery verification. - -\`\`\`bash -ao send ${prefix}-1 "Fix the failing test in auth.test.ts" -ao send ${prefix}-1 -f /tmp/detailed-instructions.txt # From file -ao send ${prefix}-1 --no-wait "Urgent: stop what you're doing" # Skip idle wait -ao send ${prefix}-1 --timeout 120 "Take your time" # Custom timeout (seconds) -\`\`\` - -**How it works**: -1. Waits for the session to become idle (default: up to 600s) -2. Clears any partial input in the session -3. Sends the message (multi-line messages use tmux buffer loading) -4. Presses Enter to submit -5. Verifies delivery by checking for agent activity indicators -6. Retries Enter up to 3 times if delivery isn't confirmed - -**Always use \`ao send\`** instead of raw \`tmux send-keys\`. It solves the hard problems: busy detection, input clearing, long message handling, delivery verification. - ---- - -### ao session ls - -List all sessions with metadata. - -\`\`\`bash -ao session ls # All projects -ao session ls -p ${projectId} # Filter to this project -\`\`\` - -Shows: session name, age, branch, status, PR link. - ---- - -### ao session kill - -Kill a session, remove its worktree, and archive its metadata. - -\`\`\`bash -ao session kill ${prefix}-3 -\`\`\` - -**Irreversible**: Removes the git worktree (uncommitted/unpushed work is lost). Only kill sessions that have pushed their work or are truly stuck. - ---- - -### ao session cleanup - -Automatically kill sessions where the PR has been merged. - -\`\`\`bash -ao session cleanup # All projects -ao session cleanup -p ${projectId} # This project only -ao session cleanup -p ${projectId} --dry-run # Preview what would be killed -\`\`\` - -Always run with \`--dry-run\` first when unsure. - ---- - -### ao review-check - -Scan all sessions with PRs for pending review comments and send fix instructions. - -\`\`\`bash -ao review-check # All projects -ao review-check ${projectId} # This project only -ao review-check ${projectId} --dry-run # Preview without sending -\`\`\` - -**What it does**: Finds PRs with unresolved review threads or "changes requested" decisions, then sends a fix prompt to the corresponding worker agent. - ---- - -### ao open - -Open session(s) in terminal tabs. - -\`\`\`bash -ao open ${prefix}-3 # Specific session -ao open ${projectId} # All sessions for this project -ao open all # All sessions across all projects -ao open ${projectId} -w # Open in new terminal window -\`\`\` - ---- - -### ao dashboard - -Start the web dashboard manually (usually started by \`ao start\`). - -\`\`\`bash -ao dashboard # Start on configured port (${config.port}) -ao dashboard -p 8080 # Custom port -ao dashboard --no-open # Don't auto-open browser -\`\`\` - ---- - -### ao start / ao stop - -Start or stop the orchestrator agent and dashboard. - -\`\`\`bash -ao start ${projectId} # Start everything -ao start ${projectId} --no-dashboard # Skip dashboard -ao start ${projectId} --no-orchestrator # Skip orchestrator agent -ao stop ${projectId} # Stop everything -\`\`\` - ---- - -### tmux (read-only inspection) - -For read-only inspection of sessions, you can use tmux directly: - -\`\`\`bash -# Peek at last 30 lines of a session (read-only, no attach) -tmux capture-pane -t "${prefix}-3" -p -S -30 - -# List all tmux sessions -tmux ls -\`\`\` - -Never use \`tmux send-keys\` — use \`ao send\` instead.`; -} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1b185d9dd..753c5be1f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -254,7 +254,6 @@ export interface Agent { * run git/gh commands. Without this, PRs created by agents never show up. */ setupWorkspaceHooks?(workspacePath: string, config: WorkspaceHooksConfig): Promise; - } export interface AgentLaunchConfig { @@ -264,15 +263,11 @@ export interface AgentLaunchConfig { prompt?: string; permissions?: "skip" | "default"; model?: string; - /** - * System-level instructions to inject into the agent's context. - * Used by `ao start` to give the orchestrator agent its instructions. - * - * Each agent plugin handles this in getLaunchCommand(): + * System prompt to pass to the agent for orchestrator context. * - Claude Code: --append-system-prompt * - Codex: --system-prompt or AGENTS.md - * - Aider: --read flag or conventions + * - Aider: --system-prompt flag * - OpenCode: equivalent mechanism */ systemPrompt?: string; @@ -728,8 +723,8 @@ export interface OrchestratorConfig { */ configPath: string; - /** Web dashboard port */ - port: number; + /** Web dashboard port (defaults to 3000) */ + port?: number; /** Milliseconds before a "ready" session becomes "idle" (default: 300000 = 5 min) */ readyThresholdMs: number; @@ -890,6 +885,9 @@ export interface SessionMetadata { project?: string; createdAt?: string; runtimeHandle?: string; + dashboardPort?: number; + terminalWsPort?: number; + directTerminalWsPort?: number; } // ============================================================================= diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 253cde379..7085ca1c3 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -2,11 +2,7 @@ * Shared utility functions for agent-orchestrator plugins. */ -import { execFile } from "node:child_process"; -import { stat } from "node:fs/promises"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); +import { open, stat } from "node:fs/promises"; /** * POSIX-safe shell escaping: wraps value in single quotes, @@ -36,9 +32,57 @@ export function validateUrl(url: string, label: string): void { } } +/** + * Read the last line from a file by reading backwards from the end. + * Pure Node.js — no external binaries. Handles any file size. + */ +async function readLastLine(filePath: string): Promise { + const CHUNK = 4096; + const fh = await open(filePath, "r"); + try { + const { size } = await fh.stat(); + if (size === 0) return null; + + // Read backwards in chunks, accumulating raw buffers to avoid + // corrupting multi-byte UTF-8 characters at chunk boundaries. + const chunks: Buffer[] = []; + let totalBytes = 0; + let pos = size; + + while (pos > 0) { + const readSize = Math.min(CHUNK, pos); + pos -= readSize; + const chunk = Buffer.alloc(readSize); + await fh.read(chunk, 0, readSize, pos); + chunks.unshift(chunk); + totalBytes += readSize; + + // Convert all accumulated bytes to string at once (safe for multi-byte) + const tail = Buffer.concat(chunks, totalBytes).toString("utf-8"); + + // Find the last non-empty line + const lines = tail.split("\n"); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (line) { + // If i > 0, we have a complete line (there's a newline before it) + // If i === 0 and pos === 0, we've read the whole file — line is complete + // If i === 0 and pos > 0, the line may be truncated — keep reading + if (i > 0 || pos === 0) return line; + } + } + } + + const tail = Buffer.concat(chunks, totalBytes).toString("utf-8"); + return tail.trim() || null; + } finally { + await fh.close(); + } +} + /** * Read the last entry from a JSONL file. - * Uses `tail -1` to efficiently read the last line, then JSON.parse in Node. + * Reads backwards from end of file — pure Node.js, no external binaries. * * @param filePath - Path to the JSONL file * @returns Object containing the last entry's type and file mtime, or null if empty/invalid @@ -47,12 +91,9 @@ export async function readLastJsonlEntry( filePath: string, ): Promise<{ lastType: string | null; modifiedAt: Date } | null> { try { - const [{ stdout }, fileStat] = await Promise.all([ - execFileAsync("tail", ["-1", filePath], { timeout: 5_000 }), - stat(filePath), - ]); + const [line, fileStat] = await Promise.all([readLastLine(filePath), stat(filePath)]); + - const line = stdout.trim(); if (!line) return null; const parsed: unknown = JSON.parse(line); diff --git a/packages/integration-tests/src/config-metadata-service.integration.test.ts b/packages/integration-tests/src/config-metadata-service.integration.test.ts new file mode 100644 index 000000000..bff3cee0c --- /dev/null +++ b/packages/integration-tests/src/config-metadata-service.integration.test.ts @@ -0,0 +1,214 @@ +/** + * Integration test for config discovery → metadata service flow. + * + * Tests the full chain from config file → hash-based directory calculation → + * metadata operations, verifying that the service layer architecture works + * end-to-end with real filesystem I/O. + */ + +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { mkdirSync, existsSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + getSessionsDir, + getProjectBaseDir, + generateConfigHash, + writeMetadata, + readMetadata, + updateMetadata, + deleteMetadata, + listMetadata, + validateAndStoreOrigin, +} from "@composio/ao-core"; + +describe("config → metadata service integration (real filesystem)", () => { + let tmpDir: string; + let configPath: string; + let repoPath: string; + + beforeAll(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "ao-config-meta-integ-")); + + repoPath = join(tmpDir, "my-repo"); + mkdirSync(repoPath, { recursive: true }); + + // Create a minimal config file + const config = { + port: 3000, + defaults: { + runtime: "tmux", + agent: "claude-code", + workspace: "worktree", + notifiers: [], + }, + projects: { + "test-project": { + name: "Test Project", + repo: "org/test-repo", + path: repoPath, + defaultBranch: "main", + sessionPrefix: "test", + }, + }, + notifiers: {}, + notificationRouting: { urgent: [], action: [], warning: [], info: [] }, + reactions: {}, + }; + + configPath = join(tmpDir, "agent-orchestrator.yaml"); + await writeFile(configPath, JSON.stringify(config, null, 2)); + }); + + afterAll(async () => { + // Clean up hash-based directories in ~/.agent-orchestrator + try { + const projectBaseDir = getProjectBaseDir(configPath, repoPath); + if (existsSync(projectBaseDir)) { + await rm(projectBaseDir, { recursive: true, force: true }); + } + } catch { + // Ignore cleanup errors + } + + if (tmpDir) { + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + }); + + it("config hash is deterministic", () => { + const hash1 = generateConfigHash(configPath); + const hash2 = generateConfigHash(configPath); + expect(hash1).toBe(hash2); + expect(hash1.length).toBeGreaterThan(0); + }); + + it("getSessionsDir returns hash-based path including project name", () => { + const sessionsDir = getSessionsDir(configPath, repoPath); + + expect(sessionsDir).toContain(".agent-orchestrator"); + expect(sessionsDir).toContain("my-repo"); + expect(sessionsDir).toContain("sessions"); + + const hash = generateConfigHash(configPath); + expect(sessionsDir).toContain(hash); + }); + + it("different repos get different session directories", () => { + const repo2Path = join(tmpDir, "other-repo"); + mkdirSync(repo2Path, { recursive: true }); + + const dir1 = getSessionsDir(configPath, repoPath); + const dir2 = getSessionsDir(configPath, repo2Path); + + expect(dir1).not.toBe(dir2); + expect(dir1).toContain("my-repo"); + expect(dir2).toContain("other-repo"); + }); + + it("full metadata lifecycle through hash-based directory", () => { + const sessionsDir = getSessionsDir(configPath, repoPath); + mkdirSync(sessionsDir, { recursive: true }); + + // 1. Write metadata + writeMetadata(sessionsDir, "lifecycle-1", { + worktree: join(tmpDir, "worktrees", "lifecycle-1"), + branch: "feat/TEST-100", + status: "spawning", + project: "test-project", + issue: "TEST-100", + createdAt: new Date().toISOString(), + }); + + // 2. Read it back + const meta = readMetadata(sessionsDir, "lifecycle-1"); + expect(meta).not.toBeNull(); + expect(meta!.branch).toBe("feat/TEST-100"); + expect(meta!.status).toBe("spawning"); + expect(meta!.project).toBe("test-project"); + expect(meta!.issue).toBe("TEST-100"); + + // 3. Update status + updateMetadata(sessionsDir, "lifecycle-1", { + status: "working", + pr: "https://github.com/org/test-repo/pull/42", + }); + + const updated = readMetadata(sessionsDir, "lifecycle-1"); + expect(updated!.status).toBe("working"); + expect(updated!.pr).toBe("https://github.com/org/test-repo/pull/42"); + expect(updated!.issue).toBe("TEST-100"); // preserved + + // 4. List sessions + writeMetadata(sessionsDir, "lifecycle-2", { + worktree: join(tmpDir, "worktrees", "lifecycle-2"), + branch: "feat/TEST-200", + status: "idle", + project: "test-project", + }); + + const ids = listMetadata(sessionsDir); + expect(ids).toContain("lifecycle-1"); + expect(ids).toContain("lifecycle-2"); + + // 5. Delete with archive + deleteMetadata(sessionsDir, "lifecycle-1", true); + expect(readMetadata(sessionsDir, "lifecycle-1")).toBeNull(); + + // Verify archive exists + const archiveDir = join(sessionsDir, "archive"); + expect(existsSync(archiveDir)).toBe(true); + const archived = readdirSync(archiveDir); + expect(archived.some((f) => f.startsWith("lifecycle-1_"))).toBe(true); + + // 6. Delete without archive + deleteMetadata(sessionsDir, "lifecycle-2", false); + expect(readMetadata(sessionsDir, "lifecycle-2")).toBeNull(); + }); + + it("origin validation stores and detects repo identity", () => { + // validateAndStoreOrigin should not throw for a valid repo path + // (even without a real git remote, it stores path-based identity) + expect(() => validateAndStoreOrigin(configPath, repoPath)).not.toThrow(); + + // Calling again with the same path should succeed (same origin) + expect(() => validateAndStoreOrigin(configPath, repoPath)).not.toThrow(); + }); + + it("multi-project isolation with shared config", () => { + const repo2Path = join(tmpDir, "project-b-repo"); + mkdirSync(repo2Path, { recursive: true }); + + const dirA = getSessionsDir(configPath, repoPath); + const dirB = getSessionsDir(configPath, repo2Path); + mkdirSync(dirA, { recursive: true }); + mkdirSync(dirB, { recursive: true }); + + // Write session to project A + writeMetadata(dirA, "projA-session-1", { + worktree: "/a/wt", + branch: "feat/A", + status: "working", + project: "project-a", + }); + + // Write session to project B + writeMetadata(dirB, "projB-session-1", { + worktree: "/b/wt", + branch: "feat/B", + status: "idle", + project: "project-b", + }); + + // Sessions are isolated + expect(listMetadata(dirA)).toContain("projA-session-1"); + expect(listMetadata(dirA)).not.toContain("projB-session-1"); + expect(listMetadata(dirB)).toContain("projB-session-1"); + expect(listMetadata(dirB)).not.toContain("projA-session-1"); + + // Cleanup + deleteMetadata(dirA, "projA-session-1", false); + deleteMetadata(dirB, "projB-session-1", false); + }); +}); diff --git a/packages/integration-tests/src/metadata-lifecycle.integration.test.ts b/packages/integration-tests/src/metadata-lifecycle.integration.test.ts new file mode 100644 index 000000000..bcf22e171 --- /dev/null +++ b/packages/integration-tests/src/metadata-lifecycle.integration.test.ts @@ -0,0 +1,336 @@ +/** + * Integration test for metadata lifecycle — real filesystem operations. + * + * Tests the full metadata CRUD cycle (write, read, update, list, delete/archive) + * and concurrent access patterns using @composio/ao-core metadata functions + * with real filesystem I/O. + */ + +import { mkdtemp, rm } from "node:fs/promises"; +import { existsSync, readdirSync, readFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + writeMetadata, + readMetadata, + readMetadataRaw, + updateMetadata, + deleteMetadata, + listMetadata, + type SessionMetadata, +} from "@composio/ao-core"; + +describe("metadata lifecycle (real filesystem)", () => { + let tmpDir: string; + + beforeAll(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "ao-meta-lifecycle-")); + }); + + afterAll(async () => { + if (tmpDir) { + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + }); + + it("write + read round-trip preserves all fields", () => { + const sessionsDir = join(tmpDir, "test-roundtrip"); + mkdirSync(sessionsDir, { recursive: true }); + + const metadata: SessionMetadata = { + worktree: "/tmp/wt/session-1", + branch: "feat/INT-100", + status: "working", + tmuxName: "abc123-session-1", + issue: "INT-100", + pr: "https://github.com/org/repo/pull/42", + summary: "Implementing feature INT-100", + project: "my-project", + createdAt: "2026-01-01T00:00:00.000Z", + dashboardPort: 4000, + }; + + writeMetadata(sessionsDir, "session-1", metadata); + const result = readMetadata(sessionsDir, "session-1"); + + expect(result).not.toBeNull(); + expect(result!.worktree).toBe(metadata.worktree); + expect(result!.branch).toBe(metadata.branch); + expect(result!.status).toBe(metadata.status); + expect(result!.tmuxName).toBe(metadata.tmuxName); + expect(result!.issue).toBe(metadata.issue); + expect(result!.pr).toBe(metadata.pr); + expect(result!.summary).toBe(metadata.summary); + expect(result!.project).toBe(metadata.project); + expect(result!.createdAt).toBe(metadata.createdAt); + expect(result!.dashboardPort).toBe(4000); + }); + + it("readMetadataRaw returns all key-value pairs", () => { + const sessionsDir = join(tmpDir, "test-raw"); + mkdirSync(sessionsDir, { recursive: true }); + + writeMetadata(sessionsDir, "session-raw", { + worktree: "/w", + branch: "main", + status: "idle", + project: "proj", + }); + + const raw = readMetadataRaw(sessionsDir, "session-raw"); + expect(raw).not.toBeNull(); + expect(raw!["worktree"]).toBe("/w"); + expect(raw!["branch"]).toBe("main"); + expect(raw!["status"]).toBe("idle"); + expect(raw!["project"]).toBe("proj"); + }); + + it("read returns null for non-existent session", () => { + const sessionsDir = join(tmpDir, "test-nonexistent"); + mkdirSync(sessionsDir, { recursive: true }); + + const result = readMetadata(sessionsDir, "no-such-session"); + expect(result).toBeNull(); + }); + + it("updateMetadata merges fields into existing file", () => { + const sessionsDir = join(tmpDir, "test-update"); + mkdirSync(sessionsDir, { recursive: true }); + + writeMetadata(sessionsDir, "session-upd", { + worktree: "/w", + branch: "feat/x", + status: "spawning", + project: "proj", + }); + + updateMetadata(sessionsDir, "session-upd", { + status: "working", + pr: "https://github.com/org/repo/pull/99", + }); + + const result = readMetadata(sessionsDir, "session-upd"); + expect(result!.status).toBe("working"); + expect(result!.pr).toBe("https://github.com/org/repo/pull/99"); + // Original fields preserved + expect(result!.worktree).toBe("/w"); + expect(result!.branch).toBe("feat/x"); + expect(result!.project).toBe("proj"); + }); + + it("updateMetadata removes keys set to empty string", () => { + const sessionsDir = join(tmpDir, "test-remove-key"); + mkdirSync(sessionsDir, { recursive: true }); + + writeMetadata(sessionsDir, "session-rmk", { + worktree: "/w", + branch: "main", + status: "working", + summary: "Remove me", + }); + + updateMetadata(sessionsDir, "session-rmk", { + summary: "", + }); + + const raw = readMetadataRaw(sessionsDir, "session-rmk"); + expect(raw!["summary"]).toBeUndefined(); + expect(raw!["worktree"]).toBe("/w"); + }); + + it("updateMetadata creates file if it does not exist", () => { + const sessionsDir = join(tmpDir, "test-update-create"); + mkdirSync(sessionsDir, { recursive: true }); + + updateMetadata(sessionsDir, "session-new", { + worktree: "/w", + branch: "main", + status: "spawning", + }); + + const result = readMetadata(sessionsDir, "session-new"); + expect(result).not.toBeNull(); + expect(result!.status).toBe("spawning"); + }); + + it("listMetadata returns session IDs, excluding archive directory", () => { + const sessionsDir = join(tmpDir, "test-list"); + mkdirSync(sessionsDir, { recursive: true }); + + writeMetadata(sessionsDir, "session-a", { worktree: "/a", branch: "a", status: "idle" }); + writeMetadata(sessionsDir, "session-b", { worktree: "/b", branch: "b", status: "working" }); + writeMetadata(sessionsDir, "session-c", { worktree: "/c", branch: "c", status: "done" }); + + const ids = listMetadata(sessionsDir); + expect(ids).toContain("session-a"); + expect(ids).toContain("session-b"); + expect(ids).toContain("session-c"); + expect(ids).not.toContain("archive"); + }); + + it("deleteMetadata with archive=true moves file to archive/", () => { + const sessionsDir = join(tmpDir, "test-archive"); + mkdirSync(sessionsDir, { recursive: true }); + + writeMetadata(sessionsDir, "session-del", { + worktree: "/w", + branch: "main", + status: "working", + }); + + expect(existsSync(join(sessionsDir, "session-del"))).toBe(true); + + deleteMetadata(sessionsDir, "session-del", true); + + // Original file removed + expect(existsSync(join(sessionsDir, "session-del"))).toBe(false); + + // Archive created + const archiveDir = join(sessionsDir, "archive"); + expect(existsSync(archiveDir)).toBe(true); + const archived = readdirSync(archiveDir); + expect(archived.length).toBe(1); + expect(archived[0]).toMatch(/^session-del_/); + + // Archive content matches original + const content = readFileSync(join(archiveDir, archived[0]), "utf-8"); + expect(content).toContain("worktree=/w"); + expect(content).toContain("branch=main"); + }); + + it("deleteMetadata with archive=false permanently removes file", () => { + const sessionsDir = join(tmpDir, "test-permanent-delete"); + mkdirSync(sessionsDir, { recursive: true }); + + writeMetadata(sessionsDir, "session-gone", { + worktree: "/w", + branch: "main", + status: "done", + }); + + deleteMetadata(sessionsDir, "session-gone", false); + + expect(existsSync(join(sessionsDir, "session-gone"))).toBe(false); + expect(existsSync(join(sessionsDir, "archive"))).toBe(false); + }); + + it("deleteMetadata is a no-op for non-existent session", () => { + const sessionsDir = join(tmpDir, "test-delete-noop"); + mkdirSync(sessionsDir, { recursive: true }); + + // Should not throw + deleteMetadata(sessionsDir, "no-such-session", true); + deleteMetadata(sessionsDir, "no-such-session", false); + }); + + it("validates session ID rejects path traversal attempts", () => { + const sessionsDir = join(tmpDir, "test-validation"); + mkdirSync(sessionsDir, { recursive: true }); + + expect(() => readMetadata(sessionsDir, "../escape")).toThrow("Invalid session ID"); + expect(() => readMetadata(sessionsDir, "foo/bar")).toThrow("Invalid session ID"); + expect(() => readMetadata(sessionsDir, "foo bar")).toThrow("Invalid session ID"); + }); + + describe("concurrent access patterns", () => { + it("concurrent writes to different sessions do not interfere", async () => { + const sessionsDir = join(tmpDir, "test-concurrent-different"); + mkdirSync(sessionsDir, { recursive: true }); + + // Write 20 sessions concurrently + const count = 20; + const promises = Array.from({ length: count }, (_, i) => + Promise.resolve().then(() => { + writeMetadata(sessionsDir, `concurrent-${i}`, { + worktree: `/w/${i}`, + branch: `branch-${i}`, + status: "working", + issue: `ISSUE-${i}`, + }); + }), + ); + + await Promise.all(promises); + + // Verify all sessions were written correctly + const ids = listMetadata(sessionsDir); + expect(ids.length).toBe(count); + + for (let i = 0; i < count; i++) { + const meta = readMetadata(sessionsDir, `concurrent-${i}`); + expect(meta).not.toBeNull(); + expect(meta!.worktree).toBe(`/w/${i}`); + expect(meta!.branch).toBe(`branch-${i}`); + expect(meta!.issue).toBe(`ISSUE-${i}`); + } + }); + + it("concurrent updates to the same session preserve last-write wins", async () => { + const sessionsDir = join(tmpDir, "test-concurrent-same"); + mkdirSync(sessionsDir, { recursive: true }); + + // Create initial session + writeMetadata(sessionsDir, "shared-session", { + worktree: "/w", + branch: "main", + status: "spawning", + }); + + // Rapidly update the same session from multiple "tasks" + const updates = Array.from({ length: 10 }, (_, i) => + Promise.resolve().then(() => { + updateMetadata(sessionsDir, "shared-session", { + status: `status-${i}`, + summary: `Update ${i}`, + }); + }), + ); + + await Promise.all(updates); + + // Verify the file is valid (not corrupted) + const result = readMetadata(sessionsDir, "shared-session"); + expect(result).not.toBeNull(); + expect(result!.worktree).toBe("/w"); + expect(result!.branch).toBe("main"); + // Status should be one of the updates (last-write-wins) + expect(result!.status).toMatch(/^status-\d$/); + expect(result!.summary).toMatch(/^Update \d$/); + }); + }); + + describe("dashboardPort serialization", () => { + it("preserves dashboardPort through write/read cycle", () => { + const sessionsDir = join(tmpDir, "test-dashboard-port"); + mkdirSync(sessionsDir, { recursive: true }); + + writeMetadata(sessionsDir, "port-session", { + worktree: "/w", + branch: "main", + status: "working", + dashboardPort: 4567, + }); + + const result = readMetadata(sessionsDir, "port-session"); + expect(result!.dashboardPort).toBe(4567); + }); + + it("omits dashboardPort when undefined", () => { + const sessionsDir = join(tmpDir, "test-no-dashboard-port"); + mkdirSync(sessionsDir, { recursive: true }); + + writeMetadata(sessionsDir, "no-port-session", { + worktree: "/w", + branch: "main", + status: "working", + }); + + const raw = readMetadataRaw(sessionsDir, "no-port-session"); + expect(raw!["dashboardPort"]).toBeUndefined(); + + const result = readMetadata(sessionsDir, "no-port-session"); + expect(result!.dashboardPort).toBeUndefined(); + }); + }); +}); diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 1fba616aa..86571c440 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -730,7 +730,6 @@ function createClaudeCodeAgent(): Agent { const hookScriptPath = join(session.workspacePath, ".claude", "metadata-updater.sh"); await setupHookInWorkspace(session.workspacePath, hookScriptPath); }, - }; } diff --git a/packages/web/package.json b/packages/web/package.json index e290378b9..a8eb02b81 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "dev": "concurrently \"npm:dev:next\" \"npm:dev:terminal\" \"npm:dev:direct-terminal\"", - "dev:next": "next dev", + "dev:next": "next dev -p ${PORT:-3000}", "dev:terminal": "tsx watch server/terminal-websocket.ts", "dev:direct-terminal": "tsx watch server/direct-terminal-ws.ts", "build": "next build", diff --git a/tests/integration/Dockerfile b/tests/integration/Dockerfile new file mode 100644 index 000000000..02a5ff149 --- /dev/null +++ b/tests/integration/Dockerfile @@ -0,0 +1,42 @@ +# Integration test environment - simulates fresh developer machine +FROM node:20-bookworm + +# Install dependencies that a typical developer would have +RUN apt-get update && apt-get install -y \ + git \ + tmux \ + curl \ + jq \ + && rm -rf /var/lib/apt/lists/* + +# Install pnpm globally (typically installed by developer) +RUN npm install -g pnpm + +# Install GitHub CLI (common for developers working with GitHub) +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ + && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ + && apt-get update \ + && apt-get install gh -y \ + && rm -rf /var/lib/apt/lists/* + +# Set up working directory +WORKDIR /workspace + +# Copy the entire repo (simulates git clone) +COPY . /workspace/agent-orchestrator + +# Create a test user (simulates developer's home directory) +RUN useradd -m -s /bin/bash testuser + +# Configure npm to use user-local prefix (no sudo needed for npm link) +RUN mkdir -p /home/testuser/.npm-global && \ + chown -R testuser:testuser /home/testuser/.npm-global +USER testuser +RUN npm config set prefix '/home/testuser/.npm-global' +ENV PATH="/home/testuser/.npm-global/bin:$PATH" + +WORKDIR /home/testuser + +# Entry point will be the test script +CMD ["/bin/bash"] diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml new file mode 100644 index 000000000..bc12cb2db --- /dev/null +++ b/tests/integration/docker-compose.yml @@ -0,0 +1,24 @@ +version: '3.8' + +services: + onboarding-test: + build: + context: ../.. + dockerfile: tests/integration/Dockerfile + container_name: ao-onboarding-test + command: /workspace/agent-orchestrator/tests/integration/onboarding-test.sh + environment: + - NODE_ENV=test + - CI=true + # Mount the repo for live development (optional) + volumes: + - ../..:/workspace/agent-orchestrator + # Expose ports for debugging (optional) + # Use 9000/9001/9003 on host to avoid conflicts with orchestrator + # Container still uses 9000/3001/3003 internally + ports: + - "9000:9000" # Dashboard (host:container) + - "9001:3001" # Terminal WebSocket (host:container) + - "9003:3003" # DirectTerminal WebSocket (host:container) + # Increase shared memory for browser-based tests (future) + shm_size: 2gb diff --git a/tests/integration/onboarding-test.sh b/tests/integration/onboarding-test.sh new file mode 100755 index 000000000..bb44359a7 --- /dev/null +++ b/tests/integration/onboarding-test.sh @@ -0,0 +1,207 @@ +#!/bin/bash +# Integration test: Fresh developer onboarding experience +# Measures time and verifies each step of the onboarding flow + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Timing utilities +start_time=$(date +%s) +step_start=0 + +start_step() { + echo -e "\n${BLUE}▶ $1${NC}" + step_start=$(date +%s) +} + +end_step() { + local step_end=$(date +%s) + local duration=$((step_end - step_start)) + echo -e "${GREEN}✓ $1 (${duration}s)${NC}" +} + +fail_step() { + echo -e "${RED}✗ $1${NC}" + exit 1 +} + +# Test starts here +echo -e "${BLUE}╔════════════════════════════════════════════════════════╗${NC}" +echo -e "${BLUE}║ Agent Orchestrator - Onboarding Integration Test ║${NC}" +echo -e "${BLUE}╔════════════════════════════════════════════════════════╗${NC}" +echo "" + +# Step 1: Simulate git clone (already done by Docker COPY, but we cd into it) +start_step "Step 1: Navigate to repository" +cd /workspace/agent-orchestrator || fail_step "Repository not found" +end_step "Step 1: Repository accessible" + +# Step 2: Run setup script +start_step "Step 2: Running ./scripts/setup.sh" +if ! ./scripts/setup.sh; then + fail_step "Step 2: Setup script failed" +fi +end_step "Step 2: Setup completed" + +# Step 3: Verify ao command is available +start_step "Step 3: Verify ao command" +if ! command -v ao &> /dev/null; then + fail_step "Step 3: ao command not found (npm link failed?)" +fi +ao --version || fail_step "Step 3: ao --version failed" +end_step "Step 3: ao command available" + +# Step 4: Create minimal test config +start_step "Step 4: Create test configuration" +mkdir -p /tmp/ao-test-project +cd /tmp/ao-test-project +git init +git config user.email "test@example.com" +git config user.name "Test User" + +cat > agent-orchestrator.yaml << 'EOF' +dataDir: /tmp/ao-test-data +worktreeDir: /tmp/ao-test-worktrees +port: 9000 + +projects: + test-project: + repo: test/repo + path: /tmp/ao-test-project + defaultBranch: main +EOF + +end_step "Step 4: Configuration created" + +# Step 5: Verify config is valid +start_step "Step 5: Validate configuration" +# ao init would fail if run again, so we just verify the file is readable +if [ ! -f agent-orchestrator.yaml ]; then + fail_step "Step 5: Config file not found" +fi +end_step "Step 5: Configuration validated" + +# Step 6: Start orchestrator (in background) +start_step "Step 6: Start orchestrator" +# Start in background and capture PID +ao start --no-orchestrator & # Only start dashboard, not the orchestrator session +DASHBOARD_PID=$! + +# Wait for dashboard to be ready (max 30 seconds) +echo " Waiting for dashboard to start..." +for i in {1..30}; do + if curl -s http://localhost:9000 > /dev/null 2>&1; then + break + fi + if ! kill -0 $DASHBOARD_PID 2>/dev/null; then + fail_step "Step 6: Dashboard process died" + fi + sleep 1 +done + +if ! curl -s http://localhost:9000 > /dev/null 2>&1; then + fail_step "Step 6: Dashboard not responding after 30s" +fi + +end_step "Step 6: Dashboard started successfully" + +# Step 7: Verify dashboard endpoints +start_step "Step 7: Verify dashboard API" + +# Test /api/sessions endpoint +if ! curl -sf http://localhost:9000/api/sessions > /dev/null; then + fail_step "Step 7: /api/sessions endpoint failed" +fi + +# Test SSE events endpoint (just verify it responds, don't wait for events) +if ! timeout 2 curl -sf http://localhost:9000/api/events > /dev/null 2>&1; then + # SSE might timeout, that's ok - we just want to verify it exists + : +fi + +end_step "Step 7: Dashboard API responding" + +# Step 8: Verify WebSocket terminal servers +start_step "Step 8: Verify WebSocket servers" + +# Check if direct terminal WebSocket server is running (required for terminal feature) +echo " Checking WebSocket server on port 3003..." +max_retries=10 +for i in $(seq 1 $max_retries); do + if curl -sf http://localhost:3003/health > /dev/null 2>&1; then + echo " ✓ WebSocket server responding" + break + fi + if [ $i -eq $max_retries ]; then + fail_step "Step 8: WebSocket terminal server not responding (bug: ao start didn't launch all services)" + fi + sleep 1 +done + +end_step "Step 8: WebSocket servers verified" + +# Step 9: Verify orchestrator terminal page (end-to-end test) +start_step "Step 9: Verify orchestrator terminal feature" + +# Create orchestrator session first (so we have something to test) +echo " Creating test orchestrator session..." +tmux new-session -d -s test-project-orchestrator || true + +# Write minimal metadata +mkdir -p /tmp/ao-test-data +cat > /tmp/ao-test-data/test-project-orchestrator << 'EOF' +worktree=/tmp/ao-test-project +branch=main +status=working +project=test-project +EOF + +# Test that the session detail page loads (where terminal would be) +if ! curl -sf http://localhost:9000/sessions/test-project-orchestrator > /dev/null; then + fail_step "Step 9: Orchestrator session page failed to load" +fi + +# Cleanup test session +tmux kill-session -t test-project-orchestrator 2>/dev/null || true + +end_step "Step 9: Orchestrator terminal page accessible" + +# Step 10: Cleanup +start_step "Step 10: Cleanup" +kill $DASHBOARD_PID 2>/dev/null || true +# Wait for process to exit +sleep 2 +# Force kill if still running +kill -9 $DASHBOARD_PID 2>/dev/null || true + +# Kill any remaining Node processes (dashboard, websocket servers) +pkill -f "node.*next.*dev" || true +pkill -f "tsx.*terminal" || true + +end_step "Step 10: Cleanup completed" + +# Calculate total time +end_time=$(date +%s) +total_duration=$((end_time - start_time)) + +# Summary +echo "" +echo -e "${GREEN}╔════════════════════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ 🎉 All Tests Passed! ║${NC}" +echo -e "${GREEN}╔════════════════════════════════════════════════════════╗${NC}" +echo "" +echo -e "${BLUE}Total onboarding time: ${total_duration}s${NC}" +echo "" + +# Export metrics for CI +if [ -n "$GITHUB_ACTIONS" ]; then + echo "onboarding_time_seconds=$total_duration" >> "$GITHUB_OUTPUT" +fi + +exit 0 diff --git a/tests/integration/run-test.sh b/tests/integration/run-test.sh new file mode 100755 index 000000000..2e1703f68 --- /dev/null +++ b/tests/integration/run-test.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Quick runner for integration tests + +set -e + +cd "$(dirname "$0")" + +echo "🚀 Running Agent Orchestrator onboarding integration test..." +echo "" + +# Build and run +docker-compose up --build --abort-on-container-exit --exit-code-from onboarding-test + +# Capture exit code +EXIT_CODE=$? + +# Cleanup +echo "" +echo "🧹 Cleaning up..." +docker-compose down -v + +if [ $EXIT_CODE -eq 0 ]; then + echo "" + echo "✅ Test passed!" + exit 0 +else + echo "" + echo "❌ Test failed (exit code: $EXIT_CODE)" + echo "" + echo "To debug:" + echo " docker-compose run --rm onboarding-test /bin/bash" + exit $EXIT_CODE +fi