diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7a6658e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,58 @@ +# CLAUDE.md — Symphony Project Conventions + +## Package Structure + +``` +internal/ + domain/ # Shared types (WorkItem, FSM) — zero external deps + engine/ # Central event loop, handlers, state — the orchestrator core + tracker/KIND/ # Tracker adapters (github/, linear/, mock/) + codehost/KIND/ # Code host adapters (github/) + agent/KIND/ # Agent adapters (claude/, mock/) + config/ # symphony.yaml parser + validator + prompt/ # Template rendering + field-based routing + workspace/ # Git clone/worktree management + state/ # bbolt persistent store + logging/ # JSONL file logger + server/ # HTTP API (healthz, metrics, control endpoints) + tui/views/ # Bubble Tea TUI with 3 view modes +``` + +## Key Conventions + +- **Interfaces in root packages, implementations in sub-packages**: `tracker/tracker.go` defines the interface, `tracker/github/source.go` implements it. +- **`domain.WorkItem` is canonical**: All packages import `domain`, never define their own work item type. +- **No mutexes in engine**: The event loop goroutine owns all mutable state. External callers use `Emit()` to send events. +- **FSM enforcement**: All state changes go through `domain.Transition()`. Invalid transitions are logged errors. +- **Event log is append-only**: `events.jsonl` records every FSM transition for debugging and replay. + +## Testing Patterns + +- **Property tests** (`test/property/`): Random event sequences verify FSM invariants (55k sequences). +- **Scenario tests** (`test/scenario/`): Named end-to-end paths through the engine with mock adapters. +- **Contract tests** (`internal/tracker/contract_test.go`): Shared behavioral tests for all tracker implementations. +- **Unit tests**: Colocated `*_test.go` files for eligibility, handoff, reconcile, retry, config, prompt routing. +- **Mock adapters**: `agent/mock/`, `tracker/mock/` — configurable behavior for testing. + +## Build & Test + +```bash +go build ./... # Build everything +go test ./... -count=1 # Run all tests +go test ./test/property/ -v # FSM property tests +go test ./test/scenario/ -v # Scenario tests +``` + +## Error Handling + +- Wrap errors with `fmt.Errorf("context: %w", err)` for stack traces. +- GitHub API errors use typed error types in `internal/github/errors.go`. +- Agent failures trigger FSM transitions (`error` event), not panics. +- Budget/stall violations transition to `needs_human`, not retry. + +## Config + +- Config lives in `.symphony/symphony.yaml` (per-repo). +- Environment variables resolved via `$VAR` syntax at load time. +- Required fields validated by `ValidateSymphonyConfig()`. +- `symphony doctor` checks config, credentials, and binary availability. diff --git a/Makefile b/Makefile index 5f00649..941454d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test test-integration lint sidecar docker-build docker-up doctor clean +.PHONY: build test test-property test-scenario test-contract lint local-lint docker-build docker-up docker-down doctor clean build: go build -o bin/symphony ./cmd/symphony @@ -6,14 +6,20 @@ build: test: go test ./... -count=1 -test-integration: - go test -tags=integration ./... -count=1 +test-property: + go test ./test/property/ -v + +test-scenario: + go test ./test/scenario/ -v + +test-contract: + go test ./internal/tracker/ -v lint: golangci-lint run -sidecar: - cd sidecar/claude && npm install +local-lint: + docker run --rm -v $(CURDIR):/app -w /app golangci/golangci-lint:v2.11.4 golangci-lint run docker-build: docker build -t symphony . @@ -28,4 +34,4 @@ doctor: go build -o bin/symphony ./cmd/symphony && bin/symphony --doctor clean: - rm -rf bin/ sidecar/claude/node_modules + rm -rf bin/ diff --git a/README.md b/README.md index 18f74ef..b8cf7bf 100644 --- a/README.md +++ b/README.md @@ -1,461 +1,108 @@ # Symphony -Symphony is a long-running orchestration service that continuously reads work from a GitHub Project, dispatches coding agents to work on issues in isolated repository workspaces, and manages the full lifecycle from dispatch through PR creation and handoff. +Symphony is an orchestration service that polls GitHub Project V2 boards (or Linear), dispatches AI coding agents (Claude Code, OpenCode, Codex) to work on issues in isolated git workspaces, and manages the full lifecycle from dispatch through PR creation and handoff. -## How It Works +## Architecture ``` -GitHub Project (Todo/In Progress) - │ - ▼ - ┌─────────┐ poll every N seconds - │Symphony │◄──────────────────────────── - │Orchestr.│ │ - └────┬────┘ │ - │ dispatch eligible items │ - ▼ │ - ┌─────────┐ │ - │ Worker │ clone repo, create branch │ - │ │ run agent in workspace │ - └────┬────┘ │ - │ │ - ▼ │ - ┌─────────┐ │ - │ claude │ read files, edit code, │ - │ -p │ run tests, commit changes │ - └────┬────┘ │ - │ │ - ▼ │ - ┌─────────┐ │ - │Write- │ push branch, create PR, │ - │back │ update project status │ - └────┬────┘ │ - │ │ - ▼ │ - GitHub PR ──── Human Review ────────────┘ +Tracker (GitHub/Linear) Agent (Claude/OpenCode/Codex) CodeHost (GitHub) + │ │ │ + ▼ ▼ ▼ + ┌─────────────────────────────────────────────────────────────────────┐ + │ Engine (Event Loop) │ + │ FSM: open → queued → preparing → running → completed → handed_off│ + │ Handlers: dispatch, retry, stall, budget, reconcile, handoff │ + │ Event Log: .symphony/state/events.jsonl (append-only audit trail)│ + └─────────────────────────────────────────────────────────────────────┘ + │ │ │ │ + Workspace Prompt Router State Store TUI Dashboard + (git clone/ (.symphony/ (bbolt, (Bubble Tea, + worktree) prompts/) crash-safe) 3 views) ``` -## Prerequisites - -- **Go 1.26+** — for building Symphony -- **git** — for workspace operations -- **Claude CLI** — authenticated locally via `claude login` -- **GitHub PAT** — fine-grained token with repo, project, and issues permissions -- **gh CLI** — for the agent to post workpad comments on issues - ## Quick Start ```bash -# 1. Clone and build -git clone https://github.com/shivamstaq/github-symphony.git -cd github-symphony -go build -o symphony ./cmd/symphony - -# 2. Create .env in the directory where you'll run Symphony -echo "GITHUB_TOKEN=ghp_your_token_here" > .env - -# 3. Authenticate Claude CLI (one-time) -claude login - -# 4. Copy and edit the example workflow -cp WORKFLOW.md.example WORKFLOW.md -# Edit WORKFLOW.md: set your tracker.owner, tracker.project_number, tracker.project_scope - -# 5. Validate your setup -./symphony --doctor WORKFLOW.md - -# 6. Run Symphony -./symphony --port 9097 WORKFLOW.md -``` - -> **Important:** Symphony loads `.env` from the **current working directory**. Always `cd` into the directory containing your `.env` before running the binary. Flags must come **before** the positional `WORKFLOW_PATH` argument. - -## Setting Up a New Repository - -### 1. Create a GitHub Project V2 - -Go to your GitHub profile → Projects → New project, or use `gh`: - -```bash -gh project create --owner YOUR_USER --title "My Project" --format json -``` - -Note the `number` from the output (e.g., `"number": 6`). - -### 2. Configure the Status Field - -Your project needs a **Status** single-select field with at least these values: - -| Status | Purpose | -|--------|---------| -| **Todo** | Items ready for agent execution | -| **In Progress** | Items currently being worked on | -| **Human Review** | Items waiting for human review (handoff state) | -| **Done** | Completed items | - -Symphony dispatches items in `active_values` (default: Todo, Ready, In Progress) and ignores items in `terminal_values` (default: Done, Closed, Cancelled). After an agent creates a PR, Symphony moves the item to the `handoff_project_status` value (e.g., Human Review). - -### 3. Create Issues and Add to Project - -```bash -# Create issues in your repository -gh issue create --repo YOUR_USER/YOUR_REPO \ - --title "Fix the flaky test" \ - --body "The auth module test fails intermittently." - -gh issue create --repo YOUR_USER/YOUR_REPO \ - --title "Add input validation" \ - --body "Config parser accepts invalid values silently." - -# Add them to your project -gh project item-add PROJECT_NUMBER --owner YOUR_USER \ - --url https://github.com/YOUR_USER/YOUR_REPO/issues/1 - -gh project item-add PROJECT_NUMBER --owner YOUR_USER \ - --url https://github.com/YOUR_USER/YOUR_REPO/issues/2 -``` +# Build +go build -o symphony ./cmd/symphony/ -### 4. Set Up Dependencies (Optional) +# Initialize in your repo +cd /path/to/your-repo +./symphony init -Use GitHub's native issue dependencies to control execution order. If issue #2 depends on issue #1: +# Set your GitHub token +export GITHUB_TOKEN=ghp_... -1. Open issue #2 on GitHub -2. In the sidebar, click "Add blocked by" and select issue #1 +# Validate +./symphony doctor -Symphony will only dispatch issue #2 after issue #1 is closed. Sub-issues are also respected — a parent issue with open sub-issues won't be dispatched until all children are closed. - -### 5. Create WORKFLOW.md - -Copy the example and customize: - -```bash -cp WORKFLOW.md.example WORKFLOW.md -``` - -Edit the YAML front matter: - -```yaml ---- -tracker: - kind: github - owner: YOUR_USER # GitHub username or org - project_number: 6 # from step 1 - project_scope: user # "user" or "organization" - active_values: - - Todo - - In Progress - terminal_values: - - Done - - Closed - - Cancelled -github: - token: $GITHUB_TOKEN -agent: - kind: claude_code - max_concurrent_agents: 3 # parallel agents - max_turns: 5 # re-invocations per issue - stall_timeout_ms: 600000 # kill stalled agents after 10 min -claude: - model: sonnet # or opus, haiku - permission_profile: bypassPermissions -git: - branch_prefix: symphony/ -polling: - interval_ms: 30000 # poll every 30 seconds -pull_request: - open_pr_on_success: true - draft_by_default: true - handoff_project_status: Human Review - comment_on_issue_with_pr: true ---- +# Run +./symphony run ``` -The Markdown body below the front matter is the **prompt template** sent to the agent. It uses Go template syntax (`{{.work_item.title}}`, etc.) and is rendered per issue with full context. See `WORKFLOW.md.example` for a complete playbook including workpad comments and retry handling. +## CLI Commands -### 6. Create .env - -```bash -echo "GITHUB_TOKEN=ghp_your_fine_grained_pat" > .env ``` - -Required PAT permissions: -- **Repository**: Read and Write (for cloning, pushing branches, creating PRs) -- **Projects**: Read and Write (for fetching items, updating status fields) -- **Issues**: Read and Write (for posting comments, reading state) - -### 7. Validate and Run - -```bash -# Validate everything is configured correctly -./symphony --doctor WORKFLOW.md - -# Run Symphony (TUI dashboard appears automatically in terminal) -./symphony --port 9097 WORKFLOW.md +symphony init # Interactive setup wizard +symphony run [--mock] # Start orchestrator with TUI +symphony doctor # Validate config + environment +symphony status # One-shot JSON state dump +symphony attach # Connect to agent PTY +symphony logs [--follow] [--agent X] # Tail structured logs +symphony pause # Pause agent between turns +symphony resume # Resume paused agent +symphony kill # Force-stop agent +symphony events [--item X] # Query FSM event log +symphony config validate # Check symphony.yaml +symphony config show # Show resolved config ``` -The TUI shows running agents, retry queue, recent events, and summary stats. Press `q` to quit gracefully. +## Configuration -## What Happens When You Run Symphony - -1. **Poll**: Fetches all items from your GitHub Project in active status values -2. **Filter**: Checks eligibility — blocked items, terminal items, already-running items are skipped -3. **Dispatch**: Sends eligible items to worker goroutines (up to `max_concurrent_agents`) -4. **Workspace**: Clones the repository, creates a git worktree with a deterministic branch (`symphony/__`) -5. **CLAUDE.md**: Generates a context file in the workspace with issue details for the agent -6. **Agent**: Invokes `claude -p --output-format json` with the rendered prompt. Claude reads files, edits code, runs tests, and commits changes. -7. **Session**: On continuation turns, uses `--resume ` so Claude has memory of prior work -8. **Detect**: Checks if the agent created any git commits -9. **Write-back**: If commits exist — pushes the branch, creates/updates a draft PR, comments on the issue, moves the project status to "Human Review" -10. **Handoff**: Marks the item as handed off. Symphony stops dispatching it. -11. **Repeat**: Polls again, picks up newly eligible items - -## Configuration Reference - -All configuration lives in `WORKFLOW.md` YAML front matter: - -| Key | Default | Description | -|-----|---------|-------------| -| `tracker.kind` | required | `github` | -| `tracker.owner` | required | GitHub user or org | -| `tracker.project_number` | required | Project V2 number | -| `tracker.project_scope` | `organization` | `user` or `organization` | -| `tracker.active_values` | `[Todo, Ready, In Progress]` | Project status values eligible for dispatch | -| `tracker.terminal_values` | `[Done, Closed, Cancelled]` | Terminal status values (stop execution) | -| `github.token` | `$GITHUB_TOKEN` | PAT or `$VAR` env reference | -| `agent.kind` | required | `claude_code`, `opencode`, or `codex` | -| `agent.max_concurrent_agents` | `10` | Maximum parallel workers | -| `agent.max_turns` | `20` | Re-invocations per work item before giving up | -| `agent.stall_timeout_ms` | `300000` | Kill stalled workers after this duration | -| `claude.model` | — | Model override (`sonnet`, `opus`, `haiku`) | -| `claude.permission_profile` | `bypassPermissions` | Claude CLI permission mode | -| `claude.allowed_tools` | all | Restrict agent tools (e.g., `[Read, Edit, Bash]`) | -| `git.branch_prefix` | `symphony/` | Branch name prefix | -| `git.use_worktrees` | `true` | Use git worktrees (recommended) | -| `polling.interval_ms` | `30000` | Poll interval in milliseconds | -| `pull_request.open_pr_on_success` | `true` | Create PR after agent commits | -| `pull_request.draft_by_default` | `true` | Create draft PRs | -| `pull_request.handoff_project_status` | — | Status value for handoff (e.g., `Human Review`) | -| `pull_request.comment_on_issue_with_pr` | `true` | Post PR link as issue comment | -| `server.port` | — | HTTP server port (disabled if unset) | - -## CLI Reference +Symphony uses a `.symphony/` directory per repository: ``` -symphony [flags] [WORKFLOW_PATH] - -Arguments: - WORKFLOW_PATH Path to WORKFLOW.md (default: ./WORKFLOW.md) - -Flags: - --port PORT Start HTTP server on PORT - --log-level LVL Log level: debug, info, warn, error (default: info) - --log-format FMT Log format: text, json (default: text) - --state-dir PATH Persistent state directory - --doctor Validate config and environment, then exit - --no-tui Disable TUI dashboard, use plain log output +.symphony/ +├── symphony.yaml # Configuration +├── prompts/ # Prompt templates (routed by project field) +│ └── default.md +├── state/ +│ ├── symphony.db # Persistent state (bbolt) +│ └── events.jsonl # FSM audit trail +├── logs/ +│ ├── orchestrator.jsonl # Structured orchestrator logs +│ └── agents/ # Per-agent session logs + PTY capture +└── sockets/ # Unix sockets for `symphony attach` ``` -**Flags must come before the positional WORKFLOW_PATH argument** (Go `flag` package limitation). - -### Examples - -```bash -# Validate setup (checks config, GitHub connectivity, claude binary) -./symphony --doctor WORKFLOW.md - -# Run with TUI dashboard + HTTP API -./symphony --port 9097 WORKFLOW.md - -# Run without TUI (for CI/Docker/piped output) -./symphony --no-tui --log-format json --port 9097 WORKFLOW.md - -# Debug mode (verbose logging) -./symphony --port 9097 --log-level debug WORKFLOW.md -``` +See [docs/configuration.md](docs/configuration.md) for the full schema reference. -## TUI Dashboard - -When running in a terminal, Symphony displays a live Bubble Tea dashboard: - -``` -🎵 Symphony Uptime: 00:14:32 -Agents: 2/5 running │ Dispatched: 7 │ Handed Off: 3 -────────────────────────────────────────────────────────── -RUNNING AGENTS - Issue Phase Time Tokens - ────────────────────────────────────────────────────── - org/repo#4 streaming_turn 3m12s 12.4k - org/repo#7 launching 0m48s 3.1k - -RETRY QUEUE - org/repo#1 → due in 8s (attempt 2) - -RECENT EVENTS - 09:14:32 org/repo#4 PR created → pull/12 - 09:14:01 org/repo#7 Workspace created (worktree) - 09:13:12 org/repo#1 Blocked by #4 (state: open) -────────────────────────────────────────────────────────── -[q] Quit [r] Refresh -``` - -Disable with `--no-tui` for plain log output. - -## HTTP API - -When `--port` is set, Symphony exposes: - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/healthz` | GET | Health check with uptime, auth mode, running count, last poll time | -| `/metrics` | GET | Prometheus-format metrics (12 `symphony_*` metrics) | -| `/api/v1/state` | GET | Full orchestrator runtime snapshot (JSON) | -| `/api/v1/work-items/{id}` | GET | Single work item details | -| `/api/v1/refresh` | POST | Trigger immediate reconciliation | -| `/api/v1/webhooks/github` | POST | GitHub webhook ingress (requires `github.webhook_secret`) | - -## Authentication - -### GitHub (Symphony itself) - -Symphony uses a **fine-grained Personal Access Token** for all GitHub API operations. Set `GITHUB_TOKEN` in your `.env` file. No GitHub App registration required. - -### Agent Adapters - -Symphony does **not** manage agent API keys. Each agent subprocess inherits the full parent environment and uses its own local credentials: - -| Adapter | Auth Method | Setup | -|---------|-------------|-------| -| **Claude Code** | Local OAuth via `~/.claude` | Run `claude login` once | -| **OpenCode** | Local config | Configure `opencode` | -| **Codex** | Local config | Configure `codex` | - -No `ANTHROPIC_API_KEY` is required — the `claude` CLI handles auth via its own OAuth flow. - -## Session Preservation - -Symphony preserves Claude session IDs across continuation turns. When an issue is re-invoked: - -1. The previous `session_id` is loaded from the persistent store (bbolt) -2. Claude is invoked with `--resume ` -3. Claude has full memory of prior conversation, tool results, and file changes - -Additionally, a `CLAUDE.md` file is generated in each workspace with issue context. Claude reads this automatically on every invocation, providing persistent instructions even without session resumption. - -## Dependency Handling - -Symphony respects two types of GitHub issue relationships: - -### Blocking Dependencies -If issue A is blocked by issue B (via GitHub's "Blocked by" feature), Symphony will **not dispatch A** until B is closed. All blockers must be closed for an item to be eligible. - -### Parent/Sub-Issues -If a parent issue has open sub-issues, Symphony skips the parent and dispatches eligible sub-issues instead. When all sub-issues are closed, the parent becomes eligible with enriched context about the completed child work. - -## Observability - -### Prometheus Metrics (`/metrics`) - -12 metrics exposed in Prometheus text format: - -| Metric | Type | Description | -|--------|------|-------------| -| `symphony_active_runs` | gauge | Current running workers | -| `symphony_max_concurrent_agents` | gauge | Configured concurrency limit | -| `symphony_retry_queue_depth` | gauge | Pending retries | -| `symphony_tokens_total{direction}` | counter | Token usage (input/output/total) | -| `symphony_sessions_started_total` | counter | Total agent sessions | -| `symphony_github_writebacks_total` | counter | PR/comment operations | -| `symphony_dispatches_total` | counter | Total dispatches | -| `symphony_work_item_state{state}` | gauge | Items by state | -| `symphony_errors_total` | counter | Error count | -| `symphony_pr_handoffs_total` | counter | Successful handoffs | - -### Docker Observability Stack - -```bash -docker compose up -d -``` - -Starts 5 services: -- **Symphony** (port 9097) — orchestrator with HTTP API -- **VictoriaMetrics** (port 8428) — metrics storage (Prometheus-compatible) -- **VictoriaLogs** (port 9428) — searchable log storage -- **Vector** — log collector (reads Symphony JSON logs, pushes to VictoriaLogs) -- **Grafana** (port 3097, admin/admin) — dashboards for metrics + logs - -Query logs in Grafana using LogsQL: -``` -work_item_id:"github:PVTI_xxx" # All logs for a work item -log.level:error # All errors -msg:"claude CLI" # Agent activity -``` - -## Architecture - -``` -cmd/symphony/main.go CLI entrypoint, wiring, signal handling, TUI launch -internal/ - config/ WORKFLOW.md loader, typed config, validation, file watcher - orchestrator/ Poll loop, dispatch, eligibility, retry, reconciliation - worker.go Multi-turn agent execution loop + session preservation - events.go Event bus for TUI and logging - source_bridge.go GitHub → orchestrator type bridge - github/ GraphQL queries, PR/comment write-back, auth, tools - adapter/ Claude CLI adapter (exec + JSON parse) - workspace/ Git clone/worktree, branches, hooks, push - prompt/ Go template rendering with missingkey=error - state/ bbolt persistent state (retries, sessions, totals) - server/ HTTP API, Prometheus metrics, health check - webhook/ GitHub webhook signature verification - tui/ Bubble Tea terminal dashboard - tracker/ Abstract interfaces for multi-backend support - logging/ Structured log setup - ssh/ SSH worker extension (stub) -``` - -## Testing - -```bash -# Unit tests (no external dependencies) -go test ./... -count=1 - -# Integration tests (requires GITHUB_TOKEN — loads from .env two levels up) -cd test/integration && go test -tags=integration -v -count=1 - -# Lint -golangci-lint run -``` - -## Docker - -```bash -# Build image (Go binary + git, no Node.js needed) -docker build -t symphony . - -# Run full stack with observability -docker compose up -d - -# View logs -docker compose logs -f symphony - -# Stop -docker compose down -``` +## FSM States -## Guardrails +| State | Description | +|-------|-------------| +| `open` | In tracker, not yet picked up | +| `queued` | Claimed, awaiting dispatch slot | +| `preparing` | Workspace being created | +| `running` | Agent actively working | +| `paused` | Between-turn pause | +| `completed` | Agent finished with commits | +| `handed_off` | PR created, status updated | +| `needs_human` | No progress / stall / budget exceeded | +| `failed` | Unrecoverable (max retries exhausted) | -Symphony includes multiple safety mechanisms: +## Supported Integrations -- **Max continuation retries** (default 10): Prevents infinite re-dispatch loops -- **Continuation backoff** (5s → 10s → 20s → 30s): Avoids rapid-fire re-invocations -- **Stall detection**: Kills workers silent for longer than `stall_timeout_ms` -- **Incomplete data rejection**: Items with failed dependency fetches are skipped -- **Token sanitization**: Git auth tokens are masked in all log output -- **Clone mutex**: Prevents concurrent bare repo clones to the same cache path -- **Context cancellation**: Workers stop promptly on SIGTERM; Claude processes are killed -- **Handoff on PR creation**: PR creation unconditionally triggers handoff (prevents re-dispatch loops) -- **Eligibility checks**: 10+ rules checked before dispatch (active status, open state, not blocked, not claimed, slots available, per-repo limits, per-status limits, sub-issue check, Pass2 data completeness) +| Layer | Supported | Planned | +|-------|-----------|---------| +| **Tracker** | GitHub Projects V2, Linear | Jira, GitLab | +| **Agent** | Claude Code | OpenCode, Codex | +| **Code Host** | GitHub | GitLab | -## License +## Documentation -See [LICENSE](LICENSE). +- [Getting Started](docs/getting-started.md) +- [Configuration Reference](docs/configuration.md) +- [Architecture](docs/architecture.md) +- [Testing](docs/testing.md) +- [End-to-End Testing Guide](docs/testing-e2e.md) diff --git a/WORKFLOW.md.example b/WORKFLOW.md.example deleted file mode 100644 index 088ac45..0000000 --- a/WORKFLOW.md.example +++ /dev/null @@ -1,147 +0,0 @@ ---- -tracker: - kind: github - owner: YOUR_ORG_OR_USER - project_number: YOUR_PROJECT_NUMBER - project_scope: user - active_values: - - Todo - - In Progress - terminal_values: - - Done - - Closed - - Cancelled -github: - token: $GITHUB_TOKEN -agent: - kind: claude_code - max_concurrent_agents: 3 - max_turns: 5 - stall_timeout_ms: 600000 - max_retry_backoff_ms: 300000 -claude: - model: sonnet - permission_profile: bypassPermissions - allowed_tools: - - Read - - Edit - - Write - - Bash - - Glob - - Grep -git: - branch_prefix: symphony/ - use_worktrees: true -polling: - interval_ms: 30000 -pull_request: - open_pr_on_success: true - draft_by_default: true - handoff_project_status: Human Review - comment_on_issue_with_pr: true ---- - -# Symphony Agent — Issue Execution Playbook - -You are an autonomous coding agent working on a GitHub issue dispatched by Symphony. - -## Context - -- **Issue**: {{.work_item.issue_identifier}} — {{.work_item.title}} -- **Repository**: {{.repository.full_name}} -- **Branch**: `{{.branch_name}}` (based on `{{.base_branch}}`) -- **Project Status**: {{.work_item.project_status}} -{{if .attempt}}- **Attempt**: {{.attempt}} (this is a retry — check the workpad for prior progress){{end}} - -{{if .work_item.sub_issues}} -## Prior Sub-Issue Work - -This issue previously had sub-issues. Review what was completed: -{{range .work_item.sub_issues}}- {{.identifier}} ({{.state}}) -{{end}} -Continue with any remaining work, taking the completed sub-issue results into account. -{{end}} - -{{if .work_item.description}} -## Issue Description - -{{.work_item.description}} -{{end}} - -## Execution Protocol - -Follow these steps in order. Do not skip steps. - -### Step 0: Understand the Issue - -1. Read the full issue description above carefully. -2. If the issue references specific files, read them first. -3. Identify what needs to change and what the acceptance criteria are. - -### Step 1: Check for Existing Workpad - -**ALWAYS check first** whether a workpad comment already exists on this issue before creating one: - -```bash -gh issue view {{.work_item.issue_number}} --repo {{.repository.full_name}} --comments 2>/dev/null | grep -q "Symphony Workpad" -``` - -- If a workpad already exists: **read it** and continue from where you left off. Do NOT create a duplicate. -- If no workpad exists: create one using the command below. - -{{if .attempt}}**This is continuation attempt {{.attempt}}.** A workpad likely already exists — find it and update it.{{end}} - -Only if no workpad exists, create one: - -```bash -gh issue comment {{.work_item.issue_number}} --repo {{.repository.full_name}} --body "## Symphony Workpad - -**Branch**: \`{{.branch_name}}\` -**Status**: In Progress - -### Plan -- [ ] (fill in your implementation plan) - -### Acceptance Criteria -- [ ] (derived from the issue) - -### Progress -- Started at $(date -u +%Y-%m-%dT%H:%M:%SZ) - ---- -🤖 *Automated by [Symphony](https://github.com/shivamstaq/github-symphony)* -" -``` - -### Step 2: Implement - -1. Make the necessary code changes to resolve the issue. -2. Write clean, minimal changes — only modify what the issue requires. -3. Commit your changes incrementally with descriptive messages: - ```bash - git add -A && git commit -m "description of change" - ``` -4. If the problem is too large, break it into sub-tasks in your workpad and work through them one at a time. - -### Step 3: Validate - -1. If the repository has tests, run them: - ```bash - # Look for test commands in package.json, Makefile, or standard locations - ``` -2. If tests fail, fix the failures before proceeding. -3. Update your workpad comment with validation results. - -### Step 4: Complete - -1. Ensure all changes are committed. -2. Update the workpad comment status to "Complete". -3. Your work is done — Symphony will handle pushing the branch and creating the PR. - -## Guardrails - -- **Stay in scope**: Only fix what the issue asks for. If you discover related problems, note them in the workpad but don't fix them. -- **Don't push directly**: Symphony handles `git push` and PR creation after you finish. -- **Don't modify CI/CD**: Don't change GitHub Actions, deployment configs, or similar infrastructure unless the issue specifically asks for it. -- **Atomic commits**: Each commit should be a logical unit of work. -- **No secrets**: Never hardcode tokens, keys, or credentials. diff --git a/cmd/symphony/attach.go b/cmd/symphony/attach.go new file mode 100644 index 0000000..89eae1d --- /dev/null +++ b/cmd/symphony/attach.go @@ -0,0 +1,62 @@ +package main + +import ( + "fmt" + "io" + "net" + "os" + "os/signal" + "path/filepath" + "syscall" + + "github.com/spf13/cobra" +) + +var attachCmd = &cobra.Command{ + Use: "attach ", + Short: "Attach to a running agent's PTY session", + Long: `Connects to the Unix socket of a running agent and streams its terminal output. Press Ctrl+C to detach.`, + Args: cobra.ExactArgs(1), + RunE: runAttach, +} + +func init() { + rootCmd.AddCommand(attachCmd) +} + +func runAttach(cmd *cobra.Command, args []string) error { + itemID := args[0] + cwd, _ := os.Getwd() + socketPath := filepath.Join(cwd, ".symphony", "sockets", itemID+".sock") + + if _, err := os.Stat(socketPath); os.IsNotExist(err) { + return fmt.Errorf("no active session for %q — socket not found at %s", itemID, socketPath) + } + + conn, err := net.Dial("unix", socketPath) + if err != nil { + return fmt.Errorf("connect to agent session: %w", err) + } + defer func() { _ = conn.Close() }() + + fmt.Fprintf(os.Stderr, "Attached to %s (Ctrl+C to detach)\n", itemID) + + // Handle Ctrl+C to detach cleanly + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT) + go func() { + <-sigCh + fmt.Fprintln(os.Stderr, "\nDetached.") + _ = conn.Close() + os.Exit(0) + }() + + // Stream socket output to stdout + _, err = io.Copy(os.Stdout, conn) + if err != nil { + return nil // connection closed normally + } + + fmt.Fprintln(os.Stderr, "\nSession ended.") + return nil +} diff --git a/cmd/symphony/config_cmd.go b/cmd/symphony/config_cmd.go new file mode 100644 index 0000000..3a48e3d --- /dev/null +++ b/cmd/symphony/config_cmd.go @@ -0,0 +1,74 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/joho/godotenv" + "github.com/spf13/cobra" + + "github.com/shivamstaq/github-symphony/internal/config" +) + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Configuration management commands", +} + +var configValidateCmd = &cobra.Command{ + Use: "validate", + Short: "Validate symphony.yaml", + RunE: func(cmd *cobra.Command, args []string) error { + _ = godotenv.Load() + cwd, _ := os.Getwd() + configPath := filepath.Join(cwd, ".symphony", "symphony.yaml") + + cfg, err := config.LoadSymphonyConfig(configPath) + if err != nil { + return fmt.Errorf("parse error: %w", err) + } + if err := config.ValidateSymphonyConfig(cfg); err != nil { + return err + } + fmt.Println("Config is valid.") + return nil + }, +} + +var configShowCmd = &cobra.Command{ + Use: "show", + Short: "Show resolved configuration with defaults applied", + RunE: func(cmd *cobra.Command, args []string) error { + _ = godotenv.Load() + cwd, _ := os.Getwd() + configPath := filepath.Join(cwd, ".symphony", "symphony.yaml") + + cfg, err := config.LoadSymphonyConfig(configPath) + if err != nil { + return fmt.Errorf("parse error: %w", err) + } + + // Mask sensitive values + if cfg.Auth.GitHub.Token != "" { + cfg.Auth.GitHub.Token = "***" + } + if cfg.Auth.Linear.APIKey != "" { + cfg.Auth.Linear.APIKey = "***" + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil + }, +} + +func init() { + configCmd.AddCommand(configValidateCmd) + configCmd.AddCommand(configShowCmd) + rootCmd.AddCommand(configCmd) +} diff --git a/cmd/symphony/doctor.go b/cmd/symphony/doctor.go new file mode 100644 index 0000000..b6045e3 --- /dev/null +++ b/cmd/symphony/doctor.go @@ -0,0 +1,171 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "text/template" + "time" + + "github.com/joho/godotenv" + "github.com/spf13/cobra" + + "github.com/shivamstaq/github-symphony/internal/config" +) + +var doctorCmd = &cobra.Command{ + Use: "doctor", + Short: "Validate configuration and environment", + Long: `Checks symphony.yaml, verifies credentials, and tests connectivity.`, + RunE: runDoctor, +} + +func init() { + rootCmd.AddCommand(doctorCmd) +} + +func runDoctor(cmd *cobra.Command, args []string) error { + _ = godotenv.Load() + + cwd, err := os.Getwd() + if err != nil { + return err + } + + symphonyDir := filepath.Join(cwd, ".symphony") + configPath := filepath.Join(symphonyDir, "symphony.yaml") + + fmt.Println("Symphony Doctor") + fmt.Println("===============") + fmt.Println() + + // Check .symphony/ exists + if _, err := os.Stat(symphonyDir); os.IsNotExist(err) { + fmt.Println(" FAIL .symphony/ directory not found") + fmt.Println(" Run 'symphony init' to create it") + return nil + } + fmt.Println(" ok .symphony/ directory exists") + + // Check symphony.yaml exists + if _, err := os.Stat(configPath); os.IsNotExist(err) { + fmt.Println(" FAIL symphony.yaml not found") + return nil + } + fmt.Println(" ok symphony.yaml found") + + // Parse config + cfg, err := config.LoadSymphonyConfig(configPath) + if err != nil { + fmt.Printf(" FAIL config parse error: %v\n", err) + return nil + } + fmt.Println(" ok symphony.yaml parsed successfully") + + // Validate config + if err := config.ValidateSymphonyConfig(cfg); err != nil { + fmt.Printf(" FAIL %v\n", err) + return nil + } + fmt.Println(" ok config validation passed") + + // Check directories + dirs := map[string]string{ + "prompts": filepath.Join(symphonyDir, "prompts"), + "state": filepath.Join(symphonyDir, "state"), + "logs": filepath.Join(symphonyDir, "logs"), + } + for name, path := range dirs { + if _, err := os.Stat(path); os.IsNotExist(err) { + fmt.Printf(" WARN %s/ directory missing\n", name) + } else { + fmt.Printf(" ok %s/ directory exists\n", name) + } + } + + // Check default prompt template + defaultPrompt := filepath.Join(symphonyDir, "prompts", cfg.PromptRouting.Default) + if _, err := os.Stat(defaultPrompt); os.IsNotExist(err) { + fmt.Printf(" FAIL default prompt template not found: %s\n", defaultPrompt) + } else { + fmt.Printf(" ok default prompt template: %s\n", cfg.PromptRouting.Default) + } + + // Check agent binary + agentBin := cfg.Agent.Command + if agentBin == "" { + switch cfg.Agent.Kind { + case "claude_code": + agentBin = "claude" + case "opencode": + agentBin = "opencode" + case "codex": + agentBin = "codex" + } + } + if path, err := exec.LookPath(agentBin); err != nil { + fmt.Printf(" WARN agent binary not found: %s\n", agentBin) + } else { + fmt.Printf(" ok agent binary: %s\n", path) + } + + // Check git + if _, err := exec.LookPath("git"); err != nil { + fmt.Println(" FAIL git not found on PATH") + } else { + fmt.Println(" ok git available") + } + + // Check prompt template is parseable + if _, statErr := os.Stat(defaultPrompt); statErr == nil { + tmplData, readErr := os.ReadFile(defaultPrompt) + if readErr == nil { + if _, parseErr := template.New("check").Option("missingkey=error").Parse(string(tmplData)); parseErr != nil { + fmt.Printf(" WARN prompt template has parse errors: %v\n", parseErr) + } else { + fmt.Println(" ok prompt template parses correctly") + } + } + } + + // Auth check + if cfg.Tracker.Kind == "github" { + if cfg.Auth.GitHub.Token != "" { + fmt.Println(" ok GitHub token configured") + } else if cfg.Auth.GitHub.AppID != "" { + fmt.Println(" ok GitHub App credentials configured") + } else { + fmt.Println(" FAIL no GitHub credentials") + } + } + + // GitHub API connectivity check + if cfg.Tracker.Kind == "github" && cfg.Auth.GitHub.Token != "" { + apiURL := cfg.Auth.GitHub.APIURL + if apiURL == "" { + apiURL = "https://api.github.com" + } + client := &http.Client{Timeout: 10 * time.Second} + req, _ := http.NewRequest("GET", apiURL+"/user", nil) + req.Header.Set("Authorization", "Bearer "+cfg.Auth.GitHub.Token) + resp, err := client.Do(req) + if err != nil { + fmt.Printf(" WARN GitHub API unreachable: %v\n", err) + } else { + _ = resp.Body.Close() + if resp.StatusCode == 200 { + fmt.Println(" ok GitHub API reachable (authenticated)") + } else { + fmt.Printf(" WARN GitHub API returned status %d\n", resp.StatusCode) + } + } + } + + fmt.Println() + fmt.Printf("Tracker: %s | Agent: %s | Max concurrent: %d\n", + cfg.Tracker.Kind, cfg.Agent.Kind, cfg.Agent.MaxConcurrent) + + return nil +} diff --git a/cmd/symphony/events.go b/cmd/symphony/events.go new file mode 100644 index 0000000..1709c7d --- /dev/null +++ b/cmd/symphony/events.go @@ -0,0 +1,65 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +var eventsCmd = &cobra.Command{ + Use: "events", + Short: "Query the FSM event log", + Long: `Reads .symphony/state/events.jsonl and displays FSM state transitions.`, + RunE: runEvents, +} + +var eventsItem string + +func init() { + eventsCmd.Flags().StringVar(&eventsItem, "item", "", "Filter by work item ID") + rootCmd.AddCommand(eventsCmd) +} + +func runEvents(cmd *cobra.Command, args []string) error { + cwd, _ := os.Getwd() + eventsPath := filepath.Join(cwd, ".symphony", "state", "events.jsonl") + + f, err := os.Open(eventsPath) + if err != nil { + return fmt.Errorf("open events: %w (has Symphony run yet?)", err) + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if eventsItem != "" && !strings.Contains(line, eventsItem) { + continue + } + + var evt map[string]any + if err := json.Unmarshal([]byte(line), &evt); err != nil { + fmt.Println(line) + continue + } + + ts, _ := evt["timestamp"].(string) + itemID, _ := evt["item_id"].(string) + from, _ := evt["from"].(string) + to, _ := evt["to"].(string) + event, _ := evt["event"].(string) + + if len(ts) > 19 { + ts = ts[11:19] + } + + fmt.Printf("%s %-20s %s -> %s [%s]\n", ts, itemID, from, to, event) + } + + return nil +} diff --git a/cmd/symphony/init.go b/cmd/symphony/init.go new file mode 100644 index 0000000..f77a073 --- /dev/null +++ b/cmd/symphony/init.go @@ -0,0 +1,228 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Initialize a .symphony/ directory in the current repository", + Long: `Creates a .symphony/ directory with: + - symphony.yaml (configuration) + - prompts/default.md (default prompt template) + - state/ (persistent state directory) + - logs/ (log output directory) + +Also adds .symphony/ to .gitignore if not already present.`, + RunE: runInit, +} + +func init() { + rootCmd.AddCommand(initCmd) +} + +func runInit(cmd *cobra.Command, args []string) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + + symphonyDir := filepath.Join(cwd, ".symphony") + if _, err := os.Stat(symphonyDir); err == nil { + return fmt.Errorf(".symphony/ already exists in %s — run 'symphony doctor' to validate", cwd) + } + + reader := bufio.NewReader(os.Stdin) + + // Step 1: Tracker + trackerKind := prompt(reader, "Tracker type", "github", []string{"github", "linear"}) + + var owner, projectNum, token string + if trackerKind == "github" { + owner = promptFree(reader, "GitHub owner (org or user)") + projectNum = promptFree(reader, "GitHub Project number") + + // Check for token in env + envToken := os.Getenv("GITHUB_TOKEN") + if envToken != "" { + fmt.Printf(" %s GitHub token detected from $GITHUB_TOKEN\n", checkmark()) + token = "$GITHUB_TOKEN" + } else { + token = promptFree(reader, "GitHub token (or $VAR_NAME)") + } + } + + // Step 2: Agent + agentKind := prompt(reader, "Agent CLI", "claude_code", []string{"claude_code", "opencode", "codex"}) + maxConcurrent := promptFree(reader, "Max concurrent agents (default: 3)") + if maxConcurrent == "" { + maxConcurrent = "3" + } + + // Create directories + dirs := []string{ + symphonyDir, + filepath.Join(symphonyDir, "prompts"), + filepath.Join(symphonyDir, "state"), + filepath.Join(symphonyDir, "logs"), + filepath.Join(symphonyDir, "logs", "agents"), + filepath.Join(symphonyDir, "sockets"), + } + for _, d := range dirs { + if err := os.MkdirAll(d, 0755); err != nil { + return fmt.Errorf("create directory %s: %w", d, err) + } + } + + // Write symphony.yaml + yamlContent := generateYAML(trackerKind, owner, projectNum, token, agentKind, maxConcurrent) + yamlPath := filepath.Join(symphonyDir, "symphony.yaml") + if err := os.WriteFile(yamlPath, []byte(yamlContent), 0644); err != nil { + return fmt.Errorf("write symphony.yaml: %w", err) + } + + // Write default prompt + promptPath := filepath.Join(symphonyDir, "prompts", "default.md") + if err := os.WriteFile(promptPath, []byte(defaultPromptTemplate), 0644); err != nil { + return fmt.Errorf("write default prompt: %w", err) + } + + // Add .symphony/ to .gitignore + addToGitignore(cwd) + + fmt.Println() + fmt.Printf(" %s Created %s\n", checkmark(), yamlPath) + fmt.Printf(" %s Created %s\n", checkmark(), promptPath) + fmt.Printf(" %s Created state/ and logs/ directories\n", checkmark()) + fmt.Printf(" %s Updated .gitignore\n", checkmark()) + fmt.Println() + fmt.Println("Next: edit .symphony/symphony.yaml, then run 'symphony run'") + fmt.Println(" or run 'symphony doctor' to validate your config") + + return nil +} + +func prompt(reader *bufio.Reader, label, defaultVal string, options []string) string { + fmt.Printf("? %s [%s] (default: %s): ", label, strings.Join(options, " / "), defaultVal) + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + if input == "" { + return defaultVal + } + for _, opt := range options { + if strings.EqualFold(input, opt) { + return opt + } + } + return defaultVal +} + +func promptFree(reader *bufio.Reader, label string) string { + fmt.Printf("? %s: ", label) + input, _ := reader.ReadString('\n') + return strings.TrimSpace(input) +} + +func checkmark() string { + return "ok" +} + +func addToGitignore(cwd string) { + gitignorePath := filepath.Join(cwd, ".gitignore") + content, _ := os.ReadFile(gitignorePath) + if strings.Contains(string(content), ".symphony/") { + return + } + f, err := os.OpenFile(gitignorePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return + } + defer func() { _ = f.Close() }() + if len(content) > 0 && !strings.HasSuffix(string(content), "\n") { + _, _ = f.WriteString("\n") + } + _, _ = f.WriteString("\n# Symphony orchestrator state\n.symphony/\n") +} + +func generateYAML(trackerKind, owner, projectNum, token, agentKind, maxConcurrent string) string { + var sb strings.Builder + sb.WriteString("# Symphony configuration\n") + sb.WriteString("# Docs: https://github.com/shivamstaq/github-symphony\n\n") + + sb.WriteString("tracker:\n") + fmt.Fprintf(&sb, " kind: %s\n", trackerKind) + if trackerKind == "github" { + fmt.Fprintf(&sb, " owner: %s\n", owner) + fmt.Fprintf(&sb, " project_number: %s\n", projectNum) + sb.WriteString(" active_values: [Todo, In Progress]\n") + sb.WriteString(" terminal_values: [Done, Closed, Cancelled]\n") + } + + sb.WriteString("\nauth:\n") + if trackerKind == "github" { + sb.WriteString(" github:\n") + fmt.Fprintf(&sb, " token: %s\n", token) + } + + sb.WriteString("\nagent:\n") + fmt.Fprintf(&sb, " kind: %s\n", agentKind) + fmt.Fprintf(&sb, " max_concurrent: %s\n", maxConcurrent) + sb.WriteString(" max_turns: 20\n") + + if agentKind == "claude_code" { + sb.WriteString(" claude:\n") + sb.WriteString(" model: sonnet\n") + sb.WriteString(" permission_profile: bypassPermissions\n") + } + + sb.WriteString("\ngit:\n") + sb.WriteString(" branch_prefix: symphony/\n") + sb.WriteString(" use_worktrees: true\n") + + sb.WriteString("\npolling:\n") + sb.WriteString(" interval_ms: 30000\n") + + sb.WriteString("\npull_request:\n") + sb.WriteString(" open_on_success: true\n") + sb.WriteString(" draft_by_default: true\n") + sb.WriteString(" handoff_status: Human Review\n") + + sb.WriteString("\nprompt_routing:\n") + sb.WriteString(" default: default.md\n") + sb.WriteString(" # field_name: Type\n") + sb.WriteString(" # routes:\n") + sb.WriteString(" # bug: bug_fix.md\n") + sb.WriteString(" # feature: feature.md\n") + + sb.WriteString("\nserver:\n") + sb.WriteString(" port: 9097\n") + + return sb.String() +} + +const defaultPromptTemplate = `You are working on a GitHub issue. + +## Issue +**Title**: {{.work_item.title}} +**Description**: {{.work_item.description}} + +## Repository +{{.repository.full_name}} + +## Instructions +1. Read the issue carefully and understand what needs to be done +2. Explore the codebase to understand the relevant code +3. Implement the fix or feature +4. Write tests if appropriate +5. Ensure all existing tests pass +6. Create clear, atomic commits + +Branch: {{.branch_name}} +Base: {{.base_branch}} +` diff --git a/cmd/symphony/logs.go b/cmd/symphony/logs.go new file mode 100644 index 0000000..e2991d9 --- /dev/null +++ b/cmd/symphony/logs.go @@ -0,0 +1,110 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +var logsCmd = &cobra.Command{ + Use: "logs", + Short: "Tail orchestrator logs", + Long: `Reads and displays logs from .symphony/logs/orchestrator.jsonl.`, + RunE: runLogs, +} + +var ( + logsFollow bool + logsAgent string + logsLevel string + logsLines int +) + +func init() { + logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false, "Follow log output") + logsCmd.Flags().StringVar(&logsAgent, "agent", "", "Filter by agent/item ID") + logsCmd.Flags().StringVar(&logsLevel, "level", "", "Minimum log level (debug, info, warn, error)") + logsCmd.Flags().IntVarP(&logsLines, "lines", "n", 50, "Number of lines to show") + rootCmd.AddCommand(logsCmd) +} + +func runLogs(cmd *cobra.Command, args []string) error { + cwd, _ := os.Getwd() + logPath := filepath.Join(cwd, ".symphony", "logs", "orchestrator.jsonl") + + f, err := os.Open(logPath) + if err != nil { + return fmt.Errorf("open log file: %w (is Symphony initialized?)", err) + } + defer func() { _ = f.Close() }() + + // Read all lines + var lines []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if logsAgent != "" && !strings.Contains(line, logsAgent) { + continue + } + if logsLevel != "" && !matchesLevel(line, logsLevel) { + continue + } + lines = append(lines, line) + } + + // Show last N lines + start := 0 + if len(lines) > logsLines { + start = len(lines) - logsLines + } + + for _, line := range lines[start:] { + fmt.Println(formatLogLine(line)) + } + + return nil +} + +func matchesLevel(line, minLevel string) bool { + levelOrder := map[string]int{ + "DEBUG": 0, "INFO": 1, "WARN": 2, "ERROR": 3, + } + minOrd, ok := levelOrder[strings.ToUpper(minLevel)] + if !ok { + return true + } + + // Parse level from JSON line + var entry map[string]any + if err := json.Unmarshal([]byte(line), &entry); err != nil { + return true + } + level, _ := entry["level"].(string) + lineOrd, ok := levelOrder[strings.ToUpper(level)] + if !ok { + return true + } + return lineOrd >= minOrd +} + +func formatLogLine(line string) string { + var entry map[string]any + if err := json.Unmarshal([]byte(line), &entry); err != nil { + return line // not JSON, show raw + } + + ts, _ := entry["time"].(string) + level, _ := entry["level"].(string) + msg, _ := entry["msg"].(string) + + if len(ts) > 19 { + ts = ts[11:19] // extract HH:MM:SS + } + + return fmt.Sprintf("%s %-5s %s", ts, level, msg) +} diff --git a/cmd/symphony/main.go b/cmd/symphony/main.go index a902036..a488476 100644 --- a/cmd/symphony/main.go +++ b/cmd/symphony/main.go @@ -1,613 +1,25 @@ package main import ( - "context" - "flag" "fmt" - "log/slog" - "net/http" "os" - "os/signal" - "path/filepath" - "strings" - "syscall" - "time" - tea "github.com/charmbracelet/bubbletea" - "github.com/joho/godotenv" - "github.com/shivamstaq/github-symphony/internal/adapter" - "github.com/shivamstaq/github-symphony/internal/config" - ghub "github.com/shivamstaq/github-symphony/internal/github" - "github.com/shivamstaq/github-symphony/internal/logging" - "github.com/shivamstaq/github-symphony/internal/orchestrator" - "github.com/shivamstaq/github-symphony/internal/server" - "github.com/shivamstaq/github-symphony/internal/state" - symphonyTUI "github.com/shivamstaq/github-symphony/internal/tui" - "github.com/shivamstaq/github-symphony/internal/webhook" - "github.com/shivamstaq/github-symphony/internal/workspace" + "github.com/spf13/cobra" ) -func main() { - // Load .env file from CWD - _ = godotenv.Load() - - var ( - port int - logFormat string - logLevel string - stateDir string - doctor bool - noTUI bool - victoriaLogsURL string - ) - - flag.IntVar(&port, "port", 9097, "HTTP server port (0 to disable)") - flag.StringVar(&logFormat, "log-format", "text", "Log output format: text, json") - flag.BoolVar(&noTUI, "no-tui", false, "Disable TUI, use plain log output (for CI/Docker)") - flag.StringVar(&logLevel, "log-level", "info", "Log level: debug, info, warn, error") - flag.StringVar(&stateDir, "state-dir", "", "Directory for persistent state") - flag.BoolVar(&doctor, "doctor", false, "Validate config and environment, then exit") - flag.StringVar(&victoriaLogsURL, "victorialogs-url", "http://localhost:9428", "VictoriaLogs URL for log push (empty to disable)") - flag.Parse() - - logger := setupLogger(logFormat, logLevel) - - // Wrap logger with VictoriaLogs pusher if configured - var logPusher *logging.LogPusher - if victoriaLogsURL != "" { - logPusher = logging.NewLogPusherFromURL(logger.Handler(), victoriaLogsURL) - logger = slog.New(logPusher) - } - - slog.SetDefault(logger) - - // Resolve workflow path - workflowPath := "WORKFLOW.md" - if flag.NArg() > 0 { - workflowPath = flag.Arg(0) - } - - logger.Info("loading workflow", "path", workflowPath) - - // Load workflow - wf, err := config.LoadWorkflow(workflowPath) - if err != nil { - logger.Error("workflow load failed", "error", err) - os.Exit(1) - } - - // Parse config - cfg, err := config.NewServiceConfig(wf.Config) - if err != nil { - logger.Error("config parse failed", "error", err) - os.Exit(1) - } - - // Apply CLI overrides (port default is 9097; use --port 0 to disable HTTP server) - cfg.Server.Port = port - - // Validate - if err := config.ValidateForDispatch(cfg); err != nil { - logger.Error("config validation failed", "error", err) - os.Exit(1) - } - - // Resolve workspace paths - wsRoot := cfg.Workspace.Root - if wsRoot == "" { - wsRoot = filepath.Join(os.TempDir(), "symphony_workspaces") - } - wsCacheDir := cfg.Workspace.RepoCacheDir - if wsCacheDir == "" { - wsCacheDir = filepath.Join(wsRoot, "repo_cache") - } - wsWorktreeDir := cfg.Workspace.WorktreeDir - if wsWorktreeDir == "" { - wsWorktreeDir = filepath.Join(wsRoot, "worktrees") - } - - // Resolve state dir - if stateDir == "" { - stateDir = filepath.Join(wsRoot, ".symphony") - } - - // Doctor mode - if doctor { - runDoctor(cfg, wsRoot, stateDir) - return - } - - logger.Info("symphony starting", - "tracker.owner", cfg.Tracker.Owner, - "tracker.project_number", cfg.Tracker.ProjectNumber, - "agent.kind", cfg.Agent.Kind, - "auth_mode", cfg.GitHub.ResolvedAuthMode, - "workspace_root", wsRoot, - ) - - // Open persistent state store - if err := os.MkdirAll(stateDir, 0755); err != nil { - logger.Error("cannot create state directory", "path", stateDir, "error", err) - os.Exit(1) - } - store, err := state.Open(filepath.Join(stateDir, "symphony.db")) - if err != nil { - logger.Error("state store open failed", "error", err) - os.Exit(1) - } - defer func() { _ = store.Close() }() - - // Create GitHub auth provider - authProvider := ghub.NewPATProvider(cfg.GitHub.Token) - - // Create GitHub clients - token, err := authProvider.Token(context.Background(), ghub.RepoRef{}) - if err != nil { - logger.Error("auth failed", "error", err) - os.Exit(1) - } - - apiURL := strings.TrimSuffix(cfg.GitHub.APIURL, "/") - if apiURL == "" { - apiURL = "https://api.github.com" - } - graphqlEndpoint := apiURL + "/graphql" - - gqlClient := ghub.NewGraphQLClient(graphqlEndpoint, token) - writeBack := ghub.NewWriteBack(apiURL, graphqlEndpoint, token) - - // Fetch project field metadata for status updates (best-effort) - var projectMeta *ghub.ProjectFieldMeta - if cfg.PullRequest.HandoffProjectStatus != "" { - meta, err := gqlClient.FetchProjectFieldMeta( - context.Background(), - cfg.Tracker.Owner, - cfg.Tracker.ProjectNumber, - cfg.Tracker.ProjectScope, - cfg.Tracker.StatusFieldName, - ) - if err != nil { - logger.Warn("could not fetch project field metadata (status updates will be skipped)", "error", err) - } else { - projectMeta = meta - logger.Info("project metadata loaded", - "project_id", meta.ProjectID, - "field_id", meta.FieldID, - "options", len(meta.Options), - ) - // Verify the handoff status option exists - if _, ok := meta.Options[cfg.PullRequest.HandoffProjectStatus]; !ok { - available := make([]string, 0, len(meta.Options)) - for name := range meta.Options { - available = append(available, name) - } - logger.Error("CRITICAL: handoff_project_status not found in project", - "configured", cfg.PullRequest.HandoffProjectStatus, - "available_options", available, - ) - fmt.Fprintf(os.Stderr, "\n⚠️ WARNING: handoff_project_status %q does not exist on your GitHub Project.\n", cfg.PullRequest.HandoffProjectStatus) - fmt.Fprintf(os.Stderr, " Available status options: %v\n", available) - fmt.Fprintf(os.Stderr, " Symphony cannot move items to handoff status after PR creation.\n") - fmt.Fprintf(os.Stderr, " Fix: add %q to your project's Status field, or change handoff_project_status in WORKFLOW.md\n\n", cfg.PullRequest.HandoffProjectStatus) - } - } - } - - // Create GitHub source - ghSource := ghub.NewSource(gqlClient, ghub.SourceConfig{ - Owner: cfg.Tracker.Owner, - ProjectNumber: cfg.Tracker.ProjectNumber, - ProjectScope: cfg.Tracker.ProjectScope, - StatusFieldName: cfg.Tracker.StatusFieldName, - PageSize: cfg.GitHub.GraphQLPageSize, - PriorityValueMap: cfg.Tracker.PriorityValueMap, - }) - - sourceBridge := orchestrator.NewSourceBridge(ghSource, cfg.Tracker.PriorityValueMap) - - // Create workspace manager - wsMgr := workspace.NewManager(workspace.ManagerConfig{ - WorktreeDir: wsWorktreeDir, - RepoCacheDir: wsCacheDir, - BranchPrefix: cfg.Git.BranchPrefix, - UseWorktrees: cfg.Git.UseWorktrees, - FetchDepth: cfg.Git.FetchDepth, - Hooks: workspace.HooksConfig{ - AfterCreate: cfg.Hooks.AfterCreate, - BeforeRun: cfg.Hooks.BeforeRun, - AfterRun: cfg.Hooks.AfterRun, - BeforeRemove: cfg.Hooks.BeforeRemove, - TimeoutMs: cfg.Hooks.TimeoutMs, - }, - }) - - // Create shared event bus for TUI + worker events - eventBus := orchestrator.NewEventBus(200) - - // Create worker runner - runner := orchestrator.NewRunner(orchestrator.WorkerDeps{ - WorkspaceManager: wsMgr, - AdapterFactory: func(cwd string) (adapter.AdapterClient, error) { - acfg := adapter.AdapterConfig{ - Kind: cfg.Agent.Kind, - Cwd: cwd, - } - switch cfg.Agent.Kind { - case "claude_code": - acfg.Command = "claude" // uses locally-authenticated claude CLI - acfg.Model = cfg.Claude.Model - acfg.AllowedTools = cfg.Claude.AllowedTools - permMode := "bypassPermissions" - if p, ok := cfg.Claude.PermissionProfile.(string); ok && p != "" { - permMode = p - } - acfg.PermissionMode = permMode - case "opencode": - acfg.Command = "opencode" - acfg.Args = []string{"acp"} - case "codex": - acfg.Command = "codex" - acfg.Args = []string{"app-server"} - } - if cfg.Agent.Command != "" { - acfg.Command = "bash" - acfg.Args = []string{"-lc", cfg.Agent.Command} - } - return adapter.NewAdapter(acfg) - }, - Source: sourceBridge, - WriteBack: writeBack, - StateStore: store, - PromptTemplate: wf.PromptTemplate, - MaxTurns: cfg.Agent.MaxTurns, - HooksBefore: cfg.Hooks.BeforeRun, - HooksAfter: cfg.Hooks.AfterRun, - HooksTimeoutMs: cfg.Hooks.TimeoutMs, - PullRequestCfg: buildPRConfig(cfg, projectMeta), - GitToken: token, - EventBus: eventBus, - }) - - // Create orchestrator - orch := orchestrator.New(orchestrator.OrchestratorConfig{ - PollIntervalMs: cfg.Polling.IntervalMs, - MaxConcurrentAgents: cfg.Agent.MaxConcurrentAgents, - StallTimeoutMs: cfg.Agent.StallTimeoutMs, - MaxRetryBackoffMs: cfg.Agent.MaxRetryBackoffMs, - Eligibility: orchestrator.EligibilityConfig{ - ActiveValues: cfg.Tracker.ActiveValues, - TerminalValues: cfg.Tracker.TerminalValues, - ExecutableItemTypes: cfg.Tracker.ExecutableItemTypes, - RequireIssueBacking: cfg.Tracker.RequireIssueBacking, - RepoAllowlist: cfg.Tracker.RepoAllowlist, - RepoDenylist: cfg.Tracker.RepoDenylist, - RequiredLabels: cfg.Tracker.RequiredLabels, - BlockedStatusValues: cfg.Tracker.BlockedStatusValues, - }, - ActiveValues: cfg.Tracker.ActiveValues, - TerminalValues: cfg.Tracker.TerminalValues, - }, sourceBridge, runner) - orch.Events = eventBus // share event bus between orchestrator and workers - - // Restore persisted retries - retries, err := store.LoadRetries() - if err != nil { - logger.Warn("failed to load persisted retries", "error", err) - } else { - for _, r := range retries { - orch.RestoreRetry(orchestrator.RetryEntry{ - WorkItemID: r.WorkItemID, - IssueIdentifier: r.IssueIdentifier, - Attempt: r.Attempt, - DueAt: time.UnixMilli(r.DueAtMs), - Error: r.Error, - }) - } - if len(retries) > 0 { - logger.Info("restored persisted retries", "count", len(retries)) - } - } - - // Restore handed-off items - handoffs, err := store.LoadHandoffs() - if err != nil { - logger.Warn("failed to load persisted handoffs", "error", err) - } else { - for _, id := range handoffs { - orch.RestoreHandoff(id) - } - if len(handoffs) > 0 { - logger.Info("restored persisted handoffs", "count", len(handoffs)) - } - } - - // Restore totals - totals, err := store.LoadTotals() - if err != nil { - logger.Warn("failed to load persisted totals", "error", err) - } - _ = totals // TODO: apply to orchestrator state +var rootCmd = &cobra.Command{ + Use: "symphony", + Short: "Symphony — AI agent orchestrator for GitHub projects", + Long: `Symphony orchestrates AI coding agents (Claude, OpenCode, Codex) to work on +GitHub issues from a GitHub Project board. It manages the full lifecycle from +dispatch to PR creation and handoff. - startedAt := time.Now() - - stateProvider := &orchestratorStateProvider{ - orch: orch, - authMode: cfg.GitHub.ResolvedAuthMode, - startedAt: startedAt, - } - - // Start HTTP server if configured - logger.Debug("server port check", "port", cfg.Server.Port) - if cfg.Server.Port > 0 { - - srv := server.New(server.Config{ - Port: cfg.Server.Port, - Host: cfg.Server.Host, - ReadTimeoutMs: cfg.Server.ReadTimeoutMs, - WriteTimeoutMs: cfg.Server.WriteTimeoutMs, - }, stateProvider) - - // Mount webhook handler if secret is configured - if cfg.GitHub.WebhookSecret != "" { - wh := webhook.NewHandler(cfg.GitHub.WebhookSecret, func(eventType string, _ []byte) { - logger.Info("webhook received, triggering refresh", "event", eventType) - orch.SetPendingRefresh() - }) - srv.MountWebhook(wh) - } - - go func() { - logger.Info("HTTP server starting", "port", cfg.Server.Port) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Error("HTTP server failed", "error", err) - } - }() - } - - // Start workflow file watcher - watcher, err := config.NewWatcher(workflowPath, func(newWf *config.WorkflowDefinition) { - logger.Info("workflow reloaded") - // TODO: apply new config to running orchestrator - }) - if err != nil { - logger.Warn("workflow watcher failed to start", "error", err) - } else { - defer func() { _ = watcher.Close() }() - } - - // Signal handling - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - sigChan := make(chan os.Signal, 2) - signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT) - - go func() { - sig := <-sigChan - logger.Info("received signal, initiating shutdown", "signal", sig) - cancel() - - // Second signal: force exit - sig = <-sigChan - logger.Warn("received second signal, force exiting", "signal", sig) - os.Exit(1) - }() - - // Run orchestrator in background - go orch.Run(ctx) - - // Start TUI if running interactively (terminal attached and not disabled) - useTUI := !noTUI && isTerminal() - if useTUI { - // Redirect slog away from stderr — TUI owns the terminal. - // Logs go to: VictoriaLogs (if configured) + log file only. - logFile, err := os.CreateTemp("", "symphony-*.log") - if err == nil { - fileHandler := slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: logging.ParseLevel(logLevel)}) - if logPusher != nil { - // File + VictoriaLogs (no stderr) - slog.SetDefault(slog.New(logging.NewLogPusherFromURL(fileHandler, victoriaLogsURL))) - } else { - slog.SetDefault(slog.New(fileHandler)) - } - logger.Info("TUI active, logs redirected", "log_file", logFile.Name()) - defer logFile.Close() - } - - tuiModel := symphonyTUI.New(symphonyTUI.Config{ - StateProvider: stateProvider, - EventBus: eventBus, - StartedAt: startedAt, - }) - p := tea.NewProgram(tuiModel, tea.WithAltScreen()) - if _, tuiErr := p.Run(); tuiErr != nil { - fmt.Fprintf(os.Stderr, "TUI error: %v\n", tuiErr) - } - cancel() // TUI quit → cancel orchestrator - } else { - // Non-interactive: block until signal - <-ctx.Done() - } - - // Graceful shutdown - logger.Info("shutting down...") - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) - defer shutdownCancel() - orch.Shutdown(shutdownCtx) - - // Persist retry state - entries := orch.GetRetryEntries() - for _, e := range entries { - _ = store.SaveRetry(state.RetryRecord{ - WorkItemID: e.WorkItemID, - IssueIdentifier: e.IssueIdentifier, - Attempt: e.Attempt, - DueAtMs: e.DueAt.UnixMilli(), - Error: e.Error, - }) - } - if len(entries) > 0 { - logger.Info("persisted retry state", "count", len(entries)) - } - - logger.Info("symphony stopped") - - // Flush log pusher - if logPusher != nil { - logPusher.Close() - } +Run 'symphony init' to set up a project, then 'symphony run' to start.`, } -// orchestratorStateProvider bridges the orchestrator to the server's StateProvider interface. -type orchestratorStateProvider struct { - orch *orchestrator.Orchestrator - authMode string - startedAt time.Time -} - -func (p *orchestratorStateProvider) GetState() orchestrator.State { return p.orch.GetState() } -func (p *orchestratorStateProvider) IsHealthy() bool { return true } -func (p *orchestratorStateProvider) AuthMode() string { return p.authMode } -func (p *orchestratorStateProvider) TriggerRefresh() { p.orch.SetPendingRefresh() } -func (p *orchestratorStateProvider) StartedAt() time.Time { return p.startedAt } - -func runDoctor(cfg *config.ServiceConfig, wsRoot, stateDir string) { - fmt.Println("PASS: workflow file loaded and parsed") - fmt.Println("PASS: config validation passed") - fmt.Printf(" tracker.kind: %s\n", cfg.Tracker.Kind) - fmt.Printf(" tracker.owner: %s\n", cfg.Tracker.Owner) - fmt.Printf(" tracker.project_number: %d\n", cfg.Tracker.ProjectNumber) - fmt.Printf(" agent.kind: %s\n", cfg.Agent.Kind) - fmt.Printf(" auth_mode: %s\n", cfg.GitHub.ResolvedAuthMode) - fmt.Printf(" workspace_root: %s\n", wsRoot) - fmt.Printf(" state_dir: %s\n", stateDir) - - // Check agent runtime - switch cfg.Agent.Kind { - case "claude_code": - if err := checkBinaryExists("claude"); err != nil { - fmt.Printf("FAIL: claude CLI not found on PATH: %v\n", err) - fmt.Println(" Install: https://docs.anthropic.com/en/docs/claude-code") - fmt.Println(" Then run: claude login") - os.Exit(1) - } - fmt.Println("PASS: claude CLI found on PATH") - case "opencode": - if err := checkBinaryExists("opencode"); err != nil { - fmt.Printf("FAIL: opencode not found on PATH: %v\n", err) - os.Exit(1) - } - fmt.Println("PASS: opencode found on PATH") - case "codex": - if err := checkBinaryExists("codex"); err != nil { - fmt.Printf("FAIL: codex not found on PATH: %v\n", err) - os.Exit(1) - } - fmt.Println("PASS: codex found on PATH") - } - - // Check git - if err := checkBinaryExists("git"); err != nil { - fmt.Printf("FAIL: git not found on PATH: %v\n", err) +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) os.Exit(1) } - fmt.Println("PASS: git found on PATH") - - // Check GitHub connectivity - if cfg.GitHub.Token != "" { - apiURL := cfg.GitHub.APIURL - if apiURL == "" { - apiURL = "https://api.github.com" - } - provider := ghub.NewPATProvider(cfg.GitHub.Token) - client, err := provider.HTTPClient(context.Background(), ghub.RepoRef{}) - if err != nil { - fmt.Printf("FAIL: GitHub auth: %v\n", err) - os.Exit(1) - } - resp, err := client.Get(apiURL + "/user") - if err != nil { - fmt.Printf("FAIL: GitHub connectivity: %v\n", err) - os.Exit(1) - } - _ = resp.Body.Close() - if resp.StatusCode == 200 { - fmt.Println("PASS: GitHub API connectivity verified") - } else { - fmt.Printf("WARN: GitHub API returned status %d\n", resp.StatusCode) - } - } - - fmt.Println("\nAll checks passed.") -} - -func checkBinaryExists(name string) error { - _, err := lookPath(name) - return err -} - -// lookPath is os/exec.LookPath but avoids importing os/exec in main -// just for this one function when we already have it available. -func lookPath(name string) (string, error) { - // Simple PATH search - pathEnv := os.Getenv("PATH") - for _, dir := range filepath.SplitList(pathEnv) { - full := filepath.Join(dir, name) - if info, err := os.Stat(full); err == nil && !info.IsDir() { - return full, nil - } - } - return "", fmt.Errorf("%s not found in PATH", name) -} - -func isTerminal() bool { - fi, err := os.Stdout.Stat() - if err != nil { - return false - } - return fi.Mode()&os.ModeCharDevice != 0 -} - -func buildPRConfig(cfg *config.ServiceConfig, meta *ghub.ProjectFieldMeta) orchestrator.PullRequestConfig { - pc := orchestrator.PullRequestConfig{ - OpenPROnSuccess: cfg.PullRequest.OpenPROnSuccess, - DraftByDefault: cfg.PullRequest.DraftByDefault, - HandoffProjectStatus: cfg.PullRequest.HandoffProjectStatus, - CommentOnIssue: cfg.PullRequest.CommentOnIssueWithPR, - } - if meta != nil { - pc.ProjectID = meta.ProjectID - pc.StatusFieldID = meta.FieldID - if optID, ok := meta.Options[cfg.PullRequest.HandoffProjectStatus]; ok { - pc.HandoffOptionID = optID - } - } - return pc -} - -func setupLogger(format, level string) *slog.Logger { - var handler slog.Handler - opts := &slog.HandlerOptions{Level: parseLogLevel(level)} - - switch format { - case "json": - handler = slog.NewJSONHandler(os.Stderr, opts) - default: - handler = slog.NewTextHandler(os.Stderr, opts) - } - - return slog.New(handler) -} - -func parseLogLevel(level string) slog.Level { - switch level { - case "debug": - return slog.LevelDebug - case "warn": - return slog.LevelWarn - case "error": - return slog.LevelError - default: - return slog.LevelInfo - } } diff --git a/cmd/symphony/pause.go b/cmd/symphony/pause.go new file mode 100644 index 0000000..dbc86a7 --- /dev/null +++ b/cmd/symphony/pause.go @@ -0,0 +1,84 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + + "github.com/joho/godotenv" + "github.com/spf13/cobra" + + "github.com/shivamstaq/github-symphony/internal/config" +) + +var pauseCmd = &cobra.Command{ + Use: "pause ", + Short: "Pause an agent between turns", + Long: `Sends a pause signal via the HTTP API. The current turn finishes, but the next turn will not start until resumed.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return sendControl("pause", args[0]) + }, +} + +var resumeCmd = &cobra.Command{ + Use: "resume ", + Short: "Resume a paused agent", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return sendControl("resume", args[0]) + }, +} + +var killCmd = &cobra.Command{ + Use: "kill ", + Short: "Force-stop an agent", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return sendControl("kill", args[0]) + }, +} + +func init() { + rootCmd.AddCommand(pauseCmd) + rootCmd.AddCommand(resumeCmd) + rootCmd.AddCommand(killCmd) +} + +func sendControl(action, itemID string) error { + _ = godotenv.Load() + addr := resolveAPIAddr() + + url := fmt.Sprintf("http://%s/api/v1/%s/%s", addr, action, itemID) + resp, err := http.Post(url, "application/json", nil) + if err != nil { + return fmt.Errorf("failed to connect to Symphony at %s: %w\nIs 'symphony run' active?", addr, err) + } + defer func() { _ = resp.Body.Close() }() + + body, _ := io.ReadAll(resp.Body) + fmt.Println(string(body)) + return nil +} + +func resolveAPIAddr() string { + cwd, _ := os.Getwd() + configPath := filepath.Join(cwd, ".symphony", "symphony.yaml") + + cfg, err := config.LoadSymphonyConfig(configPath) + if err != nil { + return "localhost:9097" + } + + host := cfg.Server.Host + if host == "" || host == "0.0.0.0" { + host = "localhost" + } + port := cfg.Server.Port + if port == 0 { + port = 9097 + } + return fmt.Sprintf("%s:%d", host, port) +} diff --git a/cmd/symphony/run.go b/cmd/symphony/run.go new file mode 100644 index 0000000..e674602 --- /dev/null +++ b/cmd/symphony/run.go @@ -0,0 +1,278 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/joho/godotenv" + "github.com/spf13/cobra" + + "github.com/shivamstaq/github-symphony/internal/agent" + "github.com/shivamstaq/github-symphony/internal/agent/claude" + agentcodex "github.com/shivamstaq/github-symphony/internal/agent/codex" + agentmock "github.com/shivamstaq/github-symphony/internal/agent/mock" + agentopencode "github.com/shivamstaq/github-symphony/internal/agent/opencode" + codehostgithub "github.com/shivamstaq/github-symphony/internal/codehost/github" + "github.com/shivamstaq/github-symphony/internal/config" + "github.com/shivamstaq/github-symphony/internal/engine" + "github.com/shivamstaq/github-symphony/internal/logging" + promptpkg "github.com/shivamstaq/github-symphony/internal/prompt" + "github.com/shivamstaq/github-symphony/internal/server" + "github.com/shivamstaq/github-symphony/internal/state" + "github.com/shivamstaq/github-symphony/internal/tracker" + tui "github.com/shivamstaq/github-symphony/internal/tui/views" + "github.com/shivamstaq/github-symphony/internal/workspace" + + // Register tracker implementations + _ "github.com/shivamstaq/github-symphony/internal/tracker/github" + _ "github.com/shivamstaq/github-symphony/internal/tracker/linear" +) + +var runMock bool + +var runCmd = &cobra.Command{ + Use: "run", + Short: "Start the Symphony orchestrator", + Long: `Starts the main orchestration loop: polls the tracker, dispatches agents, manages state.`, + RunE: runRun, +} + +func init() { + runCmd.Flags().BoolVar(&runMock, "mock", false, "Use mock agent instead of real CLI (for testing)") + rootCmd.AddCommand(runCmd) +} + +func runRun(cmd *cobra.Command, args []string) error { + _ = godotenv.Load() + + cwd, err := os.Getwd() + if err != nil { + return err + } + + symphonyDir := filepath.Join(cwd, ".symphony") + configPath := filepath.Join(symphonyDir, "symphony.yaml") + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return fmt.Errorf("no .symphony/symphony.yaml found — run 'symphony init' first") + } + + // Load config + cfg, err := config.LoadSymphonyConfig(configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + if err := config.ValidateSymphonyConfig(cfg); err != nil { + return fmt.Errorf("config validation: %w", err) + } + + // Setup logging — file-only to avoid corrupting TUI + logPath := filepath.Join(symphonyDir, "logs", "orchestrator.jsonl") + logger, logFile, err := logging.SetupJSONL(logPath, "info") + if err != nil { + return fmt.Errorf("setup logging: %w", err) + } + defer func() { _ = logFile.Close() }() + slog.SetDefault(logger) + + // Setup event log + evtLogPath := filepath.Join(symphonyDir, "state", "events.jsonl") + evtLog, err := engine.NewEventLog(evtLogPath) + if err != nil { + return fmt.Errorf("setup event log: %w", err) + } + + // Create tracker + trk, err := tracker.NewTracker(cfg) + if err != nil { + return fmt.Errorf("create tracker: %w", err) + } + + // Create agent + var agentDep agent.Agent + if runMock { + logger.Info("using mock agent") + agentDep = agentmock.NewSuccessAgent() + } else { + logger.Info("using real agent", "kind", cfg.Agent.Kind) + logDir := filepath.Join(symphonyDir, "logs", "agents") + socketDir := filepath.Join(symphonyDir, "sockets") + switch cfg.Agent.Kind { + case "claude_code": + agentDep = claude.New(claude.Config{ + Binary: cfg.Agent.Command, + Model: cfg.Agent.Claude.Model, + AllowedTools: cfg.Agent.Claude.AllowedTools, + PermissionMode: cfg.Agent.Claude.PermissionProfile, + LogDir: logDir, + SocketDir: socketDir, + }) + case "opencode": + agentDep = agentopencode.New(agentopencode.Config{ + Binary: cfg.Agent.Command, + Model: cfg.Agent.OpenCode.Model, + ConfigFile: cfg.Agent.OpenCode.ConfigFile, + LogDir: logDir, + SocketDir: socketDir, + }) + case "codex": + agentDep = agentcodex.New(agentcodex.Config{ + Binary: cfg.Agent.Command, + ApprovalPolicy: cfg.Agent.Codex.ApprovalPolicy, + LogDir: logDir, + SocketDir: socketDir, + }) + default: + return fmt.Errorf("unknown agent kind: %s", cfg.Agent.Kind) + } + } + + // Verify agent binary is available + if !runMock { + switch cfg.Agent.Kind { + case "opencode": + if err := agentopencode.CheckDependencies(); err != nil { + return fmt.Errorf("agent dependency check: %w", err) + } + case "codex": + if err := agentcodex.CheckDependencies(); err != nil { + return fmt.Errorf("agent dependency check: %w", err) + } + } + } + + // Create workspace manager + wsMgr := workspace.NewManager(workspace.ManagerConfig{ + WorktreeDir: cfg.Workspace.WorktreeDir, + RepoCacheDir: cfg.Workspace.RepoCacheDir, + BranchPrefix: cfg.Git.BranchPrefix, + UseWorktrees: cfg.Git.UseWorktrees, + FetchDepth: cfg.Git.FetchDepth, + Hooks: workspace.HooksConfig{ + AfterCreate: cfg.Hooks.AfterCreate, + BeforeRun: cfg.Hooks.BeforeRun, + AfterRun: cfg.Hooks.AfterRun, + BeforeRemove: cfg.Hooks.BeforeRemove, + TimeoutMs: cfg.Hooks.TimeoutMs, + }, + }) + + // Create prompt router + promptRouter := promptpkg.NewRouter(cfg.PromptRouting, filepath.Join(symphonyDir, "prompts")) + + // Create CodeHost (GitHub) + apiURL := cfg.Auth.GitHub.APIURL + if apiURL == "" { + apiURL = "https://api.github.com" + } + codeHost := codehostgithub.New(apiURL, cfg.Auth.GitHub.Token) + + // Open state store + storePath := filepath.Join(symphonyDir, "state", "symphony.db") + _ = os.MkdirAll(filepath.Dir(storePath), 0755) + store, err := state.Open(storePath) + if err != nil { + return fmt.Errorf("open state store: %w", err) + } + defer func() { _ = store.Close() }() + + eng := engine.New(engine.Deps{ + Config: cfg, + Tracker: trk, + Agent: agentDep, + CodeHost: codeHost, + Store: store, + Workspace: wsMgr, + PromptRouter: promptRouter, + EventLog: evtLog, + Logger: logger, + }) + + // Signal handling + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + logger.Info("symphony starting", + "tracker", cfg.Tracker.Kind, + "agent", cfg.Agent.Kind, + "max_concurrent", cfg.Agent.MaxConcurrent, + "poll_interval_ms", cfg.Polling.IntervalMs, + ) + + // Start HTTP API server + if cfg.Server.Port > 0 { + apiServer := server.NewAPIServer(eng, server.APIServerConfig{ + WebhookSecret: cfg.Auth.GitHub.WebhookSecret, + }) + addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) + httpServer := &http.Server{Addr: addr, Handler: apiServer.Handler()} + go func() { + logger.Info("HTTP API listening", "addr", addr) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("HTTP server error", "error", err) + } + }() + defer func() { _ = httpServer.Close() }() + } + + // Run engine in background goroutine + engineDone := make(chan error, 1) + go func() { + engineDone <- eng.Run(ctx) + }() + + // Launch TUI in main goroutine + tuiModel := tui.New(tui.Config{ + Engine: eng, + StartedAt: time.Now(), + LogDir: filepath.Join(symphonyDir, "logs"), + }) + + p := tea.NewProgram(tuiModel, tea.WithAltScreen()) + + // Handle signals — cancel engine which will cause TUI to show shutdown + go func() { + select { + case sig := <-sigCh: + logger.Info("received signal", "signal", sig) + cancel() + // Give engine a moment to shut down, then quit TUI + time.Sleep(500 * time.Millisecond) + p.Send(tea.Quit()) + case <-ctx.Done(): + } + }() + + // Run TUI — blocks until user quits + if _, err := p.Run(); err != nil { + // TUI error is non-fatal, engine may still be running + logger.Error("TUI error", "error", err) + } + + // TUI exited — shut down engine + cancel() + + // Wait for engine to finish + select { + case err := <-engineDone: + if err != nil && err != context.Canceled { + return err + } + case <-time.After(5 * time.Second): + logger.Warn("engine shutdown timed out") + } + + return nil +} diff --git a/cmd/symphony/status.go b/cmd/symphony/status.go new file mode 100644 index 0000000..ebc1793 --- /dev/null +++ b/cmd/symphony/status.go @@ -0,0 +1,79 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Show current orchestrator state (non-interactive)", + Long: `Reads the latest event log and state to show a one-shot status dump in JSON format.`, + RunE: runStatus, +} + +func init() { + rootCmd.AddCommand(statusCmd) +} + +func runStatus(cmd *cobra.Command, args []string) error { + cwd, _ := os.Getwd() + eventsPath := filepath.Join(cwd, ".symphony", "state", "events.jsonl") + + if _, err := os.Stat(eventsPath); os.IsNotExist(err) { + fmt.Println("No state found. Is Symphony running?") + return nil + } + + // Read last few events to show recent state + data, err := os.ReadFile(eventsPath) + if err != nil { + return fmt.Errorf("read events: %w", err) + } + + // Parse events + var events []map[string]any + for _, line := range splitLines(string(data)) { + if line == "" { + continue + } + var evt map[string]any + if err := json.Unmarshal([]byte(line), &evt); err == nil { + events = append(events, evt) + } + } + + // Show last 20 events + start := 0 + if len(events) > 20 { + start = len(events) - 20 + } + + status := map[string]any{ + "total_events": len(events), + "recent": events[start:], + } + + out, _ := json.MarshalIndent(status, "", " ") + fmt.Println(string(out)) + return nil +} + +func splitLines(s string) []string { + var lines []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + lines = append(lines, s[start:i]) + start = i + 1 + } + } + if start < len(s) { + lines = append(lines, s[start:]) + } + return lines +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..9ea8b3d --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,129 @@ +# Architecture + +## Overview + +Symphony has three adapter layers connected by a central engine: + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Tracker │ │ Agent │ │ CodeHost │ +│(GitHub/ │ │(Claude/ │ │(GitHub) │ +│ Linear) │ │ Mock) │ │ │ +└────┬─────┘ └────┬─────┘ └────┬─────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────┐ +│ Engine (Event Loop) │ +│ Single goroutine owns all mutable state │ +│ No mutexes — events processed sequentially │ +└─────────────────────────────────────────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ + FSM Event Log State Store TUI + (domain) (events.jsonl) (bbolt) (Bubble Tea) +``` + +## FSM (Finite State Machine) + +Every work item has exactly one state. Transitions are enforced by a declarative table in `internal/domain/fsm.go`: + +``` +open ──claim──> queued ──dispatch──> preparing ──workspace_ready──> running + ▲ │ + │ error agent_exited_ │ + │ (has retries) with_commits │ + │ │ │ + │ ▼ │ + │ completed │ + │ │ │ + │ pr_created │ + │ │ │ + │ ▼ │ + │ handed_off │ + │ │ + │ agent_exited_no_commits / │ + │ stall_detected / │ + │ budget_exceeded │ + │ │ │ + │ ▼ │ + └──retry_manual── needs_human <──────────┘ +``` + +Guard functions control conditional transitions (e.g., `running → queued` requires `has_retries_left`). + +## Engine Event Loop + +The engine processes events sequentially through a single goroutine: + +```go +for event := range eventCh { + switch event.Type { + case EvtPollTick: handlePollTick(ctx) // fetch + dispatch + stall + reconcile + case EvtAgentExited: handleAgentExited(event) // FSM transition + handoff or retry + case EvtAgentUpdate: handleAgentUpdate(event) // token tracking + budget check + case EvtPauseRequested: handlePause(event) // set pause flag + case EvtRetryDue: handleRetryDue(event) // re-dispatch from retry queue + // ... 21 event types total + } +} +``` + +No mutexes are needed because only this goroutine reads/writes the state. + +## Adapter Pattern + +### Tracker (`internal/tracker/tracker.go`) +- `FetchCandidates(ctx)` — poll for dispatchable items +- `FetchStates(ctx, ids)` — refresh running items +- `ValidateConfig(ctx, input)` — check configured fields exist +- Implementations: `tracker/github/`, `tracker/linear/`, `tracker/mock/` + +### Agent (`internal/agent/agent.go`) +- `Start(ctx, config) → *Session` — launch agent process +- Session provides `Updates` channel (progress) and `Done` channel (result) +- Implementations: `agent/claude/`, `agent/mock/` + +### CodeHost (`internal/codehost/codehost.go`) +- `UpsertPR(ctx, params)` — create or update pull request +- `UpdateProjectStatus(ctx, params)` — move project item status +- `CommentOnItem(ctx, ref, body)` — post comment +- Implementation: `codehost/github/` + +## Event Log + +Every FSM transition is appended to `.symphony/state/events.jsonl`: + +```json +{"timestamp":"2026-03-27T14:32:01Z","item_id":"42","from":"open","to":"queued","event":"claim"} +{"timestamp":"2026-03-27T14:32:01Z","item_id":"42","from":"queued","to":"preparing","event":"dispatch"} +``` + +Query with `symphony events --item 42`. + +## State Persistence + +bbolt store at `.symphony/state/symphony.db` persists: +- **Handoffs** — prevents re-dispatch across restarts +- **Retries** — preserves retry queue with attempt counts +- **Totals** — lifetime token/cost/session counters + +Restored on startup, persisted on shutdown and on each handoff. + +## Eligibility Rules + +Before dispatch, each item passes 13 eligibility checks: +1. Has project item ID and title +2. Dependency data complete (not Pass2Failed) +3. Content type executable (issue, not PR) +4. Project status in active values +5. Not in terminal or blocked status +6. Issue backing required (not draft) +7. Issue state is open +8. Repo in allowlist / not in denylist +9. Required labels present +10. Not already claimed/running/handed off +11. Global concurrency slots available +12. Per-status concurrency limit OK +13. Per-repo concurrency limit OK +14. No open blocking dependencies +15. No open sub-issues (dispatch children instead) diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..6983671 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,139 @@ +# Configuration Reference + +Symphony is configured via `.symphony/symphony.yaml`. Run `symphony init` to generate a template. + +## `tracker` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `kind` | string | *required* | `"github"` or `"linear"` | +| `owner` | string | *required* | GitHub org/user login | +| `project_number` | int | *required* | GitHub Project V2 number | +| `project_scope` | string | `"organization"` | `"organization"` or `"user"` | +| `status_field_name` | string | `"Status"` | Project field for dispatch decisions | +| `active_values` | []string | `[Todo, Ready, In Progress]` | Statuses eligible for dispatch | +| `terminal_values` | []string | `[Done, Closed, Cancelled, ...]` | Statuses that stop work | +| `blocked_values` | []string | `[]` | Statuses treated as blocked | +| `priority_field_name` | string | `"Priority"` | Field for priority sorting | +| `priority_value_map` | map | `{}` | Map field values to numeric priority (`{P0: 0, P1: 1}`) | +| `executable_item_types` | []string | `["issue"]` | Item types to dispatch | +| `require_issue_backing` | bool | `true` | Require real issue (not draft) | +| `repo_allowlist` | []string | `[]` | Only dispatch from these repos | +| `repo_denylist` | []string | `[]` | Never dispatch from these repos | +| `required_labels` | []string | `[]` | All must be present on issue | + +## `auth` + +### `auth.github` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `mode` | string | `"auto"` | `"pat"`, `"app"`, or `"auto"` | +| `token` | string | | PAT or `$GITHUB_TOKEN` | +| `api_url` | string | `"https://api.github.com"` | GitHub API base URL | +| `app_id` | string | | GitHub App ID | +| `private_key` | string | | GitHub App private key | +| `installation_id` | string | | GitHub App installation ID | +| `webhook_secret` | string | | Webhook signature verification | + +### `auth.linear` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `api_key` | string | | Linear API key or `$LINEAR_API_KEY` | + +## `agent` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `kind` | string | `"claude_code"` | `"claude_code"`, `"opencode"`, or `"codex"` | +| `command` | string | auto-detect | Override binary path | +| `max_concurrent` | int | `10` | Global agent concurrency limit | +| `max_turns` | int | `20` | Max turns per agent session | +| `stall_timeout_ms` | int | `300000` | No activity → kill (0 to disable) | +| `max_retry_backoff_ms` | int | `300000` | Max retry delay cap | +| `max_continuation_retries` | int | `10` | Max retries before `failed` | +| `session_reuse` | bool | `true` | Resume previous session on retry | +| `max_concurrent_by_status` | map | `{}` | Per-status concurrency limits | +| `max_concurrent_by_repo` | map | `{}` | Per-repo concurrency limits | + +### `agent.budget` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `max_cost_per_item_usd` | float | `0` | Per-item cost cap (0 = no limit) | +| `max_cost_total_usd` | float | `0` | Global cost cap | +| `max_tokens_per_item` | int | `0` | Per-item token cap | + +### `agent.claude` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `model` | string | | Model override (e.g., `"sonnet"`, `"opus"`) | +| `permission_profile` | string | | e.g., `"bypassPermissions"` | +| `allowed_tools` | []string | `[]` | Restrict tools (empty = all) | + +## `git` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `branch_prefix` | string | `"symphony/"` | Work branch prefix | +| `fetch_depth` | int | `0` | Clone depth (0 = full) | +| `use_worktrees` | bool | `true` | Use git worktrees (faster) | +| `push_remote` | string | `"origin"` | Remote to push branches | +| `author_name` | string | `"Symphony"` | Git commit author | +| `author_email` | string | `"symphony@noreply.github.com"` | Git commit email | + +## `polling` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `interval_ms` | int | `30000` | Poll interval in milliseconds | + +## `pull_request` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `open_on_success` | bool | `true` | Create PR when agent has commits | +| `draft_by_default` | bool | `true` | Create PRs as draft | +| `reuse_existing` | bool | `true` | Update existing PR instead of creating new | +| `handoff_status` | string | | Project status to set on PR creation | +| `comment_on_issue` | bool | `true` | Comment on issue with PR link | +| `required_checks` | []string | `[]` | CI checks required before handoff | + +## `hooks` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `after_create` | string | | Script to run after workspace created | +| `before_run` | string | | Script to run before agent starts | +| `after_run` | string | | Script to run after agent exits | +| `before_remove` | string | | Script to run before workspace removed | +| `timeout_ms` | int | `60000` | Hook timeout | + +## `prompt_routing` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `field_name` | string | | GitHub Project custom field to route on | +| `routes` | map | `{}` | Field value → template file mapping | +| `default` | string | `"default.md"` | Fallback template | + +## `server` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `port` | int | `9097` | HTTP API port (0 to disable) | +| `host` | string | `"0.0.0.0"` | HTTP API bind address | + +## Environment Variables + +Any string value can reference an environment variable using `$VAR` syntax: + +```yaml +auth: + github: + token: $GITHUB_TOKEN +``` + +The variable is resolved at config load time. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..3575c87 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,88 @@ +# Getting Started + +## Prerequisites + +- **Go 1.22+** — [install](https://go.dev/doc/install) +- **git** — available on PATH +- **Claude Code CLI** — `npm install -g @anthropic-ai/claude-code` +- **GitHub token** — with scopes: `repo`, `project`, `read:org` + +## Build + +```bash +git clone https://github.com/shivamstaq/github-symphony +cd github-symphony +go build -o symphony ./cmd/symphony/ +``` + +## Prepare a GitHub Project + +1. Create a GitHub Project V2 on your org or user account +2. Add a **Status** field (single select) with options: `Todo`, `In Progress`, `Human Review`, `Done` +3. Create 1-2 test issues in a repository +4. Add them to the project, set status to `Todo` + +## Initialize + +```bash +cd /path/to/your-repo +/path/to/symphony init +``` + +The wizard will ask for: +- **Tracker type**: `github` or `linear` +- **Owner**: GitHub org or username +- **Project number**: from the project URL (`/projects/42` → `42`) +- **Token**: `$GITHUB_TOKEN` if set, or paste directly +- **Agent**: `claude_code` +- **Max concurrent**: start with `2` + +This creates `.symphony/` with config, prompts, and state directories. + +## Configure + +```bash +export GITHUB_TOKEN=ghp_your_token_here +``` + +Review and edit `.symphony/symphony.yaml` if needed. Key settings: +- `pull_request.handoff_status: Human Review` — where items go after PR creation +- `agent.max_turns: 20` — max agent turns per session +- `agent.budget.max_tokens_per_item: 500000` — token budget per issue + +## Validate + +```bash +/path/to/symphony doctor +``` + +All checks should show `ok`. Fix any `FAIL` items before running. + +## Run + +```bash +/path/to/symphony run +``` + +The TUI shows: +- Running agents with phase, tokens, elapsed time +- Retry queue with due times +- Dispatch/handoff/error counters + +Press `l` for logs, `Enter` for agent detail, `q` to quit. + +## What Happens + +1. Symphony polls your GitHub Project every 30s +2. Issues with status `Todo` are dispatched to agents +3. Each agent gets an isolated git workspace (clone/worktree) +4. Agent works on the issue using Claude Code +5. On completion with commits: branch pushed, draft PR created, project status → `Human Review` +6. On failure: retried with exponential backoff (10s → 20s → 40s...) +7. On no progress: escalated to `needs_human` (no wasteful retries) + +## Next Steps + +- [Configuration Reference](configuration.md) — all symphony.yaml fields +- [Architecture](architecture.md) — how the FSM and engine work +- [Testing](testing.md) — run and write tests diff --git a/docs/testing-e2e.md b/docs/testing-e2e.md new file mode 100644 index 0000000..62f5e04 --- /dev/null +++ b/docs/testing-e2e.md @@ -0,0 +1,180 @@ +# End-to-End Testing Guide + +Complete instructions for testing Symphony on a new repository. + +## Prerequisites + +- Go 1.22+ +- git +- Claude Code CLI: `npm install -g @anthropic-ai/claude-code` +- GitHub token with scopes: `repo`, `project`, `read:org` +- A GitHub repository you can create branches/PRs on + +## Step 1: Build + +```bash +git clone https://github.com/shivamstaq/github-symphony +cd github-symphony +go build -o symphony ./cmd/symphony/ +``` + +## Step 2: Prepare a Test GitHub Project + +1. Go to your GitHub org/user → **Projects** → **New Project** +2. Add a **Status** field (single select) with options: + - `Todo` + - `In Progress` + - `Human Review` + - `Done` +3. Create 2-3 test issues in a test repository (simple issues like "Add a README section") +4. Add them to the project, set status to **Todo** + +## Step 3: Initialize Symphony + +```bash +cd /path/to/your/test-repo +/path/to/symphony init +``` + +Answer the prompts: +- Tracker: `github` +- Owner: your org or username +- Project number: from the project URL +- Token: `$GITHUB_TOKEN` (if set in env) +- Agent: `claude_code` +- Max concurrent: `2` + +## Step 4: Configure + +```bash +export GITHUB_TOKEN=ghp_your_token_here +cat .symphony/symphony.yaml +``` + +Verify the config looks correct. Optionally edit: +```yaml +pull_request: + handoff_status: Human Review # must match a project status option +agent: + max_turns: 10 # lower for testing + budget: + max_tokens_per_item: 100000 # cap for safety +``` + +## Step 5: Validate + +```bash +/path/to/symphony doctor +``` + +Expected: +``` +Symphony Doctor +=============== + ok .symphony/ directory exists + ok symphony.yaml found + ok symphony.yaml parsed successfully + ok config validation passed + ok prompts/ directory exists + ok state/ directory exists + ok default prompt template: default.md + ok agent binary: /usr/local/bin/claude + ok git available + ok GitHub token configured + +Tracker: github | Agent: claude_code | Max concurrent: 2 +``` + +## Step 6: Run + +```bash +/path/to/symphony run +``` + +The TUI launches showing: +- Agent count, uptime, and metrics +- Running agents with phase, tokens, elapsed time +- Retry queue (if any) + +## Step 7: Verify Dispatch + +Watch the TUI: +1. After the first poll (30s), eligible issues appear as running agents +2. Each agent shows its phase: `preparing` → `running` +3. Token count increases as the agent works + +## Step 8: Verify PR Creation + +Check GitHub: +1. A branch named `symphony/owner_repo_N` should be pushed +2. A draft PR should be created +3. A comment on the issue links to the PR +4. The project status should move to `Human Review` + +## Step 9: Test Error Paths + +### Stall Detection +```yaml +# In symphony.yaml, set: +agent: + stall_timeout_ms: 60000 # 1 minute +``` +Kill a `claude` process manually while it's running. Watch the TUI — the agent should be detected as stalled and transition to `needs_human`. + +### Budget Exceeded +```yaml +agent: + budget: + max_tokens_per_item: 50000 +``` +Run an issue that requires more work. When tokens exceed the limit, the agent stops and the item transitions to `needs_human`. + +### Reconciliation +While an agent is running, close the issue on GitHub. On the next poll, Symphony detects the closure and cancels the agent. + +## Step 10: Inspect Events + +```bash +/path/to/symphony events +``` + +Output shows the full FSM audit trail: +``` +14:32:01 org/repo#42 open -> queued [claim] +14:32:01 org/repo#42 queued -> preparing [dispatch] +14:32:02 org/repo#42 preparing -> running [workspace_ready] +14:35:12 org/repo#42 running -> completed [agent_exited_with_commits] +14:35:13 org/repo#42 completed -> handed_off [pr_created] +``` + +## Step 11: Test Mock Mode + +```bash +/path/to/symphony run --mock +``` + +Uses a mock agent that simulates successful completion with commits. Useful for testing the orchestration logic without consuming API tokens. + +## Step 12: Cleanup + +```bash +# Remove symphony state +rm -rf .symphony/ + +# Clean up test branches +git push origin --delete symphony/owner_repo_1 symphony/owner_repo_2 + +# Close test PRs on GitHub +``` + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| `no .symphony/symphony.yaml found` | Run `symphony init` first | +| `tracker.kind is required` | Edit symphony.yaml, add `tracker.kind: github` | +| `no credentials found` | Set `$GITHUB_TOKEN` or configure `auth.github.token` | +| `agent binary not found` | Install Claude Code: `npm i -g @anthropic-ai/claude-code` | +| TUI shows no agents | Check poll interval, verify issues are in `Todo` status | +| Agent exits with no commits | Check issue description — may be too vague for agent | +| PR not created | Check `pull_request.open_on_success: true` in config | diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..c251352 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,95 @@ +# Testing + +## Run All Tests + +```bash +go test ./... -count=1 +``` + +## Test Categories + +### FSM Property Tests (`test/property/`) + +```bash +go test ./test/property/ -v +``` + +Generates 55,000 random event sequences and verifies invariants: +- No panics on any sequence +- Items always in exactly one valid state +- `needs_human` only reachable via expected events +- `handed_off` only reachable via `pr_created` +- Terminal states only exit via specific events +- Event log replay produces identical final state + +### Scenario Tests (`test/scenario/`) + +```bash +go test ./test/scenario/ -v +``` + +7 named scenarios testing full engine cycles: + +| Scenario | What It Tests | +|----------|---------------| +| HappyPath | dispatch → agent completes → PR → handed_off | +| RetryExhaustion | agent fails → retry queued with backoff | +| NoCommitsEscalation | agent exits without commits → needs_human (NOT retry) | +| StallRecovery | no activity beyond timeout → needs_human, worker killed | +| ReconcileClosed | issue closed externally → agent cancelled | +| BudgetExceeded | tokens over limit → needs_human | +| HandedOffNotRedispatched | second poll does NOT re-dispatch handed-off item | + +### Tracker Contract Tests (`internal/tracker/`) + +```bash +go test ./internal/tracker/ -v +``` + +Shared behavioral tests that any tracker implementation must satisfy: +- FetchCandidates returns items with WorkItemID and Title +- FetchStates returns matching items, empty for unknown IDs +- ValidateConfig returns no error for valid input + +### Unit Tests + +Colocated in each package: +- `internal/domain/fsm_test.go` — 22 valid transitions, 12 invalid, invariants +- `internal/config/symphony_config_test.go` — parsing, defaults, env vars, validation +- `internal/engine/*_test.go` — eligibility, retry backoff, stall, budget, reconcile, handoff +- `internal/prompt/router_test.go` — field-based routing, case insensitivity +- `internal/tracker/linear/normalize_test.go` — Linear → domain.WorkItem conversion + +## Writing New Tests + +### Add a Scenario Test + +```go +// test/scenario/my_test.go +func TestScenario_MyCase(t *testing.T) { + item := makeItem("100", 100) + h := NewHarness(t, HarnessConfig{ + Items: []domain.WorkItem{item}, + Agent: agentmock.NewSuccessAgent(), // or NewFailAgent, NewNoCommitsAgent + }) + defer h.Cleanup() + + h.PollOnce() + h.WaitForState("100", domain.StateHandedOff, 3*time.Second) + h.AssertState("100", domain.StateHandedOff) +} +``` + +### Add a FSM Transition + +1. Add the transition to `transitionTable` in `internal/domain/fsm.go` +2. Add a test case in `TestTransition_ValidTransitions` +3. Run `go test ./test/property/` — property tests will validate invariants +4. Update `AllEvents` if you added a new event type + +### Add a Tracker Implementation + +1. Create `internal/tracker/KIND/source.go` implementing `tracker.Tracker` +2. Create `internal/tracker/KIND/register.go` with `init()` calling `tracker.Register()` +3. Add contract test: run `contractSuite(t, "kind", yourTracker)` in `contract_test.go` +4. Add blank import in `cmd/symphony/run.go`: `_ "internal/tracker/KIND"` diff --git a/go.mod b/go.mod index 3df2b73..ba60bb8 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,11 @@ go 1.26.1 require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 - github.com/fsnotify/fsnotify v1.9.0 + github.com/creack/pty v1.1.24 github.com/go-chi/chi/v5 v5.2.5 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 + github.com/spf13/cobra v1.10.2 go.etcd.io/bbolt v1.4.3 gopkg.in/yaml.v3 v3.0.1 ) @@ -23,6 +24,7 @@ require ( github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -31,6 +33,7 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.3.8 // indirect diff --git a/go.sum b/go.sum index 791b527..d8d9f65 100644 --- a/go.sum +++ b/go.sum @@ -18,16 +18,19 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -48,12 +51,18 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= diff --git a/internal/adapter/adapter_test.go b/internal/adapter/adapter_test.go deleted file mode 100644 index 9555884..0000000 --- a/internal/adapter/adapter_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package adapter_test - -import ( - "context" - "testing" - - "github.com/shivamstaq/github-symphony/internal/adapter" -) - -func TestSubprocessAdapter_EchoRoundTrip(t *testing.T) { - // Launch a simple echo adapter: reads JSON line from stdin, writes it back to stdout - // We use a bash one-liner that reads a line and echoes back a valid response - script := `while IFS= read -r line; do echo '{"id":1,"result":{"protocolVersion":1,"provider":"test","capabilities":{}}}'; done` - - a, err := adapter.NewSubprocessAdapter(adapter.SubprocessConfig{ - Command: "bash", - Args: []string{"-c", script}, - }) - if err != nil { - t.Fatalf("failed to create adapter: %v", err) - } - defer a.Close() - - // Send initialize - resp, err := a.SendRequest(context.Background(), adapter.Request{ - ID: 1, - Method: "initialize", - Params: map[string]any{ - "protocolVersion": 1, - }, - }) - if err != nil { - t.Fatalf("SendRequest failed: %v", err) - } - - if resp.ID != 1 { - t.Errorf("expected id=1, got %d", resp.ID) - } - if resp.Result == nil { - t.Fatal("expected non-nil result") - } -} - -func TestSubprocessAdapter_ProcessExitDetected(t *testing.T) { - // Adapter that exits immediately - a, err := adapter.NewSubprocessAdapter(adapter.SubprocessConfig{ - Command: "bash", - Args: []string{"-c", "exit 0"}, - }) - if err != nil { - t.Fatalf("failed to create adapter: %v", err) - } - defer a.Close() - - _, err = a.SendRequest(context.Background(), adapter.Request{ - ID: 1, - Method: "initialize", - }) - if err == nil { - t.Fatal("expected error when process exits") - } -} diff --git a/internal/adapter/claude_cli.go b/internal/adapter/claude_cli.go deleted file mode 100644 index bc5b725..0000000 --- a/internal/adapter/claude_cli.go +++ /dev/null @@ -1,282 +0,0 @@ -package adapter - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - "os" - "os/exec" - "strings" - "sync" - - "github.com/google/uuid" -) - -// ClaudeCLI implements AdapterClient by invoking the `claude` CLI in print mode. -// Each Prompt() call runs: claude -p --output-format json --permission-mode -// The CLI inherits the parent process environment (including ANTHROPIC_API_KEY if set) -// and uses locally-authenticated credentials (~/.claude). -type ClaudeCLI struct { - binary string - model string - tools []string - permMode string - cwd string // workspace directory - resumeID string // Claude session ID to resume from - mu sync.Mutex - proc *os.Process // current running process for cancellation - updates chan *Message -} - -// ClaudeCLIConfig configures the Claude CLI adapter. -type ClaudeCLIConfig struct { - Binary string // path to claude binary (default: "claude") - Model string // model override (e.g., "sonnet", "opus") - AllowedTools []string // restrict tools (e.g., ["Read", "Edit", "Bash"]) - PermissionMode string // "bypassPermissions", "default", etc. - Cwd string // workspace directory for command execution -} - -// CLIResult is the parsed JSON output from claude -p --output-format json. -type CLIResult struct { - Type string `json:"type"` - Subtype string `json:"subtype"` - IsError bool `json:"is_error"` - Result string `json:"result"` - StopReason string `json:"stop_reason"` - NumTurns int `json:"num_turns"` - SessionID string `json:"session_id"` - DurationMs int `json:"duration_ms"` - DurationAPIMs int `json:"duration_api_ms"` - TotalCostUSD float64 `json:"total_cost_usd"` - Usage map[string]any `json:"usage"` - PermissionDenials []any `json:"permission_denials"` -} - -// NewClaudeCLI creates a Claude CLI adapter. -func NewClaudeCLI(cfg ClaudeCLIConfig) *ClaudeCLI { - binary := cfg.Binary - if binary == "" { - binary = "claude" - } - permMode := cfg.PermissionMode - if permMode == "" { - permMode = "bypassPermissions" - } - return &ClaudeCLI{ - binary: binary, - model: cfg.Model, - tools: cfg.AllowedTools, - permMode: permMode, - cwd: cfg.Cwd, - updates: make(chan *Message, 10), - } -} - -func (c *ClaudeCLI) Initialize(_ context.Context) (*InitResult, error) { - // Verify claude binary exists on PATH - path, err := exec.LookPath(c.binary) - if err != nil { - return nil, fmt.Errorf("adapter_not_found: claude binary %q not found on PATH: %w", c.binary, err) - } - slog.Info("claude CLI adapter initialized", "binary", path) - return &InitResult{ - Provider: "claude_code", - Capabilities: map[string]any{"sessionReuse": true, "tokenUsage": true}, - }, nil -} - -func (c *ClaudeCLI) NewSession(_ context.Context, params SessionParams) (string, error) { - id := uuid.New().String() - // Store resume session ID for use in Prompt() - if params.ResumeSessionID != "" { - c.mu.Lock() - c.resumeID = params.ResumeSessionID - c.mu.Unlock() - slog.Info("claude CLI session created (resuming)", "session_id", id, "resume_from", params.ResumeSessionID, "cwd", params.Cwd) - } else { - slog.Info("claude CLI session created", "session_id", id, "cwd", params.Cwd) - } - return id, nil -} - -func (c *ClaudeCLI) Prompt(ctx context.Context, sessionID string, text string) (*PromptResult, error) { - args := []string{"-p", "--output-format", "json"} - - // Resume previous session if available (gives Claude memory across turns) - c.mu.Lock() - resumeFrom := c.resumeID - c.mu.Unlock() - if resumeFrom != "" { - args = append(args, "--resume", resumeFrom) - } - - if c.permMode != "" { - args = append(args, "--permission-mode", c.permMode) - } - if c.model != "" { - args = append(args, "--model", c.model) - } - if len(c.tools) > 0 { - args = append(args, "--allowed-tools", strings.Join(c.tools, ",")) - } - - cmd := exec.CommandContext(ctx, c.binary, args...) - cmd.Dir = c.cwd - cmd.Stdin = strings.NewReader(text) - cmd.Env = os.Environ() - - // Capture stdout via pipe so we can track the process for cancellation - stdout, err := cmd.StdoutPipe() - if err != nil { - return &PromptResult{StopReason: StopFailed, Summary: fmt.Sprintf("stdout pipe: %v", err)}, nil - } - - slog.Info("claude CLI executing", - "session_id", sessionID, - "model", c.model, - "perm_mode", c.permMode, - "prompt_len", len(text), - ) - - if err := cmd.Start(); err != nil { - return &PromptResult{StopReason: StopFailed, Summary: fmt.Sprintf("start: %v", err)}, nil - } - - // Track the process for cancellation AFTER start - c.mu.Lock() - c.proc = cmd.Process - c.mu.Unlock() - - // Read all output - output, readErr := io.ReadAll(stdout) - - // Wait for process to finish - waitErr := cmd.Wait() - - c.mu.Lock() - c.proc = nil - c.mu.Unlock() - - if readErr != nil { - slog.Error("claude CLI read failed", "session_id", sessionID, "error", readErr) - return &PromptResult{StopReason: StopFailed, Summary: fmt.Sprintf("read: %v", readErr)}, nil - } - - if waitErr != nil { - var stderr string - if exitErr, ok := waitErr.(*exec.ExitError); ok { - stderr = string(exitErr.Stderr) - } - slog.Error("claude CLI failed", - "session_id", sessionID, - "error", waitErr, - "stderr", stderr, - ) - return &PromptResult{ - StopReason: StopFailed, - Summary: fmt.Sprintf("claude CLI error: %v\n%s", waitErr, stderr), - }, nil - } - - // Parse JSON result - var result CLIResult - if err := json.Unmarshal(output, &result); err != nil { - slog.Error("claude CLI output parse failed", - "session_id", sessionID, - "output_len", len(output), - "error", err, - ) - return &PromptResult{ - StopReason: StopFailed, - Summary: fmt.Sprintf("failed to parse claude output: %v", err), - }, nil - } - - slog.Info("claude CLI completed", - "session_id", sessionID, - "stop_reason", result.StopReason, - "num_turns", result.NumTurns, - "cost_usd", result.TotalCostUSD, - "duration_ms", result.DurationMs, - "is_error", result.IsError, - ) - - // Store session ID for future resumption - if result.SessionID != "" { - c.mu.Lock() - c.resumeID = result.SessionID - c.mu.Unlock() - } - - // Map stop reason - stopReason := mapCLIStopReason(result) - - return &PromptResult{ - StopReason: stopReason, - Summary: truncate(result.Result, 500), - SessionID: result.SessionID, - CostUSD: result.TotalCostUSD, - NumTurns: result.NumTurns, - DurationMs: result.DurationMs, - }, nil -} - -func (c *ClaudeCLI) Cancel(_ context.Context, _ string) error { - c.mu.Lock() - proc := c.proc - c.mu.Unlock() - if proc != nil { - return proc.Kill() - } - return nil -} - -func (c *ClaudeCLI) CloseSession(_ context.Context, _ string) error { - return nil // no-op for CLI mode -} - -func (c *ClaudeCLI) Close() error { - return nil // no-op -} - -func (c *ClaudeCLI) Updates() <-chan *Message { - return c.updates -} - -func (c *ClaudeCLI) PID() int { - c.mu.Lock() - defer c.mu.Unlock() - if c.proc != nil { - return c.proc.Pid - } - return 0 -} - -func mapCLIStopReason(result CLIResult) StopReason { - if result.IsError { - return StopFailed - } - switch result.StopReason { - case "end_turn": - return StopCompleted - case "stop_sequence": - return StopCompleted - case "max_tokens": - return StopCompleted - default: - if result.Subtype == "error" { - return StopFailed - } - return StopCompleted - } -} - -func truncate(s string, max int) string { - if len(s) <= max { - return s - } - return s[:max] + "..." -} diff --git a/internal/adapter/factory.go b/internal/adapter/factory.go deleted file mode 100644 index ab12684..0000000 --- a/internal/adapter/factory.go +++ /dev/null @@ -1,174 +0,0 @@ -package adapter - -import ( - "context" - "fmt" - "os" -) - -// NewAdapter creates an adapter client for the given agent kind. -func NewAdapter(cfg AdapterConfig) (AdapterClient, error) { - // Validate CWD exists if specified - if cfg.Cwd != "" { - if info, err := os.Stat(cfg.Cwd); err != nil || !info.IsDir() { - return nil, fmt.Errorf("invalid_workspace_cwd: %q is not a valid directory", cfg.Cwd) - } - } - - switch cfg.Kind { - case "claude_code": - return NewClaudeCLI(ClaudeCLIConfig{ - Binary: cfg.Command, - Model: cfg.Model, - AllowedTools: cfg.AllowedTools, - PermissionMode: cfg.PermissionMode, - Cwd: cfg.Cwd, - }), nil - case "opencode": - return newGenericAdapter(cfg, "opencode") - case "codex": - return newGenericAdapter(cfg, "codex") - default: - return nil, fmt.Errorf("adapter_not_found: unsupported agent kind %q", cfg.Kind) - } -} - -// genericAdapter wraps SubprocessAdapter with the unified AdapterClient interface. -// Used for OpenCode and Codex which speak JSON-RPC over stdio. -type genericAdapter struct { - sub *SubprocessAdapter - provider string - idSeq int -} - -func newGenericAdapter(cfg AdapterConfig, provider string) (*genericAdapter, error) { - sub, err := NewSubprocessAdapter(SubprocessConfig{ - Command: cfg.Command, - Args: cfg.Args, - Cwd: cfg.Cwd, - Env: cfg.Env, - }) - if err != nil { - return nil, fmt.Errorf("adapter %s: %w", provider, err) - } - return &genericAdapter{sub: sub, provider: provider}, nil -} - -func (a *genericAdapter) allocID() int { - a.idSeq++ - return a.idSeq -} - -func (a *genericAdapter) Initialize(ctx context.Context) (*InitResult, error) { - resp, err := a.sub.SendRequest(ctx, Request{ - ID: a.allocID(), - Method: "initialize", - Params: map[string]any{ - "protocolVersion": 1, - "clientInfo": map[string]any{"name": "symphony", "version": "3.0"}, - "requestedProvider": a.provider, - "clientCapabilities": map[string]any{ - "toolExecution": true, - "permissionHandling": true, - "userInputHandling": true, - "mcp": true, - "images": false, - "audio": false, - }, - "_meta": map[string]any{ - "traceId": fmt.Sprintf("tr_%s_%d", a.provider, a.idSeq), - }, - }, - }) - if err != nil { - return nil, err - } - result := &InitResult{Provider: getStrFromMap(resp.Result, "provider")} - if caps, ok := resp.Result["capabilities"].(map[string]any); ok { - result.Capabilities = caps - } - return result, nil -} - -func (a *genericAdapter) NewSession(ctx context.Context, params SessionParams) (string, error) { - p := map[string]any{ - "cwd": params.Cwd, - "title": params.Title, - "provider": a.provider, - } - if params.Model != "" { - p["model"] = params.Model - } - if params.ProviderParams != nil { - p["providerParams"] = map[string]any{a.provider: params.ProviderParams} - } - - resp, err := a.sub.SendRequest(ctx, Request{ID: a.allocID(), Method: "session/new", Params: p}) - if err != nil { - return "", err - } - sessionID := getStrFromMap(resp.Result, "sessionId") - if sessionID == "" { - return "", fmt.Errorf("adapter %s: session/new returned empty sessionId", a.provider) - } - return sessionID, nil -} - -func (a *genericAdapter) Prompt(ctx context.Context, sessionID string, text string) (*PromptResult, error) { - resp, err := a.sub.SendRequest(ctx, Request{ - ID: a.allocID(), - Method: "session/prompt", - Params: map[string]any{ - "sessionId": sessionID, - "continuation": false, - "input": []any{map[string]any{"type": "text", "text": text}}, - }, - }) - if err != nil { - return nil, err - } - return &PromptResult{ - StopReason: StopReason(getStrFromMap(resp.Result, "stopReason")), - Summary: getStrFromMap(resp.Result, "summary"), - }, nil -} - -func (a *genericAdapter) Cancel(ctx context.Context, sessionID string) error { - _, err := a.sub.SendRequest(ctx, Request{ - ID: a.allocID(), - Method: "session/cancel", - Params: map[string]any{"sessionId": sessionID}, - }) - return err -} - -func (a *genericAdapter) CloseSession(ctx context.Context, sessionID string) error { - _, err := a.sub.SendRequest(ctx, Request{ - ID: a.allocID(), - Method: "session/close", - Params: map[string]any{"sessionId": sessionID}, - }) - return err -} - -func (a *genericAdapter) Close() error { - return a.sub.Close() -} - -func (a *genericAdapter) Updates() <-chan *Message { - return a.sub.Updates() -} - -func (a *genericAdapter) PID() int { - return a.sub.PID() -} - -func getStrFromMap(m map[string]any, key string) string { - if m == nil { - return "" - } - if v, ok := m[key].(string); ok { - return v - } - return "" -} diff --git a/internal/adapter/factory_test.go b/internal/adapter/factory_test.go deleted file mode 100644 index ed25a48..0000000 --- a/internal/adapter/factory_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package adapter_test - -import ( - "context" - "testing" - - "github.com/shivamstaq/github-symphony/internal/adapter" -) - -const factoryMockServer = ` -while IFS= read -r line; do - id=$(echo "$line" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('id',0))" 2>/dev/null || echo "0") - method=$(echo "$line" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('method',''))" 2>/dev/null || echo "") - case "$method" in - initialize) echo "{\"id\":${id},\"result\":{\"protocolVersion\":1,\"provider\":\"test\",\"capabilities\":{}}}" ;; - session/new) echo "{\"id\":${id},\"result\":{\"sessionId\":\"s1\"}}" ;; - session/prompt) echo "{\"id\":${id},\"result\":{\"stopReason\":\"completed\",\"summary\":\"done\"}}" ;; - session/cancel) echo "{\"id\":${id},\"result\":{\"cancelled\":true}}" ;; - session/close) echo "{\"id\":${id},\"result\":{\"closed\":true}}" ;; - *) echo "{\"id\":${id},\"error\":{\"code\":-1,\"message\":\"unknown\"}}" ;; - esac -done -` - -func TestFactory_CreatesClaudeAdapter(t *testing.T) { - // Claude adapter is now CLI-based, just verify it creates without error - a, err := adapter.NewAdapter(adapter.AdapterConfig{ - Kind: "claude_code", - Command: "echo", // won't actually call claude in test - }) - if err != nil { - t.Fatal(err) - } - defer a.Close() - - // Initialize verifies the binary exists — "echo" exists on PATH - // Note: it will find "echo" not "claude", which is fine for unit test -} - -func TestFactory_CreatesOpenCodeAdapter(t *testing.T) { - a, err := adapter.NewAdapter(adapter.AdapterConfig{ - Kind: "opencode", - Command: "bash", - Args: []string{"-c", factoryMockServer}, - }) - if err != nil { - t.Fatal(err) - } - defer a.Close() - - _, err = a.Initialize(context.Background()) - if err != nil { - t.Fatal(err) - } -} - -func TestFactory_CreatesCodexAdapter(t *testing.T) { - a, err := adapter.NewAdapter(adapter.AdapterConfig{ - Kind: "codex", - Command: "bash", - Args: []string{"-c", factoryMockServer}, - }) - if err != nil { - t.Fatal(err) - } - defer a.Close() - - _, err = a.Initialize(context.Background()) - if err != nil { - t.Fatal(err) - } -} - -func TestFactory_UnsupportedKind(t *testing.T) { - _, err := adapter.NewAdapter(adapter.AdapterConfig{Kind: "unknown_agent"}) - if err == nil { - t.Fatal("expected error for unsupported kind") - } -} - -func TestFactory_InvalidCWD(t *testing.T) { - _, err := adapter.NewAdapter(adapter.AdapterConfig{ - Kind: "opencode", - Command: "bash", - Args: []string{"-c", "echo hi"}, - Cwd: "/nonexistent/path/that/does/not/exist", - }) - if err == nil { - t.Fatal("expected error for invalid CWD") - } -} - -func TestFactory_GenericAdapterFullLifecycle(t *testing.T) { - // Test the generic (subprocess) adapter with opencode kind - a, err := adapter.NewAdapter(adapter.AdapterConfig{ - Kind: "opencode", - Command: "bash", - Args: []string{"-c", factoryMockServer}, - }) - if err != nil { - t.Fatal(err) - } - defer a.Close() - - ctx := context.Background() - - if _, err := a.Initialize(ctx); err != nil { - t.Fatal("Initialize:", err) - } - sid, err := a.NewSession(ctx, adapter.SessionParams{Cwd: "/tmp", Title: "test"}) - if err != nil { - t.Fatal("NewSession:", err) - } - if sid != "s1" { - t.Errorf("expected session s1, got %q", sid) - } - - result, err := a.Prompt(ctx, sid, "do work") - if err != nil { - t.Fatal("Prompt:", err) - } - if result.StopReason != adapter.StopCompleted { - t.Errorf("expected completed, got %q", result.StopReason) - } - - if err := a.Cancel(ctx, sid); err != nil { - t.Fatal("Cancel:", err) - } - if err := a.CloseSession(ctx, sid); err != nil { - t.Fatal("CloseSession:", err) - } -} diff --git a/internal/adapter/pdeathsig_linux.go b/internal/adapter/pdeathsig_linux.go deleted file mode 100644 index bdc2e7d..0000000 --- a/internal/adapter/pdeathsig_linux.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build linux - -package adapter - -import ( - "os/exec" - "syscall" -) - -// setPdeathsig sets the parent-death signal on Linux so the child process -// is killed if the parent dies unexpectedly, preventing orphaned agent processes. -func setPdeathsig(cmd *exec.Cmd) { - if cmd.SysProcAttr == nil { - cmd.SysProcAttr = &syscall.SysProcAttr{} - } - cmd.SysProcAttr.Pdeathsig = syscall.SIGKILL - cmd.SysProcAttr.Setpgid = true -} diff --git a/internal/adapter/pdeathsig_other.go b/internal/adapter/pdeathsig_other.go deleted file mode 100644 index e22f7aa..0000000 --- a/internal/adapter/pdeathsig_other.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build !linux - -package adapter - -import "os/exec" - -// setPdeathsig is a no-op on non-Linux platforms. -func setPdeathsig(_ *exec.Cmd) {} diff --git a/internal/adapter/protocol.go b/internal/adapter/protocol.go deleted file mode 100644 index 3eeee3c..0000000 --- a/internal/adapter/protocol.go +++ /dev/null @@ -1,136 +0,0 @@ -package adapter - -import ( - "bufio" - "encoding/json" - "fmt" - "io" -) - -// StopReason indicates why a prompt turn ended. -type StopReason string - -const ( - StopCompleted StopReason = "completed" - StopFailed StopReason = "failed" - StopCancelled StopReason = "cancelled" - StopTimedOut StopReason = "timed_out" - StopStalled StopReason = "stalled" - StopInputRequired StopReason = "input_required" - StopHandoff StopReason = "handoff" -) - -// UpdateKind identifies the type of streaming update. -type UpdateKind string - -const ( - UpdateSessionStarted UpdateKind = "session_started" - UpdateAssistantText UpdateKind = "assistant_text" - UpdateProgress UpdateKind = "progress" - UpdateToolCallStarted UpdateKind = "tool_call_started" - UpdateToolCallDone UpdateKind = "tool_call_completed" - UpdateToolCallFailed UpdateKind = "tool_call_failed" - UpdateTokenUsage UpdateKind = "token_usage" - UpdateRateLimits UpdateKind = "rate_limits" - UpdateApprovalAutoApproved UpdateKind = "approval_auto_approved" - UpdateApprovalRequested UpdateKind = "approval_requested" - UpdateInputRequested UpdateKind = "input_requested" - UpdateCompleted UpdateKind = "completed" - UpdateFailed UpdateKind = "failed" - UpdateWarning UpdateKind = "warning" - UpdateNotification UpdateKind = "notification" - UpdateMalformed UpdateKind = "malformed" -) - -// Request is a JSON-RPC-style request. -type Request struct { - ID int `json:"id,omitempty"` - Method string `json:"method"` - Params map[string]any `json:"params,omitempty"` -} - -// Message is a JSON-RPC-style response, notification, or request from the adapter. -type Message struct { - ID int `json:"id,omitempty"` - Method string `json:"method,omitempty"` - Result map[string]any `json:"result,omitempty"` - Error *RPCError `json:"error,omitempty"` - Params map[string]any `json:"params,omitempty"` -} - -// RPCError is a JSON-RPC error object. -type RPCError struct { - Code int `json:"code"` - Message string `json:"message"` - Data any `json:"data,omitempty"` -} - -// IsNotification returns true if the message has no ID (notification). -func (m *Message) IsNotification() bool { - return m.ID == 0 && m.Method != "" -} - -// IsResponse returns true if the message has a result or error. -func (m *Message) IsResponse() bool { - return m.ID != 0 && (m.Result != nil || m.Error != nil) -} - -// IsRequest returns true if the message has a method and ID (adapter -> client request). -func (m *Message) IsRequest() bool { - return m.ID != 0 && m.Method != "" -} - -// Encoder writes JSON-RPC messages as newline-delimited JSON to a writer. -type Encoder struct { - w io.Writer -} - -// NewEncoder creates a new protocol encoder. -func NewEncoder(w io.Writer) *Encoder { - return &Encoder{w: w} -} - -// Encode writes a request as a single JSON line. -func (e *Encoder) Encode(req Request) error { - data, err := json.Marshal(req) - if err != nil { - return fmt.Errorf("protocol encode: %w", err) - } - data = append(data, '\n') - _, err = e.w.Write(data) - return err -} - -// Decoder reads JSON-RPC messages from a reader (newline-delimited). -type Decoder struct { - scanner *bufio.Scanner -} - -// NewDecoder creates a new protocol decoder. -func NewDecoder(r io.Reader) *Decoder { - scanner := bufio.NewScanner(r) - scanner.Buffer(make([]byte, 10*1024*1024), 10*1024*1024) // 10MB max line - return &Decoder{scanner: scanner} -} - -// Decode reads the next message. Returns io.EOF when no more messages. -func (d *Decoder) Decode() (*Message, error) { - if !d.scanner.Scan() { - if err := d.scanner.Err(); err != nil { - return nil, fmt.Errorf("protocol decode: %w", err) - } - return nil, io.EOF - } - - line := d.scanner.Bytes() - if len(line) == 0 { - return d.Decode() // skip empty lines - } - - var msg Message - if err := json.Unmarshal(line, &msg); err != nil { - return nil, fmt.Errorf("protocol decode: invalid JSON: %w", err) - } - - return &msg, nil -} diff --git a/internal/adapter/protocol_test.go b/internal/adapter/protocol_test.go deleted file mode 100644 index 0a8ce4a..0000000 --- a/internal/adapter/protocol_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package adapter_test - -import ( - "bytes" - "encoding/json" - "testing" - - "github.com/shivamstaq/github-symphony/internal/adapter" -) - -func TestEncodeRequest(t *testing.T) { - req := adapter.Request{ - ID: 1, - Method: "initialize", - Params: map[string]any{ - "protocolVersion": 1, - "clientInfo": map[string]any{"name": "symphony", "version": "3.0"}, - }, - } - - var buf bytes.Buffer - enc := adapter.NewEncoder(&buf) - if err := enc.Encode(req); err != nil { - t.Fatal(err) - } - - // Should be a single line of JSON followed by newline - line := buf.String() - if line[len(line)-1] != '\n' { - t.Error("expected trailing newline") - } - - var decoded adapter.Request - if err := json.Unmarshal([]byte(line), &decoded); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - if decoded.Method != "initialize" { - t.Errorf("expected method=initialize, got %q", decoded.Method) - } -} - -func TestDecodeResponse(t *testing.T) { - line := `{"id":1,"result":{"protocolVersion":1,"provider":"codex"}}` + "\n" - dec := adapter.NewDecoder(bytes.NewReader([]byte(line))) - - msg, err := dec.Decode() - if err != nil { - t.Fatal(err) - } - - if msg.ID != 1 { - t.Errorf("expected id=1, got %v", msg.ID) - } - if msg.Result == nil { - t.Fatal("expected non-nil result") - } -} - -func TestDecodeNotification(t *testing.T) { - line := `{"method":"session/update","params":{"sessionId":"s1","update":{"kind":"progress"}}}` + "\n" - dec := adapter.NewDecoder(bytes.NewReader([]byte(line))) - - msg, err := dec.Decode() - if err != nil { - t.Fatal(err) - } - - if msg.Method != "session/update" { - t.Errorf("expected method=session/update, got %q", msg.Method) - } - if msg.ID != 0 { - t.Error("notifications should have zero id") - } -} - -func TestStopReasonConstants(t *testing.T) { - // Verify stop reason constants exist and are the right values - reasons := []adapter.StopReason{ - adapter.StopCompleted, - adapter.StopFailed, - adapter.StopCancelled, - adapter.StopTimedOut, - adapter.StopStalled, - adapter.StopInputRequired, - adapter.StopHandoff, - } - - expected := []string{"completed", "failed", "cancelled", "timed_out", "stalled", "input_required", "handoff"} - for i, r := range reasons { - if string(r) != expected[i] { - t.Errorf("stop reason %d: expected %q, got %q", i, expected[i], r) - } - } -} diff --git a/internal/adapter/subprocess.go b/internal/adapter/subprocess.go deleted file mode 100644 index 2cda993..0000000 --- a/internal/adapter/subprocess.go +++ /dev/null @@ -1,222 +0,0 @@ -package adapter - -import ( - "context" - "fmt" - "io" - "log/slog" - "os" - "os/exec" - "sync" -) - -// SubprocessConfig configures a subprocess-based adapter. -type SubprocessConfig struct { - Command string - Args []string - Cwd string - Env []string -} - -// SubprocessAdapter manages a subprocess communicating via JSON-RPC over stdio. -type SubprocessAdapter struct { - cmd *exec.Cmd - stdin io.WriteCloser - stdout io.ReadCloser - stderr io.ReadCloser - enc *Encoder - dec *Decoder - mu sync.Mutex - updates chan *Message - done chan struct{} -} - -// NewSubprocessAdapter starts a subprocess and returns an adapter. -func NewSubprocessAdapter(cfg SubprocessConfig) (*SubprocessAdapter, error) { - cmd := exec.Command(cfg.Command, cfg.Args...) - if cfg.Cwd != "" { - cmd.Dir = cfg.Cwd - } - if len(cfg.Env) > 0 { - cmd.Env = append(os.Environ(), cfg.Env...) - } - - // Set kill-on-parent-exit to prevent orphaned agent processes - setPdeathsig(cmd) - - stdin, err := cmd.StdinPipe() - if err != nil { - return nil, fmt.Errorf("adapter subprocess: stdin pipe: %w", err) - } - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, fmt.Errorf("adapter subprocess: stdout pipe: %w", err) - } - stderr, err := cmd.StderrPipe() - if err != nil { - return nil, fmt.Errorf("adapter subprocess: stderr pipe: %w", err) - } - - if err := cmd.Start(); err != nil { - return nil, fmt.Errorf("adapter subprocess: start: %w", err) - } - - a := &SubprocessAdapter{ - cmd: cmd, - stdin: stdin, - stdout: stdout, - stderr: stderr, - enc: NewEncoder(stdin), - dec: NewDecoder(stdout), - updates: make(chan *Message, 100), - done: make(chan struct{}), - } - - // Drain stderr in background - go a.drainStderr() - - return a, nil -} - -// SendRequest sends a request and waits for the corresponding response. -// Notifications received while waiting are forwarded to the updates channel. -func (a *SubprocessAdapter) SendRequest(ctx context.Context, req Request) (*Message, error) { - a.mu.Lock() - defer a.mu.Unlock() - - if err := a.enc.Encode(req); err != nil { - return nil, fmt.Errorf("adapter send: %w", err) - } - - for { - select { - case <-ctx.Done(): - return nil, ctx.Err() - default: - } - - msg, err := a.dec.Decode() - if err != nil { - if err == io.EOF { - return nil, fmt.Errorf("process_exit: adapter process exited") - } - return nil, fmt.Errorf("adapter recv: %w", err) - } - - // If it's a notification, forward it and keep reading - if msg.IsNotification() { - select { - case a.updates <- msg: - default: - slog.Warn("adapter update channel full, dropping notification") - } - continue - } - - // If it's a response matching our request - if msg.ID == req.ID { - if msg.Error != nil { - return nil, fmt.Errorf("response_error: %s (code %d)", msg.Error.Message, msg.Error.Code) - } - return msg, nil - } - - // For requests from adapter (permission/tool/input), auto-respond - if msg.IsRequest() { - // Forward to updates channel for observability - select { - case a.updates <- msg: - default: - slog.Warn("adapter update channel full, dropping request") - } - - // Auto-respond to callback requests per harness policy - a.handleCallbackRequest(msg) - continue - } - } -} - -// Updates returns the channel of notifications and adapter-to-client requests. -func (a *SubprocessAdapter) Updates() <-chan *Message { - return a.updates -} - -// Close terminates the subprocess. -func (a *SubprocessAdapter) Close() error { - _ = a.stdin.Close() - err := a.cmd.Process.Kill() - _ = a.cmd.Wait() - close(a.done) - return err -} - -// PID returns the subprocess PID if available. -func (a *SubprocessAdapter) PID() int { - if a.cmd.Process != nil { - return a.cmd.Process.Pid - } - return 0 -} - -// handleCallbackRequest auto-responds to adapter-to-client requests -// (permission, tool, input) per configured harness policy. -func (a *SubprocessAdapter) handleCallbackRequest(msg *Message) { - switch msg.Method { - case "session/request_permission": - // Auto-approve permissions - slog.Info("auto-approving permission request", "id", msg.ID) - resp := Request{ - ID: msg.ID, - Method: "session/respond_permission", - Params: map[string]any{ - "approved": true, - "optionId": "allow-once", - }, - } - a.mu.Lock() - _ = a.enc.Encode(resp) - a.mu.Unlock() - - case "session/request_tool": - // Execute tool request — forward to updates for the orchestrator to handle - slog.Info("received tool request", "id", msg.ID) - // For now, return unsupported - resp := Request{ - ID: msg.ID, - Method: "session/respond_tool", - Params: map[string]any{ - "success": false, - "error": "unsupported tool", - }, - } - a.mu.Lock() - _ = a.enc.Encode(resp) - a.mu.Unlock() - - case "session/request_input": - // Deny input requests in automated mode - slog.Info("denying input request", "id", msg.ID) - resp := Request{ - ID: msg.ID, - Method: "session/respond_input", - Params: map[string]any{ - "cancelled": true, - }, - } - a.mu.Lock() - _ = a.enc.Encode(resp) - a.mu.Unlock() - - default: - slog.Warn("unknown adapter request method", "method", msg.Method, "id", msg.ID) - } -} - -func (a *SubprocessAdapter) drainStderr() { - data, err := io.ReadAll(a.stderr) - if len(data) > 0 { - slog.Warn("adapter stderr", "output", string(data)) - } - _ = err -} diff --git a/internal/adapter/types.go b/internal/adapter/types.go deleted file mode 100644 index 67747af..0000000 --- a/internal/adapter/types.go +++ /dev/null @@ -1,54 +0,0 @@ -package adapter - -import "context" - -// AdapterClient is the unified interface for all agent adapters. -type AdapterClient interface { - Initialize(ctx context.Context) (*InitResult, error) - NewSession(ctx context.Context, params SessionParams) (string, error) - Prompt(ctx context.Context, sessionID string, text string) (*PromptResult, error) - Cancel(ctx context.Context, sessionID string) error - CloseSession(ctx context.Context, sessionID string) error - Close() error - Updates() <-chan *Message - PID() int -} - -// InitResult holds the capabilities from initialization. -type InitResult struct { - Provider string - Capabilities map[string]any -} - -// SessionParams for creating a new session. -type SessionParams struct { - Cwd string - Title string - Model string - Tools []any - MCPServers []any - ProviderParams map[string]any - ResumeSessionID string // Resume a previous Claude session (for continuation turns) -} - -// PromptResult holds the result of a prompt turn. -type PromptResult struct { - StopReason StopReason - Summary string - SessionID string // Claude session ID for resumption - CostUSD float64 // Cost of this turn - NumTurns int // Internal turns Claude took - DurationMs int // Wall-clock duration -} - -// AdapterConfig holds configuration for creating an adapter. -type AdapterConfig struct { - Kind string // "claude_code", "opencode", "codex" - Command string - Args []string - Cwd string - Env []string - Model string - AllowedTools []string // for Claude CLI: restrict tools - PermissionMode string // for Claude CLI: permission handling mode -} diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..86cf90d --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,81 @@ +package agent + +import ( + "context" + "os" + "time" +) + +// Agent runs coding tasks in a workspace. +// Implementations: agent/claude, agent/opencode, agent/codex, agent/mock +type Agent interface { + // Start launches the agent process in the given workspace. + // Returns a Session handle for interaction. + Start(ctx context.Context, cfg StartConfig) (*Session, error) +} + +// StartConfig contains everything needed to start an agent session. +type StartConfig struct { + WorkDir string // workspace directory (CWD for agent) + Prompt string // rendered prompt template + Title string // issue title (for context) + ResumeID string // previous session ID for continuation + MaxTurns int // maximum turns for this session +} + +// Session represents a running agent process. +type Session struct { + ID string // unique session identifier + PTY *os.File // PTY master fd (nil for non-PTY agents) + SocketPath string // Unix socket path for attach + Updates <-chan Update // real-time progress updates + Done <-chan Result // final result when agent exits +} + +// Update is a real-time progress notification from a running agent. +type Update struct { + Kind UpdateKind + Message string + Tokens TokenUsage + Timestamp time.Time +} + +// UpdateKind classifies an agent update. +type UpdateKind string + +const ( + UpdateTurnStarted UpdateKind = "turn_started" + UpdateTurnDone UpdateKind = "turn_done" + UpdateTokens UpdateKind = "tokens" + UpdateProgress UpdateKind = "progress" + UpdateError UpdateKind = "error" +) + +// Result is the final outcome of an agent session. +type Result struct { + StopReason StopReason + SessionID string // native session ID for resumption + CostUSD float64 // total cost of this session + NumTurns int + DurationMs int + HasCommits bool // true if new commits exist on branch + Error error +} + +// StopReason explains why the agent session ended. +type StopReason string + +const ( + StopCompleted StopReason = "completed" + StopFailed StopReason = "failed" + StopCancelled StopReason = "cancelled" + StopTimedOut StopReason = "timed_out" + StopBudgetExceeded StopReason = "budget_exceeded" +) + +// TokenUsage tracks token consumption. +type TokenUsage struct { + Input int + Output int + Total int +} diff --git a/internal/agent/claude/claude.go b/internal/agent/claude/claude.go new file mode 100644 index 0000000..48dca06 --- /dev/null +++ b/internal/agent/claude/claude.go @@ -0,0 +1,235 @@ +// Package claude implements the Agent interface for Claude Code CLI. +package claude + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "os/exec" + "strings" + "time" + + "github.com/creack/pty" + "github.com/google/uuid" + "github.com/shivamstaq/github-symphony/internal/agent" +) + +// Config configures the Claude Code agent. +type Config struct { + Binary string // path to claude binary (default: "claude") + Model string // model override (e.g., "sonnet", "opus") + AllowedTools []string // restrict tools + PermissionMode string // "bypassPermissions", etc. + LogDir string // directory for agent logs + SocketDir string // directory for attach sockets +} + +// Agent implements the agent.Agent interface using Claude Code CLI with PTY. +type Agent struct { + cfg Config +} + +// New creates a Claude Code agent. +func New(cfg Config) *Agent { + if cfg.Binary == "" { + cfg.Binary = "claude" + } + if cfg.PermissionMode == "" { + cfg.PermissionMode = "bypassPermissions" + } + return &Agent{cfg: cfg} +} + +// CLIResult is the parsed JSON output from claude -p --output-format json. +type CLIResult struct { + Type string `json:"type"` + Subtype string `json:"subtype"` + IsError bool `json:"is_error"` + Result string `json:"result"` + StopReason string `json:"stop_reason"` + NumTurns int `json:"num_turns"` + SessionID string `json:"session_id"` + DurationMs int `json:"duration_ms"` + TotalCostUSD float64 `json:"total_cost_usd"` + Usage map[string]any `json:"usage"` +} + +func (a *Agent) Start(ctx context.Context, cfg agent.StartConfig) (*agent.Session, error) { + sessionID := uuid.New().String() + updates := make(chan agent.Update, 100) + done := make(chan agent.Result, 1) + + // Build command args + args := []string{"-p", "--output-format", "json"} + if cfg.ResumeID != "" { + args = append(args, "--resume", cfg.ResumeID) + } + if a.cfg.PermissionMode != "" { + args = append(args, "--permission-mode", a.cfg.PermissionMode) + } + if a.cfg.Model != "" { + args = append(args, "--model", a.cfg.Model) + } + if len(a.cfg.AllowedTools) > 0 { + args = append(args, "--allowed-tools", strings.Join(a.cfg.AllowedTools, ",")) + } + + cmd := exec.CommandContext(ctx, a.cfg.Binary, args...) + cmd.Dir = cfg.WorkDir + cmd.Stdin = strings.NewReader(cfg.Prompt) + cmd.Env = os.Environ() + + // Start in PTY + ptmx, err := pty.Start(cmd) + if err != nil { + return nil, fmt.Errorf("pty start: %w", err) + } + + // Create PTY session for output capture and attach + itemID := sessionID // use session ID as item ID for socket naming + ptySess, ptyErr := agent.NewPTYSession(ptmx, agent.PTYConfig{ + LogDir: a.cfg.LogDir, + SocketDir: a.cfg.SocketDir, + ItemID: itemID, + RingSize: 64 * 1024, + }) + + socketPath := "" + if ptyErr != nil { + slog.Warn("PTY session setup failed, continuing without attach support", "error", ptyErr) + } else { + socketPath = ptySess.SocketPath() + } + + // Run in background goroutine + go func() { + defer close(updates) + defer close(done) + if ptySess != nil { + defer func() { _ = ptySess.Close() }() + } + + updates <- agent.Update{ + Kind: agent.UpdateTurnStarted, + Message: "claude session started", + Timestamp: time.Now(), + } + + // Read all output (JSON result comes on stdout after process exits) + // With PTY, stdout and stderr are merged into the PTY + // We need to wait for process exit and read the final JSON from the PTY buffer + err := cmd.Wait() + + // Read remaining PTY output for the JSON result + var output []byte + if ptmx != nil { + // Read any remaining data + remaining := make([]byte, 1024*1024) // 1MB max + n, _ := ptmx.Read(remaining) + if n > 0 { + output = remaining[:n] + } + } + + // Try to parse the JSON result from the output + result := agent.Result{ + SessionID: sessionID, + } + + if err != nil { + result.StopReason = agent.StopFailed + result.Error = err + } else { + // Parse JSON from output — find the last JSON object + parsed := parseLastJSON(output) + if parsed != nil { + result.SessionID = parsed.SessionID + result.CostUSD = parsed.TotalCostUSD + result.NumTurns = parsed.NumTurns + result.DurationMs = parsed.DurationMs + result.StopReason = mapStopReason(parsed) + + updates <- agent.Update{ + Kind: agent.UpdateTurnDone, + Message: "turn completed", + Tokens: agent.TokenUsage{ + Total: extractTotalTokens(parsed.Usage), + }, + Timestamp: time.Now(), + } + } else { + result.StopReason = agent.StopCompleted + } + } + + // Check for new commits + result.HasCommits = agent.HasNewCommits(cfg.WorkDir) + + done <- result + }() + + return &agent.Session{ + ID: sessionID, + PTY: ptmx, + SocketPath: socketPath, + Updates: updates, + Done: done, + }, nil +} + +// parseLastJSON extracts the last JSON object from mixed output. +func parseLastJSON(data []byte) *CLIResult { + // Claude CLI outputs JSON as the last thing on stdout + // Try parsing the entire output first + var result CLIResult + if err := json.Unmarshal(data, &result); err == nil { + return &result + } + + // Try to find JSON in the output (scan backwards for '{') + s := string(data) + for i := len(s) - 1; i >= 0; i-- { + if s[i] == '{' { + var r CLIResult + if err := json.Unmarshal([]byte(s[i:]), &r); err == nil { + return &r + } + } + } + return nil +} + +func mapStopReason(result *CLIResult) agent.StopReason { + if result.IsError { + return agent.StopFailed + } + switch result.StopReason { + case "end_turn", "stop_sequence", "max_tokens": + return agent.StopCompleted + default: + if result.Subtype == "error" { + return agent.StopFailed + } + return agent.StopCompleted + } +} + +func extractTotalTokens(usage map[string]any) int { + if usage == nil { + return 0 + } + if v, ok := usage["total_tokens"]; ok { + switch t := v.(type) { + case float64: + return int(t) + case int: + return t + } + } + return 0 +} + +// Ensure Agent implements the interface at compile time. +var _ agent.Agent = (*Agent)(nil) diff --git a/internal/agent/codex/codex.go b/internal/agent/codex/codex.go new file mode 100644 index 0000000..e230230 --- /dev/null +++ b/internal/agent/codex/codex.go @@ -0,0 +1,136 @@ +// Package codex implements the Agent interface for the Codex CLI. +package codex + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "time" + + "github.com/creack/pty" + "github.com/google/uuid" + "github.com/shivamstaq/github-symphony/internal/agent" +) + +// Config configures the Codex agent adapter. +type Config struct { + Binary string // path to codex binary (default: "codex") + ApprovalPolicy string // --approval-mode value (e.g., "full-auto", "suggest") + LogDir string // directory for agent logs + SocketDir string // directory for attach sockets +} + +// Agent implements the agent.Agent interface using the Codex CLI with PTY. +type Agent struct { + cfg Config +} + +// New creates a Codex agent adapter. +func New(cfg Config) *Agent { + if cfg.Binary == "" { + cfg.Binary = "codex" + } + if cfg.ApprovalPolicy == "" { + cfg.ApprovalPolicy = "full-auto" + } + return &Agent{cfg: cfg} +} + +func (a *Agent) Start(ctx context.Context, cfg agent.StartConfig) (*agent.Session, error) { + sessionID := uuid.New().String() + updates := make(chan agent.Update, 100) + done := make(chan agent.Result, 1) + + // Codex takes the prompt as a positional argument + args := []string{cfg.Prompt} + if a.cfg.ApprovalPolicy != "" { + args = append(args, "--approval-mode", a.cfg.ApprovalPolicy) + } + + cmd := exec.CommandContext(ctx, a.cfg.Binary, args...) + cmd.Dir = cfg.WorkDir + cmd.Env = os.Environ() + + ptmx, err := pty.Start(cmd) + if err != nil { + return nil, fmt.Errorf("codex pty start: %w", err) + } + + itemID := sessionID + ptySess, ptyErr := agent.NewPTYSession(ptmx, agent.PTYConfig{ + LogDir: a.cfg.LogDir, + SocketDir: a.cfg.SocketDir, + ItemID: itemID, + RingSize: 64 * 1024, + }) + + socketPath := "" + if ptyErr != nil { + slog.Warn("PTY session setup failed, continuing without attach support", "error", ptyErr) + } else { + socketPath = ptySess.SocketPath() + } + + go func() { + defer close(updates) + defer close(done) + if ptySess != nil { + defer func() { _ = ptySess.Close() }() + } + + updates <- agent.Update{ + Kind: agent.UpdateTurnStarted, + Message: "codex session started", + Timestamp: time.Now(), + } + + startTime := time.Now() + err := cmd.Wait() + durationMs := int(time.Since(startTime).Milliseconds()) + + result := agent.Result{ + SessionID: sessionID, + DurationMs: durationMs, + } + + if err != nil { + if ctx.Err() != nil { + result.StopReason = agent.StopCancelled + } else { + result.StopReason = agent.StopFailed + result.Error = err + } + } else { + result.StopReason = agent.StopCompleted + updates <- agent.Update{ + Kind: agent.UpdateTurnDone, + Message: "codex turn completed", + Timestamp: time.Now(), + } + } + + result.HasCommits = agent.HasNewCommits(cfg.WorkDir) + done <- result + }() + + return &agent.Session{ + ID: sessionID, + PTY: ptmx, + SocketPath: socketPath, + Updates: updates, + Done: done, + }, nil +} + +// CheckDependencies verifies that the codex binary is available on PATH. +func CheckDependencies() error { + if _, err := exec.LookPath("codex"); err != nil { + return fmt.Errorf("codex not found on PATH: %w", err) + } + return nil +} + +// Ensure Agent implements the interface at compile time. +var _ agent.Agent = (*Agent)(nil) diff --git a/internal/agent/codex/codex_test.go b/internal/agent/codex/codex_test.go new file mode 100644 index 0000000..a280c28 --- /dev/null +++ b/internal/agent/codex/codex_test.go @@ -0,0 +1,46 @@ +package codex + +import ( + "os/exec" + "testing" +) + +func TestNew_DefaultBinary(t *testing.T) { + a := New(Config{}) + if a.cfg.Binary != "codex" { + t.Errorf("expected default binary 'codex', got %q", a.cfg.Binary) + } +} + +func TestNew_DefaultApprovalPolicy(t *testing.T) { + a := New(Config{}) + if a.cfg.ApprovalPolicy != "full-auto" { + t.Errorf("expected default approval policy 'full-auto', got %q", a.cfg.ApprovalPolicy) + } +} + +func TestNew_CustomBinary(t *testing.T) { + a := New(Config{Binary: "/usr/local/bin/codex"}) + if a.cfg.Binary != "/usr/local/bin/codex" { + t.Errorf("expected custom binary, got %q", a.cfg.Binary) + } +} + +func TestNew_CustomApprovalPolicy(t *testing.T) { + a := New(Config{ApprovalPolicy: "suggest"}) + if a.cfg.ApprovalPolicy != "suggest" { + t.Errorf("expected 'suggest', got %q", a.cfg.ApprovalPolicy) + } +} + +func TestCheckDependencies_BinaryNotOnPath(t *testing.T) { + // Only run if codex is NOT installed + if _, err := exec.LookPath("codex"); err == nil { + t.Skip("codex is installed, skipping not-on-path test") + } + + err := CheckDependencies() + if err == nil { + t.Error("expected error when codex not on PATH") + } +} diff --git a/internal/agent/git.go b/internal/agent/git.go new file mode 100644 index 0000000..057ce20 --- /dev/null +++ b/internal/agent/git.go @@ -0,0 +1,39 @@ +package agent + +import ( + "os/exec" + "strings" +) + +// HasNewCommits checks if there are new commits or uncommitted changes +// in the given workspace directory relative to the remote tracking branch. +func HasNewCommits(dir string) bool { + if dir == "" { + return false + } + cmd := exec.Command("git", "status", "--porcelain") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return false + } + return len(strings.TrimSpace(string(out))) > 0 || HasUnpushedCommits(dir) +} + +// HasUnpushedCommits checks for commits ahead of the upstream tracking branch. +func HasUnpushedCommits(dir string) bool { + if dir == "" { + return false + } + cmd := exec.Command("git", "log", "--oneline", "@{u}..HEAD") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + // No upstream tracking — check if there are any commits + cmd2 := exec.Command("git", "log", "--oneline", "-1") + cmd2.Dir = dir + out2, err2 := cmd2.Output() + return err2 == nil && len(strings.TrimSpace(string(out2))) > 0 + } + return len(strings.TrimSpace(string(out))) > 0 +} diff --git a/internal/agent/git_test.go b/internal/agent/git_test.go new file mode 100644 index 0000000..96b5b11 --- /dev/null +++ b/internal/agent/git_test.go @@ -0,0 +1,42 @@ +package agent + +import ( + "os" + "testing" +) + +func TestHasNewCommits_EmptyDir(t *testing.T) { + if HasNewCommits("") { + t.Error("empty dir should return false") + } +} + +func TestHasNewCommits_NonExistentDir(t *testing.T) { + if HasNewCommits("/nonexistent/dir/that/does/not/exist") { + t.Error("nonexistent dir should return false") + } +} + +func TestHasNewCommits_TempDirNoGit(t *testing.T) { + dir, err := os.MkdirTemp("", "symphony-git-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + if HasNewCommits(dir) { + t.Error("dir without git repo should return false") + } +} + +func TestHasUnpushedCommits_EmptyDir(t *testing.T) { + if HasUnpushedCommits("") { + t.Error("empty dir should return false") + } +} + +func TestHasUnpushedCommits_NonExistentDir(t *testing.T) { + if HasUnpushedCommits("/nonexistent/dir/that/does/not/exist") { + t.Error("nonexistent dir should return false") + } +} diff --git a/internal/agent/mock/mock.go b/internal/agent/mock/mock.go new file mode 100644 index 0000000..a39db22 --- /dev/null +++ b/internal/agent/mock/mock.go @@ -0,0 +1,131 @@ +// Package mock provides a configurable mock agent for testing. +package mock + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/shivamstaq/github-symphony/internal/agent" +) + +// MockAgent is a configurable mock that simulates agent behavior. +type MockAgent struct { + // Behavior configuration + StopReason agent.StopReason + CostUSD float64 + NumTurns int + HasCommits bool + Error error + Delay time.Duration // simulate work time per turn +} + +// NewSuccessAgent returns a mock that completes successfully with commits. +func NewSuccessAgent() *MockAgent { + return &MockAgent{ + StopReason: agent.StopCompleted, + CostUSD: 0.05, + NumTurns: 3, + HasCommits: true, + } +} + +// NewFailAgent returns a mock that fails with an error. +func NewFailAgent(err error) *MockAgent { + return &MockAgent{ + StopReason: agent.StopFailed, + Error: err, + } +} + +// NewNoCommitsAgent returns a mock that completes but produces no commits. +func NewNoCommitsAgent() *MockAgent { + return &MockAgent{ + StopReason: agent.StopCompleted, + NumTurns: 2, + HasCommits: false, + } +} + +// MakeUpdate creates an agent.Update for testing. +func MakeUpdate(kind string, totalTokens int) agent.Update { + return agent.Update{ + Kind: agent.UpdateKind(kind), + Tokens: agent.TokenUsage{ + Total: totalTokens, + }, + Timestamp: time.Now(), + } +} + +func (m *MockAgent) Start(ctx context.Context, cfg agent.StartConfig) (*agent.Session, error) { + sessionID := uuid.New().String() + updates := make(chan agent.Update, 100) + done := make(chan agent.Result, 1) + + go func() { + defer close(updates) + defer close(done) + + numTurns := m.NumTurns + if numTurns == 0 { + numTurns = 1 + } + + for i := 1; i <= numTurns; i++ { + select { + case <-ctx.Done(): + done <- agent.Result{ + StopReason: agent.StopCancelled, + SessionID: sessionID, + } + return + default: + } + + updates <- agent.Update{ + Kind: agent.UpdateTurnStarted, + Message: "turn started", + Timestamp: time.Now(), + } + + if m.Delay > 0 { + select { + case <-time.After(m.Delay): + case <-ctx.Done(): + done <- agent.Result{ + StopReason: agent.StopCancelled, + SessionID: sessionID, + } + return + } + } + + updates <- agent.Update{ + Kind: agent.UpdateTurnDone, + Message: "turn completed", + Tokens: agent.TokenUsage{ + Input: 400, + Output: 200, + Total: 600, + }, + Timestamp: time.Now(), + } + } + + done <- agent.Result{ + StopReason: m.StopReason, + SessionID: sessionID, + CostUSD: m.CostUSD, + NumTurns: numTurns, + HasCommits: m.HasCommits, + Error: m.Error, + } + }() + + return &agent.Session{ + ID: sessionID, + Updates: updates, + Done: done, + }, nil +} diff --git a/internal/agent/opencode/opencode.go b/internal/agent/opencode/opencode.go new file mode 100644 index 0000000..2598bbc --- /dev/null +++ b/internal/agent/opencode/opencode.go @@ -0,0 +1,141 @@ +// Package opencode implements the Agent interface for the OpenCode CLI. +package opencode + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "strings" + "time" + + "github.com/creack/pty" + "github.com/google/uuid" + "github.com/shivamstaq/github-symphony/internal/agent" +) + +// Config configures the OpenCode agent adapter. +type Config struct { + Binary string // path to opencode binary (default: "opencode") + Model string // model override + ConfigFile string // --config path for opencode config + LogDir string // directory for agent logs + SocketDir string // directory for attach sockets +} + +// Agent implements the agent.Agent interface using the OpenCode CLI with PTY. +type Agent struct { + cfg Config +} + +// New creates an OpenCode agent adapter. +func New(cfg Config) *Agent { + if cfg.Binary == "" { + cfg.Binary = "opencode" + } + return &Agent{cfg: cfg} +} + +func (a *Agent) Start(ctx context.Context, cfg agent.StartConfig) (*agent.Session, error) { + sessionID := uuid.New().String() + updates := make(chan agent.Update, 100) + done := make(chan agent.Result, 1) + + args := []string{"-p", "--output-format", "json"} + if cfg.ResumeID != "" { + args = append(args, "--resume", cfg.ResumeID) + } + if a.cfg.Model != "" { + args = append(args, "--model", a.cfg.Model) + } + if a.cfg.ConfigFile != "" { + args = append(args, "--config", a.cfg.ConfigFile) + } + + cmd := exec.CommandContext(ctx, a.cfg.Binary, args...) + cmd.Dir = cfg.WorkDir + cmd.Stdin = strings.NewReader(cfg.Prompt) + cmd.Env = os.Environ() + + ptmx, err := pty.Start(cmd) + if err != nil { + return nil, fmt.Errorf("opencode pty start: %w", err) + } + + itemID := sessionID + ptySess, ptyErr := agent.NewPTYSession(ptmx, agent.PTYConfig{ + LogDir: a.cfg.LogDir, + SocketDir: a.cfg.SocketDir, + ItemID: itemID, + RingSize: 64 * 1024, + }) + + socketPath := "" + if ptyErr != nil { + slog.Warn("PTY session setup failed, continuing without attach support", "error", ptyErr) + } else { + socketPath = ptySess.SocketPath() + } + + go func() { + defer close(updates) + defer close(done) + if ptySess != nil { + defer func() { _ = ptySess.Close() }() + } + + updates <- agent.Update{ + Kind: agent.UpdateTurnStarted, + Message: "opencode session started", + Timestamp: time.Now(), + } + + startTime := time.Now() + err := cmd.Wait() + durationMs := int(time.Since(startTime).Milliseconds()) + + result := agent.Result{ + SessionID: sessionID, + DurationMs: durationMs, + } + + if err != nil { + if ctx.Err() != nil { + result.StopReason = agent.StopCancelled + } else { + result.StopReason = agent.StopFailed + result.Error = err + } + } else { + result.StopReason = agent.StopCompleted + updates <- agent.Update{ + Kind: agent.UpdateTurnDone, + Message: "opencode turn completed", + Timestamp: time.Now(), + } + } + + result.HasCommits = agent.HasNewCommits(cfg.WorkDir) + done <- result + }() + + return &agent.Session{ + ID: sessionID, + PTY: ptmx, + SocketPath: socketPath, + Updates: updates, + Done: done, + }, nil +} + +// CheckDependencies verifies that the opencode binary is available on PATH. +func CheckDependencies() error { + if _, err := exec.LookPath("opencode"); err != nil { + return fmt.Errorf("opencode not found on PATH: %w", err) + } + return nil +} + +// Ensure Agent implements the interface at compile time. +var _ agent.Agent = (*Agent)(nil) diff --git a/internal/agent/opencode/opencode_test.go b/internal/agent/opencode/opencode_test.go new file mode 100644 index 0000000..eaf3a6d --- /dev/null +++ b/internal/agent/opencode/opencode_test.go @@ -0,0 +1,48 @@ +package opencode + +import ( + "os/exec" + "testing" +) + +func TestNew_DefaultBinary(t *testing.T) { + a := New(Config{}) + if a.cfg.Binary != "opencode" { + t.Errorf("expected default binary 'opencode', got %q", a.cfg.Binary) + } +} + +func TestNew_CustomBinary(t *testing.T) { + a := New(Config{Binary: "/usr/local/bin/opencode"}) + if a.cfg.Binary != "/usr/local/bin/opencode" { + t.Errorf("expected custom binary, got %q", a.cfg.Binary) + } +} + +func TestNew_PreservesConfig(t *testing.T) { + a := New(Config{ + Binary: "oc", + Model: "gpt-4", + ConfigFile: "/etc/opencode.yaml", + LogDir: "/tmp/logs", + SocketDir: "/tmp/sockets", + }) + if a.cfg.Model != "gpt-4" { + t.Errorf("expected model 'gpt-4', got %q", a.cfg.Model) + } + if a.cfg.ConfigFile != "/etc/opencode.yaml" { + t.Errorf("expected config file, got %q", a.cfg.ConfigFile) + } +} + +func TestCheckDependencies_BinaryNotOnPath(t *testing.T) { + // Only run if opencode is NOT installed + if _, err := exec.LookPath("opencode"); err == nil { + t.Skip("opencode is installed, skipping not-on-path test") + } + + err := CheckDependencies() + if err == nil { + t.Error("expected error when opencode not on PATH") + } +} diff --git a/internal/agent/pty.go b/internal/agent/pty.go new file mode 100644 index 0000000..fe2214f --- /dev/null +++ b/internal/agent/pty.go @@ -0,0 +1,222 @@ +package agent + +import ( + "fmt" + "net" + "os" + "path/filepath" + "sync" +) + +// PTYSession manages a pseudo-terminal session for an agent process. +// It captures output to a log file, maintains a ring buffer for TUI display, +// and serves the PTY stream over a Unix socket for `symphony attach`. +type PTYSession struct { + mu sync.Mutex + ptyFile *os.File // PTY master fd + logFile *os.File // stdout.log capture + socketPath string // Unix socket path + listener net.Listener + ringBuf *RingBuffer // last N bytes for quick attach + clients []net.Conn // attached clients + closed bool +} + +// PTYConfig configures a PTY session. +type PTYConfig struct { + LogDir string // directory for stdout.log + SocketDir string // directory for .sock file + ItemID string // work item ID (used in filenames) + RingSize int // ring buffer size in bytes (default 64KB) +} + +// NewPTYSession creates a PTY session that tees output to a log file and ring buffer. +// The ptyFile is the master side of the PTY (from creack/pty.Start). +func NewPTYSession(ptyFile *os.File, cfg PTYConfig) (*PTYSession, error) { + ringSize := cfg.RingSize + if ringSize <= 0 { + ringSize = 64 * 1024 // 64KB default + } + + // Create log file + logDir := cfg.LogDir + if err := os.MkdirAll(logDir, 0755); err != nil { + return nil, fmt.Errorf("create log dir: %w", err) + } + logFile, err := os.OpenFile( + filepath.Join(logDir, "stdout.log"), + os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644, + ) + if err != nil { + return nil, fmt.Errorf("open stdout.log: %w", err) + } + + // Create Unix socket + socketDir := cfg.SocketDir + if err := os.MkdirAll(socketDir, 0755); err != nil { + _ = logFile.Close() + return nil, fmt.Errorf("create socket dir: %w", err) + } + socketPath := filepath.Join(socketDir, cfg.ItemID+".sock") + // Remove stale socket + _ = os.Remove(socketPath) + + listener, err := net.Listen("unix", socketPath) + if err != nil { + _ = logFile.Close() + return nil, fmt.Errorf("listen unix socket: %w", err) + } + + sess := &PTYSession{ + ptyFile: ptyFile, + logFile: logFile, + socketPath: socketPath, + listener: listener, + ringBuf: NewRingBuffer(ringSize), + } + + // Accept attach connections in background + go sess.acceptLoop() + + // Tee PTY output in background + go sess.readLoop() + + return sess, nil +} + +// SocketPath returns the Unix socket path for this session. +func (s *PTYSession) SocketPath() string { + return s.socketPath +} + +// readLoop reads from the PTY master and writes to log, ring buffer, and attached clients. +func (s *PTYSession) readLoop() { + buf := make([]byte, 4096) + for { + n, err := s.ptyFile.Read(buf) + if n > 0 { + data := buf[:n] + + // Write to log file + _, _ = s.logFile.Write(data) + + // Write to ring buffer + s.ringBuf.Write(data) + + // Write to attached clients + s.mu.Lock() + alive := make([]net.Conn, 0, len(s.clients)) + for _, c := range s.clients { + if _, werr := c.Write(data); werr == nil { + alive = append(alive, c) + } else { + _ = c.Close() + } + } + s.clients = alive + s.mu.Unlock() + } + if err != nil { + return + } + } +} + +// acceptLoop accepts new attach connections on the Unix socket. +func (s *PTYSession) acceptLoop() { + for { + conn, err := s.listener.Accept() + if err != nil { + return // listener closed + } + + s.mu.Lock() + if s.closed { + _ = conn.Close() + s.mu.Unlock() + return + } + // Send ring buffer contents (recent history) to new client + recent := s.ringBuf.Bytes() + if len(recent) > 0 { + _, _ = conn.Write(recent) + } + s.clients = append(s.clients, conn) + s.mu.Unlock() + } +} + +// Close shuts down the PTY session, closing all resources. +func (s *PTYSession) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return nil + } + s.closed = true + + // Close attached clients + for _, c := range s.clients { + _ = c.Close() + } + s.clients = nil + + // Close listener and remove socket file + _ = s.listener.Close() + _ = os.Remove(s.socketPath) + + // Close log file + _ = s.logFile.Close() + + return nil +} + +// RingBuffer is a fixed-size circular byte buffer. +type RingBuffer struct { + mu sync.Mutex + buf []byte + size int + pos int + full bool +} + +// NewRingBuffer creates a ring buffer with the given capacity. +func NewRingBuffer(size int) *RingBuffer { + return &RingBuffer{ + buf: make([]byte, size), + size: size, + } +} + +// Write appends data to the ring buffer, overwriting oldest data if full. +func (r *RingBuffer) Write(data []byte) { + r.mu.Lock() + defer r.mu.Unlock() + + for _, b := range data { + r.buf[r.pos] = b + r.pos = (r.pos + 1) % r.size + if r.pos == 0 { + r.full = true + } + } +} + +// Bytes returns the current contents of the ring buffer in order. +func (r *RingBuffer) Bytes() []byte { + r.mu.Lock() + defer r.mu.Unlock() + + if !r.full { + out := make([]byte, r.pos) + copy(out, r.buf[:r.pos]) + return out + } + + // Buffer is full — data wraps around + out := make([]byte, r.size) + copy(out, r.buf[r.pos:]) + copy(out[r.size-r.pos:], r.buf[:r.pos]) + return out +} diff --git a/internal/codehost/codehost.go b/internal/codehost/codehost.go new file mode 100644 index 0000000..ecb1d8e --- /dev/null +++ b/internal/codehost/codehost.go @@ -0,0 +1,69 @@ +package codehost + +import "context" + +// CodeHost handles git operations and pull request management. +// Implementations: codehost/github (future: codehost/gitlab) +type CodeHost interface { + // UpsertPR creates or updates a pull request. + UpsertPR(ctx context.Context, params PRParams) (*PRResult, error) + + // CommentOnItem posts a comment on an issue/PR. + CommentOnItem(ctx context.Context, ref ItemRef, body string) (string, error) + + // UpdateProjectStatus moves a project item to a new status. + UpdateProjectStatus(ctx context.Context, params StatusUpdateParams) error + + // FetchProjectMeta retrieves project field IDs for status updates. + FetchProjectMeta(ctx context.Context, params ProjectMetaParams) (*ProjectMeta, error) +} + +// PRParams contains parameters for creating/updating a PR. +type PRParams struct { + Owner string + Repo string + Title string + Body string + HeadBranch string + BaseBranch string + Draft bool +} + +// PRResult contains the result of a PR create/update. +type PRResult struct { + Number int + URL string + State string + IsDraft bool + Created bool // true if newly created, false if updated +} + +// ItemRef identifies an issue or PR. +type ItemRef struct { + Owner string + Repo string + Number int +} + +// StatusUpdateParams for moving a project item status. +type StatusUpdateParams struct { + ProjectID string + ItemID string + FieldID string + OptionID string +} + +// ProjectMetaParams for looking up project field metadata. +type ProjectMetaParams struct { + Owner string + ProjectNumber int + Scope string // "organization" or "user" + FieldName string +} + +// ProjectMeta contains resolved project field IDs. +type ProjectMeta struct { + ProjectID string + FieldID string + Options map[string]string // option name -> option ID +} diff --git a/internal/codehost/factory.go b/internal/codehost/factory.go new file mode 100644 index 0000000..c4b0c77 --- /dev/null +++ b/internal/codehost/factory.go @@ -0,0 +1,28 @@ +package codehost + +import ( + "fmt" + + "github.com/shivamstaq/github-symphony/internal/config" +) + +// NewCodeHost creates a CodeHost based on the config. +// Currently only GitHub is supported as a code host. +func NewCodeHost(cfg *config.SymphonyConfig) (CodeHost, error) { + // CodeHost is always GitHub for now (even when tracker is Linear, + // the code still lives on GitHub). + token := cfg.Auth.GitHub.Token + if token == "" { + return nil, fmt.Errorf("github token required for code host: set auth.github.token") + } + + apiURL := cfg.Auth.GitHub.APIURL + if apiURL == "" { + apiURL = "https://api.github.com" + } + + // Import cycle prevention: we can't import codehost/github here. + // Instead, the caller (cmd/symphony/run.go) creates the GitHub host directly. + // This factory exists for future extension when we have multiple code hosts. + return nil, fmt.Errorf("use codehostgithub.New() directly — factory registration coming in a future version") +} diff --git a/internal/codehost/github/github.go b/internal/codehost/github/github.go new file mode 100644 index 0000000..5f049fb --- /dev/null +++ b/internal/codehost/github/github.go @@ -0,0 +1,70 @@ +// Package github implements codehost.CodeHost for GitHub. +package github + +import ( + "context" + "fmt" + + "github.com/shivamstaq/github-symphony/internal/codehost" + gh "github.com/shivamstaq/github-symphony/internal/github" +) + +// Host implements codehost.CodeHost by delegating to github.WriteBack and github.GraphQLClient. +type Host struct { + wb *gh.WriteBack + client *gh.GraphQLClient +} + +// New creates a GitHub CodeHost adapter. +func New(baseURL, token string) *Host { + graphqlEndpoint := baseURL + "/graphql" + return &Host{ + wb: gh.NewWriteBack(baseURL, graphqlEndpoint, token), + client: gh.NewGraphQLClient(graphqlEndpoint, token), + } +} + +func (h *Host) UpsertPR(ctx context.Context, params codehost.PRParams) (*codehost.PRResult, error) { + result, err := h.wb.UpsertPR(ctx, gh.PRParams{ + Owner: params.Owner, + Repo: params.Repo, + Title: params.Title, + Body: params.Body, + HeadBranch: params.HeadBranch, + BaseBranch: params.BaseBranch, + Draft: params.Draft, + }) + if err != nil { + return nil, fmt.Errorf("github codehost: upsert PR: %w", err) + } + return &codehost.PRResult{ + Number: result.Number, + URL: result.URL, + State: result.State, + IsDraft: result.IsDraft, + Created: result.Created, + }, nil +} + +func (h *Host) CommentOnItem(ctx context.Context, ref codehost.ItemRef, body string) (string, error) { + return h.wb.CommentOnIssue(ctx, ref.Owner, ref.Repo, ref.Number, body) +} + +func (h *Host) UpdateProjectStatus(ctx context.Context, params codehost.StatusUpdateParams) error { + return h.wb.UpdateProjectField(ctx, params.ProjectID, params.ItemID, params.FieldID, params.OptionID) +} + +func (h *Host) FetchProjectMeta(ctx context.Context, params codehost.ProjectMetaParams) (*codehost.ProjectMeta, error) { + meta, err := h.client.FetchProjectFieldMeta(ctx, params.Owner, params.ProjectNumber, params.Scope, params.FieldName) + if err != nil { + return nil, fmt.Errorf("github codehost: fetch project meta: %w", err) + } + return &codehost.ProjectMeta{ + ProjectID: meta.ProjectID, + FieldID: meta.FieldID, + Options: meta.Options, + }, nil +} + +// Compile-time interface check. +var _ codehost.CodeHost = (*Host)(nil) diff --git a/internal/config/parsers_test.go b/internal/config/parsers_test.go deleted file mode 100644 index 504bc64..0000000 --- a/internal/config/parsers_test.go +++ /dev/null @@ -1,231 +0,0 @@ -package config_test - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/shivamstaq/github-symphony/internal/config" -) - -func TestParseCodex(t *testing.T) { - raw := map[string]any{ - "tracker": map[string]any{"kind": "github", "owner": "o", "project_number": 1}, - "github": map[string]any{"token": "t"}, - "agent": map[string]any{"kind": "codex"}, - "codex": map[string]any{ - "approval_policy": "auto-edit", - "thread_sandbox": "network-only", - "turn_sandbox_policy": "strict", - "listen": "ws://localhost:8080", - "schema_bundle_dir": "/tmp/schemas", - }, - } - cfg, err := config.NewServiceConfig(raw) - if err != nil { - t.Fatal(err) - } - if cfg.Codex.ApprovalPolicy != "auto-edit" { - t.Errorf("approval_policy: %q", cfg.Codex.ApprovalPolicy) - } - if cfg.Codex.Listen != "ws://localhost:8080" { - t.Errorf("listen: %q", cfg.Codex.Listen) - } -} - -func TestParseOpenCode(t *testing.T) { - raw := map[string]any{ - "tracker": map[string]any{"kind": "github", "owner": "o", "project_number": 1}, - "github": map[string]any{"token": "t"}, - "agent": map[string]any{"kind": "opencode"}, - "opencode": map[string]any{ - "model": "gpt-4", - "resume_session": false, - "config_file": "/etc/opencode.json", - }, - } - cfg, err := config.NewServiceConfig(raw) - if err != nil { - t.Fatal(err) - } - if cfg.OpenCode.Model != "gpt-4" { - t.Errorf("model: %q", cfg.OpenCode.Model) - } - if cfg.OpenCode.ResumeSession != false { - t.Error("resume_session should be false") - } -} - -func TestParseAgent_ConcurrencyMaps(t *testing.T) { - raw := map[string]any{ - "tracker": map[string]any{"kind": "github", "owner": "o", "project_number": 1}, - "github": map[string]any{"token": "t"}, - "agent": map[string]any{ - "kind": "claude_code", - "max_concurrent_agents_by_project_status": map[string]any{ - "todo": 2, - "in_progress": 3, - }, - "max_concurrent_agents_by_repo": map[string]any{ - "org/repo": 1, - }, - }, - } - cfg, err := config.NewServiceConfig(raw) - if err != nil { - t.Fatal(err) - } - if cfg.Agent.MaxConcurrentByStatus["todo"] != 2 { - t.Errorf("per-status todo: %v", cfg.Agent.MaxConcurrentByStatus) - } - if cfg.Agent.MaxConcurrentByRepo["org/repo"] != 1 { - t.Errorf("per-repo: %v", cfg.Agent.MaxConcurrentByRepo) - } -} - -func TestParseClaude_AllFields(t *testing.T) { - raw := map[string]any{ - "tracker": map[string]any{"kind": "github", "owner": "o", "project_number": 1}, - "github": map[string]any{"token": "t"}, - "agent": map[string]any{"kind": "claude_code"}, - "claude": map[string]any{ - "allowed_tools": []any{"bash", "edit"}, - "mcp_servers": []any{map[string]any{"name": "test"}}, - "permission_profile": "permissive", - }, - } - cfg, err := config.NewServiceConfig(raw) - if err != nil { - t.Fatal(err) - } - if len(cfg.Claude.AllowedTools) != 2 { - t.Errorf("allowed_tools: %v", cfg.Claude.AllowedTools) - } - if len(cfg.Claude.MCPServers) != 1 { - t.Errorf("mcp_servers: %v", cfg.Claude.MCPServers) - } -} - -func TestParsePullRequest_RequiredChecks(t *testing.T) { - raw := map[string]any{ - "tracker": map[string]any{"kind": "github", "owner": "o", "project_number": 1}, - "github": map[string]any{"token": "t"}, - "agent": map[string]any{"kind": "claude_code"}, - "pull_request": map[string]any{ - "required_before_handoff_checks": []any{"lint", "test"}, - }, - } - cfg, err := config.NewServiceConfig(raw) - if err != nil { - t.Fatal(err) - } - if len(cfg.PullRequest.RequiredBeforeHandoff) != 2 { - t.Errorf("required checks: %v", cfg.PullRequest.RequiredBeforeHandoff) - } -} - -func TestParseServer_CORSOrigins(t *testing.T) { - raw := map[string]any{ - "tracker": map[string]any{"kind": "github", "owner": "o", "project_number": 1}, - "github": map[string]any{"token": "t"}, - "agent": map[string]any{"kind": "claude_code"}, - "server": map[string]any{ - "port": 9097, - "cors_origins": []any{"http://localhost:3000"}, - }, - } - cfg, err := config.NewServiceConfig(raw) - if err != nil { - t.Fatal(err) - } - if len(cfg.Server.CORSOrigins) != 1 { - t.Errorf("cors_origins: %v", cfg.Server.CORSOrigins) - } -} - -func TestPathExpansion_Tilde(t *testing.T) { - home, _ := os.UserHomeDir() - raw := map[string]any{ - "tracker": map[string]any{"kind": "github", "owner": "o", "project_number": 1}, - "github": map[string]any{"token": "t"}, - "agent": map[string]any{"kind": "claude_code"}, - "workspace": map[string]any{"root": "~/symphony_ws"}, - } - cfg, err := config.NewServiceConfig(raw) - if err != nil { - t.Fatal(err) - } - if !strings.HasPrefix(cfg.Workspace.Root, home) { - t.Errorf("expected ~ expanded to home dir, got %q", cfg.Workspace.Root) - } -} - -func TestWorkspaceDefaults_DerivedFromRoot(t *testing.T) { - raw := map[string]any{ - "tracker": map[string]any{"kind": "github", "owner": "o", "project_number": 1}, - "github": map[string]any{"token": "t"}, - "agent": map[string]any{"kind": "claude_code"}, - } - cfg, err := config.NewServiceConfig(raw) - if err != nil { - t.Fatal(err) - } - // Root should have a default - if cfg.Workspace.Root == "" { - t.Error("workspace.root should default to temp dir") - } - // Cache and worktree dirs should derive from root - if !strings.HasPrefix(cfg.Workspace.RepoCacheDir, cfg.Workspace.Root) { - t.Errorf("repo_cache_dir should be under root: %q vs %q", cfg.Workspace.RepoCacheDir, cfg.Workspace.Root) - } - if !strings.HasPrefix(cfg.Workspace.WorktreeDir, cfg.Workspace.Root) { - t.Errorf("worktree_dir should be under root: %q vs %q", cfg.Workspace.WorktreeDir, cfg.Workspace.Root) - } - if cfg.Workspace.RepoCacheDir != filepath.Join(cfg.Workspace.Root, "repo_cache") { - t.Errorf("repo_cache_dir: %q", cfg.Workspace.RepoCacheDir) - } -} - -func TestTrackerPriorityValueMap(t *testing.T) { - raw := map[string]any{ - "tracker": map[string]any{ - "kind": "github", - "owner": "o", - "project_number": 1, - "priority_value_map": map[string]any{ - "Critical": 1, - "High": 2, - "Medium": 3, - }, - }, - "github": map[string]any{"token": "t"}, - "agent": map[string]any{"kind": "claude_code"}, - } - cfg, err := config.NewServiceConfig(raw) - if err != nil { - t.Fatal(err) - } - if cfg.Tracker.PriorityValueMap["Critical"] != 1 { - t.Errorf("priority map: %v", cfg.Tracker.PriorityValueMap) - } -} - -func TestTemplateParseValidation_InvalidTemplate(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "WORKFLOW.md") - content := "---\ntracker:\n kind: github\n---\n{{.bad template {{syntax}}\n" - os.WriteFile(path, []byte(content), 0644) - - _, err := config.LoadWorkflow(path) - if err == nil { - t.Fatal("expected error for invalid template syntax") - } - var wfErr *config.WorkflowError - if !config.AsWorkflowError(err, &wfErr) { - t.Fatalf("expected WorkflowError, got %T", err) - } - if wfErr.Kind != config.ErrTemplateParseError { - t.Errorf("expected ErrTemplateParseError, got %v", wfErr.Kind) - } -} diff --git a/internal/config/service_config.go b/internal/config/service_config.go deleted file mode 100644 index 9799cc7..0000000 --- a/internal/config/service_config.go +++ /dev/null @@ -1,558 +0,0 @@ -package config - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "gopkg.in/yaml.v3" -) - -// ServiceConfig is the typed runtime configuration derived from WORKFLOW.md front matter. -type ServiceConfig struct { - Tracker TrackerConfig - GitHub GitHubConfig - Git GitConfig - Polling PollingConfig - Workspace WorkspaceConfig - Hooks HooksConfig - Agent AgentConfig - Codex CodexConfig - Claude ClaudeConfig - OpenCode OpenCodeConfig - PullRequest PullRequestConfig - Server ServerConfig -} - -type TrackerConfig struct { - Kind string - Owner string - ProjectNumber int - ProjectScope string - StatusFieldName string - ActiveValues []string - TerminalValues []string - PriorityFieldName string - PriorityValueMap map[string]int - ExecutableItemTypes []string - RequireIssueBacking bool - AllowDraftIssueConvert bool - RepoAllowlist []string - RepoDenylist []string - RequiredLabels []string - BlockedStatusValues []string -} - -type GitHubConfig struct { - AuthMode string - APIURL string - Token string - AppID string - PrivateKey string - WebhookSecret string - InstallationID string - TokenRefreshSkew int - GraphQLPageSize int - RequestTimeoutMs int - RateLimitQPS int - ResolvedAuthMode string // computed: "pat" or "app" -} - -type GitConfig struct { - BaseBranch string - BranchPrefix string - FetchDepth int - ReuseRepoCache bool - UseWorktrees bool - CleanUntracked bool - PushRemoteName string - CommitAuthorName string - CommitAuthorEmail string -} - -type PollingConfig struct { - IntervalMs int -} - -type WorkspaceConfig struct { - Root string - RepoCacheDir string - WorktreeDir string - RemoveOnTerminal bool -} - -type HooksConfig struct { - AfterCreate string - BeforeRun string - AfterRun string - BeforeRemove string - TimeoutMs int -} - -type AgentConfig struct { - Kind string - LaunchMode string - Command string - DefaultModel string - MaxConcurrentAgents int - MaxTurns int - MaxRetryBackoffMs int - SessionReuseMode string - ReadTimeoutMs int - TurnTimeoutMs int - StallTimeoutMs int - EnableClientTools bool - EnableMCP bool - MaxConcurrentByStatus map[string]int - MaxConcurrentByRepo map[string]int - ProviderParams map[string]any -} - -type CodexConfig struct { - ApprovalPolicy string - ThreadSandbox string - TurnSandboxPolicy string - Listen string - SchemaBundleDir string - ProviderParams map[string]any -} - -type ClaudeConfig struct { - Model string - AllowedTools []string - MCPServers []any - ContinueOnPause bool - PermissionProfile any - EnableSubagents bool - ProviderParams map[string]any -} - -type OpenCodeConfig struct { - Model string - PermissionProfile any - ConfigFile string - ResumeSession bool - MCPServers []any - ProviderParams map[string]any -} - -type PullRequestConfig struct { - OpenPROnSuccess bool - DraftByDefault bool - ReuseExistingPR bool - HandoffProjectStatus string - CommentOnIssueWithPR bool - RequiredBeforeHandoff []string - CloseIssueOnMerge bool -} - -type ServerConfig struct { - Port int - Host string - ReadTimeoutMs int - WriteTimeoutMs int - CORSOrigins []string -} - -// NewServiceConfig builds a typed config from raw WORKFLOW.md front matter. -func NewServiceConfig(raw map[string]any) (*ServiceConfig, error) { - // Re-marshal and unmarshal through a structured intermediate to handle - // nested type conversions cleanly. - data, err := yaml.Marshal(raw) - if err != nil { - return nil, fmt.Errorf("config marshal: %w", err) - } - - var intermediate struct { - Tracker map[string]any `yaml:"tracker"` - GitHub map[string]any `yaml:"github"` - Git map[string]any `yaml:"git"` - Polling map[string]any `yaml:"polling"` - Workspace map[string]any `yaml:"workspace"` - Hooks map[string]any `yaml:"hooks"` - Agent map[string]any `yaml:"agent"` - Codex map[string]any `yaml:"codex"` - Claude map[string]any `yaml:"claude"` - OpenCode map[string]any `yaml:"opencode"` - PullRequest map[string]any `yaml:"pull_request"` - Server map[string]any `yaml:"server"` - } - if err := yaml.Unmarshal(data, &intermediate); err != nil { - return nil, fmt.Errorf("config unmarshal: %w", err) - } - - cfg := &ServiceConfig{} - cfg.applyDefaults() - - // Apply values from raw config - cfg.Tracker = parseTracker(intermediate.Tracker, cfg.Tracker) - cfg.GitHub = parseGitHub(intermediate.GitHub, cfg.GitHub) - cfg.Git = parseGit(intermediate.Git, cfg.Git) - cfg.Polling = parsePolling(intermediate.Polling, cfg.Polling) - cfg.Workspace = parseWorkspace(intermediate.Workspace, cfg.Workspace) - cfg.Hooks = parseHooks(intermediate.Hooks, cfg.Hooks) - cfg.Agent = parseAgent(intermediate.Agent, cfg.Agent) - cfg.Codex = parseCodex(intermediate.Codex, cfg.Codex) - cfg.Claude = parseClaude(intermediate.Claude, cfg.Claude) - cfg.OpenCode = parseOpenCode(intermediate.OpenCode, cfg.OpenCode) - cfg.PullRequest = parsePullRequest(intermediate.PullRequest, cfg.PullRequest) - cfg.Server = parseServer(intermediate.Server, cfg.Server) - - // Resolve environment variables - cfg.resolveEnvVars() - - // Resolve path expansion - cfg.resolvePathExpansion() - - // Derive workspace defaults - cfg.deriveWorkspaceDefaults() - - // Resolve auth mode - cfg.resolveAuthMode() - - return cfg, nil -} - -func (c *ServiceConfig) applyDefaults() { - c.Tracker = TrackerConfig{ - ProjectScope: "organization", - StatusFieldName: "Status", - ActiveValues: []string{"Todo", "Ready", "In Progress"}, - TerminalValues: []string{"Done", "Closed", "Cancelled", "Canceled", "Duplicate"}, - PriorityFieldName: "Priority", - ExecutableItemTypes: []string{"issue"}, - RequireIssueBacking: true, - } - c.GitHub = GitHubConfig{ - AuthMode: "auto", - APIURL: "https://api.github.com", - TokenRefreshSkew: 300000, - GraphQLPageSize: 50, - RequestTimeoutMs: 30000, - RateLimitQPS: 10, - } - c.Git = GitConfig{ - BranchPrefix: "symphony/", - FetchDepth: 0, - ReuseRepoCache: true, - UseWorktrees: true, - PushRemoteName: "origin", - } - c.Polling = PollingConfig{IntervalMs: 30000} - c.Workspace = WorkspaceConfig{ - RemoveOnTerminal: true, - } - c.Hooks = HooksConfig{TimeoutMs: 60000} - c.Agent = AgentConfig{ - MaxConcurrentAgents: 10, - MaxTurns: 20, - MaxRetryBackoffMs: 300000, - SessionReuseMode: "continue_if_supported", - ReadTimeoutMs: 5000, - TurnTimeoutMs: 3600000, - StallTimeoutMs: 300000, - EnableClientTools: true, - EnableMCP: true, - } - c.Claude = ClaudeConfig{ - ContinueOnPause: true, - } - c.OpenCode = OpenCodeConfig{ - ResumeSession: true, - } - c.Codex = CodexConfig{ - Listen: "stdio://", - } - c.PullRequest = PullRequestConfig{ - OpenPROnSuccess: true, - DraftByDefault: true, - ReuseExistingPR: true, - CommentOnIssueWithPR: true, - } - c.Server = ServerConfig{ - Host: "0.0.0.0", - ReadTimeoutMs: 30000, - WriteTimeoutMs: 60000, - } -} - -func (c *ServiceConfig) resolveEnvVars() { - c.GitHub.Token = resolveEnv(c.GitHub.Token) - c.GitHub.AppID = resolveEnv(c.GitHub.AppID) - c.GitHub.PrivateKey = resolveEnv(c.GitHub.PrivateKey) - c.GitHub.WebhookSecret = resolveEnv(c.GitHub.WebhookSecret) - c.GitHub.InstallationID = resolveEnv(c.GitHub.InstallationID) -} - -func (c *ServiceConfig) resolveAuthMode() { - mode := c.GitHub.AuthMode - if mode == "" { - mode = "auto" - } - - switch mode { - case "pat": - c.GitHub.ResolvedAuthMode = "pat" - case "app": - c.GitHub.ResolvedAuthMode = "app" - case "auto": - hasApp := c.GitHub.AppID != "" && c.GitHub.PrivateKey != "" - hasPAT := c.GitHub.Token != "" - if hasApp { - c.GitHub.ResolvedAuthMode = "app" - } else if hasPAT { - c.GitHub.ResolvedAuthMode = "pat" - } - // If neither, leave empty — validation will catch it - } -} - -// resolveEnv resolves a $VAR_NAME reference to its environment variable value. -func resolveEnv(val string) string { - if strings.HasPrefix(val, "$") { - envName := val[1:] - return os.Getenv(envName) - } - return val -} - -// Helper functions to parse individual config sections from raw maps. - -func parseTracker(raw map[string]any, def TrackerConfig) TrackerConfig { - if raw == nil { - return def - } - if v, ok := raw["kind"].(string); ok { def.Kind = v } - if v, ok := raw["owner"].(string); ok { def.Owner = v } - if v, ok := raw["project_number"].(int); ok { def.ProjectNumber = v } - if v, ok := raw["project_scope"].(string); ok { def.ProjectScope = v } - if v, ok := raw["status_field_name"].(string); ok { def.StatusFieldName = v } - if v, ok := raw["active_values"].([]any); ok { def.ActiveValues = toStringSlice(v) } - if v, ok := raw["terminal_values"].([]any); ok { def.TerminalValues = toStringSlice(v) } - if v, ok := raw["priority_field_name"].(string); ok { def.PriorityFieldName = v } - if v, ok := raw["priority_value_map"].(map[string]any); ok { def.PriorityValueMap = toIntMap(v) } - if v, ok := raw["executable_item_types"].([]any); ok { def.ExecutableItemTypes = toStringSlice(v) } - if v, ok := raw["require_issue_backing"].(bool); ok { def.RequireIssueBacking = v } - if v, ok := raw["allow_draft_issue_conversion"].(bool); ok { def.AllowDraftIssueConvert = v } - if v, ok := raw["repo_allowlist"].([]any); ok { def.RepoAllowlist = toStringSlice(v) } - if v, ok := raw["repo_denylist"].([]any); ok { def.RepoDenylist = toStringSlice(v) } - if v, ok := raw["required_labels"].([]any); ok { def.RequiredLabels = toStringSlice(v) } - if v, ok := raw["blocked_status_values"].([]any); ok { def.BlockedStatusValues = toStringSlice(v) } - return def -} - -func parseGitHub(raw map[string]any, def GitHubConfig) GitHubConfig { - if raw == nil { - return def - } - if v, ok := raw["auth_mode"].(string); ok { def.AuthMode = v } - if v, ok := raw["api_url"].(string); ok { def.APIURL = v } - if v, ok := raw["token"].(string); ok { def.Token = v } - if v, ok := raw["app_id"].(string); ok { def.AppID = v } - if v, ok := raw["private_key"].(string); ok { def.PrivateKey = v } - if v, ok := raw["webhook_secret"].(string); ok { def.WebhookSecret = v } - if v, ok := raw["installation_id"].(string); ok { def.InstallationID = v } - if v, ok := raw["token_refresh_skew_ms"].(int); ok { def.TokenRefreshSkew = v } - if v, ok := raw["graphql_page_size"].(int); ok { def.GraphQLPageSize = v } - if v, ok := raw["request_timeout_ms"].(int); ok { def.RequestTimeoutMs = v } - if v, ok := raw["rate_limit_qps"].(int); ok { def.RateLimitQPS = v } - return def -} - -func parseGit(raw map[string]any, def GitConfig) GitConfig { - if raw == nil { - return def - } - if v, ok := raw["base_branch"].(string); ok { def.BaseBranch = v } - if v, ok := raw["branch_prefix"].(string); ok { def.BranchPrefix = v } - if v, ok := raw["fetch_depth"].(int); ok { def.FetchDepth = v } - if v, ok := raw["reuse_repo_cache"].(bool); ok { def.ReuseRepoCache = v } - if v, ok := raw["use_worktrees"].(bool); ok { def.UseWorktrees = v } - if v, ok := raw["clean_untracked_before_run"].(bool); ok { def.CleanUntracked = v } - if v, ok := raw["push_remote_name"].(string); ok { def.PushRemoteName = v } - if v, ok := raw["commit_author_name"].(string); ok { def.CommitAuthorName = v } - if v, ok := raw["commit_author_email"].(string); ok { def.CommitAuthorEmail = v } - return def -} - -func parsePolling(raw map[string]any, def PollingConfig) PollingConfig { - if raw == nil { - return def - } - if v, ok := raw["interval_ms"].(int); ok { def.IntervalMs = v } - return def -} - -func parseWorkspace(raw map[string]any, def WorkspaceConfig) WorkspaceConfig { - if raw == nil { - return def - } - if v, ok := raw["root"].(string); ok { def.Root = v } - if v, ok := raw["repo_cache_dir"].(string); ok { def.RepoCacheDir = v } - if v, ok := raw["worktree_dir"].(string); ok { def.WorktreeDir = v } - if v, ok := raw["remove_on_terminal"].(bool); ok { def.RemoveOnTerminal = v } - return def -} - -func parseHooks(raw map[string]any, def HooksConfig) HooksConfig { - if raw == nil { - return def - } - if v, ok := raw["after_create"].(string); ok { def.AfterCreate = v } - if v, ok := raw["before_run"].(string); ok { def.BeforeRun = v } - if v, ok := raw["after_run"].(string); ok { def.AfterRun = v } - if v, ok := raw["before_remove"].(string); ok { def.BeforeRemove = v } - if v, ok := raw["timeout_ms"].(int); ok { def.TimeoutMs = v } - return def -} - -func parseAgent(raw map[string]any, def AgentConfig) AgentConfig { - if raw == nil { - return def - } - if v, ok := raw["kind"].(string); ok { def.Kind = v } - if v, ok := raw["launch_mode"].(string); ok { def.LaunchMode = v } - if v, ok := raw["command"].(string); ok { def.Command = v } - if v, ok := raw["default_model"].(string); ok { def.DefaultModel = v } - if v, ok := raw["max_concurrent_agents"].(int); ok { def.MaxConcurrentAgents = v } - if v, ok := raw["max_turns"].(int); ok { def.MaxTurns = v } - if v, ok := raw["max_retry_backoff_ms"].(int); ok { def.MaxRetryBackoffMs = v } - if v, ok := raw["session_reuse_mode"].(string); ok { def.SessionReuseMode = v } - if v, ok := raw["read_timeout_ms"].(int); ok { def.ReadTimeoutMs = v } - if v, ok := raw["turn_timeout_ms"].(int); ok { def.TurnTimeoutMs = v } - if v, ok := raw["stall_timeout_ms"].(int); ok { def.StallTimeoutMs = v } - if v, ok := raw["enable_client_tools"].(bool); ok { def.EnableClientTools = v } - if v, ok := raw["enable_mcp"].(bool); ok { def.EnableMCP = v } - if v, ok := raw["max_concurrent_agents_by_project_status"].(map[string]any); ok { - def.MaxConcurrentByStatus = toIntMap(v) - } - if v, ok := raw["max_concurrent_agents_by_repo"].(map[string]any); ok { - def.MaxConcurrentByRepo = toIntMap(v) - } - if v, ok := raw["provider_params"].(map[string]any); ok { def.ProviderParams = v } - return def -} - -func parseClaude(raw map[string]any, def ClaudeConfig) ClaudeConfig { - if raw == nil { - return def - } - if v, ok := raw["model"].(string); ok { def.Model = v } - if v, ok := raw["continue_on_pause_turn"].(bool); ok { def.ContinueOnPause = v } - if v, ok := raw["enable_subagents"].(bool); ok { def.EnableSubagents = v } - if v, ok := raw["allowed_tools"].([]any); ok { def.AllowedTools = toStringSlice(v) } - if v, ok := raw["mcp_servers"].([]any); ok { def.MCPServers = v } - if v, ok := raw["permission_profile"]; ok { def.PermissionProfile = v } - if v, ok := raw["provider_params"].(map[string]any); ok { def.ProviderParams = v } - return def -} - -func parseCodex(raw map[string]any, def CodexConfig) CodexConfig { - if raw == nil { - return def - } - if v, ok := raw["approval_policy"].(string); ok { def.ApprovalPolicy = v } - if v, ok := raw["thread_sandbox"].(string); ok { def.ThreadSandbox = v } - if v, ok := raw["turn_sandbox_policy"].(string); ok { def.TurnSandboxPolicy = v } - if v, ok := raw["listen"].(string); ok { def.Listen = v } - if v, ok := raw["schema_bundle_dir"].(string); ok { def.SchemaBundleDir = v } - if v, ok := raw["provider_params"].(map[string]any); ok { def.ProviderParams = v } - return def -} - -func parseOpenCode(raw map[string]any, def OpenCodeConfig) OpenCodeConfig { - if raw == nil { - return def - } - if v, ok := raw["model"].(string); ok { def.Model = v } - if v, ok := raw["permission_profile"]; ok { def.PermissionProfile = v } - if v, ok := raw["config_file"].(string); ok { def.ConfigFile = v } - if v, ok := raw["resume_session"].(bool); ok { def.ResumeSession = v } - if v, ok := raw["mcp_servers"].([]any); ok { def.MCPServers = v } - if v, ok := raw["provider_params"].(map[string]any); ok { def.ProviderParams = v } - return def -} - -func parsePullRequest(raw map[string]any, def PullRequestConfig) PullRequestConfig { - if raw == nil { - return def - } - if v, ok := raw["open_pr_on_success"].(bool); ok { def.OpenPROnSuccess = v } - if v, ok := raw["draft_by_default"].(bool); ok { def.DraftByDefault = v } - if v, ok := raw["reuse_existing_pr"].(bool); ok { def.ReuseExistingPR = v } - if v, ok := raw["handoff_project_status"].(string); ok { def.HandoffProjectStatus = v } - if v, ok := raw["comment_on_issue_with_pr"].(bool); ok { def.CommentOnIssueWithPR = v } - if v, ok := raw["close_issue_on_merge"].(bool); ok { def.CloseIssueOnMerge = v } - if v, ok := raw["required_before_handoff_checks"].([]any); ok { def.RequiredBeforeHandoff = toStringSlice(v) } - return def -} - -func parseServer(raw map[string]any, def ServerConfig) ServerConfig { - if raw == nil { - return def - } - if v, ok := raw["port"].(int); ok { def.Port = v } - if v, ok := raw["host"].(string); ok { def.Host = v } - if v, ok := raw["read_timeout_ms"].(int); ok { def.ReadTimeoutMs = v } - if v, ok := raw["write_timeout_ms"].(int); ok { def.WriteTimeoutMs = v } - if v, ok := raw["cors_origins"].([]any); ok { def.CORSOrigins = toStringSlice(v) } - return def -} - -func toStringSlice(raw []any) []string { - out := make([]string, 0, len(raw)) - for _, v := range raw { - if s, ok := v.(string); ok { - out = append(out, s) - } - } - return out -} - -func toIntMap(raw map[string]any) map[string]int { - out := make(map[string]int, len(raw)) - for k, v := range raw { - switch val := v.(type) { - case int: - out[k] = val - case float64: - out[k] = int(val) - } - } - return out -} - -func (c *ServiceConfig) resolvePathExpansion() { - c.Workspace.Root = expandPath(c.Workspace.Root) - c.Workspace.RepoCacheDir = expandPath(c.Workspace.RepoCacheDir) - c.Workspace.WorktreeDir = expandPath(c.Workspace.WorktreeDir) - c.Codex.SchemaBundleDir = expandPath(c.Codex.SchemaBundleDir) - c.OpenCode.ConfigFile = expandPath(c.OpenCode.ConfigFile) -} - -func (c *ServiceConfig) deriveWorkspaceDefaults() { - if c.Workspace.Root == "" { - c.Workspace.Root = filepath.Join(os.TempDir(), "symphony_workspaces") - } - if c.Workspace.RepoCacheDir == "" { - c.Workspace.RepoCacheDir = filepath.Join(c.Workspace.Root, "repo_cache") - } - if c.Workspace.WorktreeDir == "" { - c.Workspace.WorktreeDir = filepath.Join(c.Workspace.Root, "worktrees") - } -} - -// expandPath resolves ~ to home dir and $VAR in path strings. -func expandPath(p string) string { - if p == "" { - return p - } - if strings.HasPrefix(p, "~/") || p == "~" { - home, err := os.UserHomeDir() - if err == nil { - p = filepath.Join(home, p[1:]) - } - } - return os.ExpandEnv(p) -} diff --git a/internal/config/service_config_test.go b/internal/config/service_config_test.go deleted file mode 100644 index d3880c5..0000000 --- a/internal/config/service_config_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package config_test - -import ( - "os" - "path/filepath" - "testing" - - "github.com/shivamstaq/github-symphony/internal/config" -) - -func TestNewServiceConfig_Defaults(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "WORKFLOW.md") - content := `--- -tracker: - kind: github - owner: myorg - project_number: 1 -agent: - kind: claude_code ---- -prompt -` - os.WriteFile(path, []byte(content), 0644) - - wf, err := config.LoadWorkflow(path) - if err != nil { - t.Fatal(err) - } - - cfg, err := config.NewServiceConfig(wf.Config) - if err != nil { - t.Fatal(err) - } - - // Tracker defaults - if cfg.Tracker.StatusFieldName != "Status" { - t.Errorf("expected default StatusFieldName=Status, got %q", cfg.Tracker.StatusFieldName) - } - if len(cfg.Tracker.ActiveValues) != 3 { - t.Errorf("expected 3 default active values, got %d", len(cfg.Tracker.ActiveValues)) - } - if cfg.Tracker.ProjectScope != "organization" { - t.Errorf("expected default ProjectScope=organization, got %q", cfg.Tracker.ProjectScope) - } - - // Agent defaults - if cfg.Agent.MaxConcurrentAgents != 10 { - t.Errorf("expected default MaxConcurrentAgents=10, got %d", cfg.Agent.MaxConcurrentAgents) - } - if cfg.Agent.MaxTurns != 20 { - t.Errorf("expected default MaxTurns=20, got %d", cfg.Agent.MaxTurns) - } - if cfg.Agent.StallTimeoutMs != 300000 { - t.Errorf("expected default StallTimeoutMs=300000, got %d", cfg.Agent.StallTimeoutMs) - } - - // Git defaults - if cfg.Git.BranchPrefix != "symphony/" { - t.Errorf("expected default BranchPrefix=symphony/, got %q", cfg.Git.BranchPrefix) - } - if cfg.Git.UseWorktrees != true { - t.Error("expected default UseWorktrees=true") - } - - // Polling defaults - if cfg.Polling.IntervalMs != 30000 { - t.Errorf("expected default IntervalMs=30000, got %d", cfg.Polling.IntervalMs) - } - - // PR defaults - if cfg.PullRequest.OpenPROnSuccess != true { - t.Error("expected default OpenPROnSuccess=true") - } - if cfg.PullRequest.DraftByDefault != true { - t.Error("expected default DraftByDefault=true") - } -} - -func TestNewServiceConfig_EnvVarResolution(t *testing.T) { - t.Setenv("TEST_GITHUB_TOKEN", "ghp_test123") - - dir := t.TempDir() - path := filepath.Join(dir, "WORKFLOW.md") - content := `--- -tracker: - kind: github - owner: myorg - project_number: 1 -github: - token: $TEST_GITHUB_TOKEN -agent: - kind: claude_code ---- -prompt -` - os.WriteFile(path, []byte(content), 0644) - - wf, err := config.LoadWorkflow(path) - if err != nil { - t.Fatal(err) - } - - cfg, err := config.NewServiceConfig(wf.Config) - if err != nil { - t.Fatal(err) - } - - if cfg.GitHub.Token != "ghp_test123" { - t.Errorf("expected resolved token=ghp_test123, got %q", cfg.GitHub.Token) - } -} - -func TestNewServiceConfig_AuthModeAuto_PAT(t *testing.T) { - t.Setenv("MY_TOKEN", "ghp_abc") - - raw := map[string]any{ - "tracker": map[string]any{ - "kind": "github", - "owner": "org", - "project_number": 1, - }, - "github": map[string]any{ - "token": "$MY_TOKEN", - }, - "agent": map[string]any{ - "kind": "claude_code", - }, - } - - cfg, err := config.NewServiceConfig(raw) - if err != nil { - t.Fatal(err) - } - - if cfg.GitHub.ResolvedAuthMode != "pat" { - t.Errorf("expected resolved auth mode=pat, got %q", cfg.GitHub.ResolvedAuthMode) - } -} diff --git a/internal/config/symphony_config.go b/internal/config/symphony_config.go new file mode 100644 index 0000000..42dcead --- /dev/null +++ b/internal/config/symphony_config.go @@ -0,0 +1,309 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// SymphonyConfig is the typed runtime configuration parsed from symphony.yaml. +type SymphonyConfig struct { + Tracker TrackerV2Config `yaml:"tracker"` + Auth AuthConfig `yaml:"auth"` + Agent AgentV2Config `yaml:"agent"` + Git GitV2Config `yaml:"git"` + Workspace WorkspaceV2Config `yaml:"workspace"` + Polling PollingV2Config `yaml:"polling"` + PullRequest PullRequestV2Config `yaml:"pull_request"` + Hooks HooksV2Config `yaml:"hooks"` + PromptRouting PromptRoutingConfig `yaml:"prompt_routing"` + Server ServerV2Config `yaml:"server"` +} + +// TrackerV2Config configures the issue/project tracker. +type TrackerV2Config struct { + Kind string `yaml:"kind"` // "github" | "linear" + Owner string `yaml:"owner"` // GitHub org/user + ProjectNumber int `yaml:"project_number"` // GitHub Project V2 number + ProjectScope string `yaml:"project_scope"` // "organization" | "user" + StatusFieldName string `yaml:"status_field_name"` + ActiveValues []string `yaml:"active_values"` + TerminalValues []string `yaml:"terminal_values"` + BlockedValues []string `yaml:"blocked_values"` + PriorityFieldName string `yaml:"priority_field_name"` + PriorityValueMap map[string]int `yaml:"priority_value_map"` + ExecutableItemTypes []string `yaml:"executable_item_types"` + RequireIssueBacking bool `yaml:"require_issue_backing"` + RepoAllowlist []string `yaml:"repo_allowlist"` + RepoDenylist []string `yaml:"repo_denylist"` + RequiredLabels []string `yaml:"required_labels"` + // Linear-specific + LinearAPIKey string `yaml:"linear_api_key"` + LinearTeamID string `yaml:"linear_team_id"` +} + +// AuthConfig holds credentials for each service. +type AuthConfig struct { + GitHub GitHubAuthConfig `yaml:"github"` + Linear LinearAuthConfig `yaml:"linear"` +} + +type GitHubAuthConfig struct { + Mode string `yaml:"mode"` // "pat" | "app" | "auto" + Token string `yaml:"token"` // PAT or $VAR + APIURL string `yaml:"api_url"` + AppID string `yaml:"app_id"` + PrivateKey string `yaml:"private_key"` + InstallationID string `yaml:"installation_id"` + WebhookSecret string `yaml:"webhook_secret"` + ResolvedMode string `yaml:"-"` // computed at load time +} + +type LinearAuthConfig struct { + APIKey string `yaml:"api_key"` +} + +// AgentV2Config configures agent behavior. +type AgentV2Config struct { + Kind string `yaml:"kind"` // "claude_code" | "opencode" | "codex" + Command string `yaml:"command"` // override binary path + MaxConcurrent int `yaml:"max_concurrent"` + MaxTurns int `yaml:"max_turns"` + StallTimeoutMs int `yaml:"stall_timeout_ms"` + MaxRetryBackoffMs int `yaml:"max_retry_backoff_ms"` + MaxContinuationRetries int `yaml:"max_continuation_retries"` + SessionReuse bool `yaml:"session_reuse"` + MaxConcurrentByStatus map[string]int `yaml:"max_concurrent_by_status"` + MaxConcurrentByRepo map[string]int `yaml:"max_concurrent_by_repo"` + Budget BudgetConfig `yaml:"budget"` + Claude ClaudeV2Config `yaml:"claude"` + OpenCode OpenCodeV2Config `yaml:"opencode"` + Codex CodexV2Config `yaml:"codex"` +} + +type BudgetConfig struct { + MaxCostPerItemUSD float64 `yaml:"max_cost_per_item_usd"` + MaxCostTotalUSD float64 `yaml:"max_cost_total_usd"` + MaxTokensPerItem int `yaml:"max_tokens_per_item"` +} + +type ClaudeV2Config struct { + Model string `yaml:"model"` + PermissionProfile string `yaml:"permission_profile"` + AllowedTools []string `yaml:"allowed_tools"` +} + +type OpenCodeV2Config struct { + Model string `yaml:"model"` + ConfigFile string `yaml:"config_file"` +} + +type CodexV2Config struct { + ApprovalPolicy string `yaml:"approval_policy"` +} + +// GitV2Config configures git behavior. +type GitV2Config struct { + BranchPrefix string `yaml:"branch_prefix"` + FetchDepth int `yaml:"fetch_depth"` + UseWorktrees bool `yaml:"use_worktrees"` + PushRemote string `yaml:"push_remote"` + AuthorName string `yaml:"author_name"` + AuthorEmail string `yaml:"author_email"` +} + +// WorkspaceV2Config configures workspace directories. +type WorkspaceV2Config struct { + Root string `yaml:"root"` + RepoCacheDir string `yaml:"repo_cache_dir"` + WorktreeDir string `yaml:"worktree_dir"` + RemoveOnTerminal bool `yaml:"remove_on_terminal"` +} + +// PollingV2Config configures poll intervals. +type PollingV2Config struct { + IntervalMs int `yaml:"interval_ms"` +} + +// PullRequestV2Config configures PR creation and handoff. +type PullRequestV2Config struct { + OpenOnSuccess bool `yaml:"open_on_success"` + DraftByDefault bool `yaml:"draft_by_default"` + ReuseExisting bool `yaml:"reuse_existing"` + HandoffStatus string `yaml:"handoff_status"` + CommentOnIssue bool `yaml:"comment_on_issue"` + RequiredChecks []string `yaml:"required_checks"` +} + +// HooksV2Config configures lifecycle hooks. +type HooksV2Config struct { + AfterCreate string `yaml:"after_create"` + BeforeRun string `yaml:"before_run"` + AfterRun string `yaml:"after_run"` + BeforeRemove string `yaml:"before_remove"` + TimeoutMs int `yaml:"timeout_ms"` +} + +// PromptRoutingConfig configures custom field-based prompt routing. +type PromptRoutingConfig struct { + FieldName string `yaml:"field_name"` // GitHub Project custom field + Routes map[string]string `yaml:"routes"` // field value -> template filename + Default string `yaml:"default"` // fallback template +} + +// ServerV2Config configures the HTTP API server. +type ServerV2Config struct { + Port int `yaml:"port"` + Host string `yaml:"host"` +} + +// LoadSymphonyConfig reads and parses a symphony.yaml file. +func LoadSymphonyConfig(path string) (*SymphonyConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + return ParseSymphonyConfig(data) +} + +// ParseSymphonyConfig parses symphony.yaml content. +func ParseSymphonyConfig(data []byte) (*SymphonyConfig, error) { + cfg := &SymphonyConfig{} + cfg.applyDefaults() + + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + cfg.resolveEnvVars() + cfg.resolvePathExpansion() + cfg.deriveWorkspaceDefaults() + cfg.resolveAuthMode() + + return cfg, nil +} + +func (c *SymphonyConfig) applyDefaults() { + c.Tracker = TrackerV2Config{ + ProjectScope: "organization", + StatusFieldName: "Status", + ActiveValues: []string{"Todo", "Ready", "In Progress"}, + TerminalValues: []string{"Done", "Closed", "Cancelled", "Canceled", "Duplicate"}, + PriorityFieldName: "Priority", + ExecutableItemTypes: []string{"issue"}, + RequireIssueBacking: true, + } + c.Auth = AuthConfig{ + GitHub: GitHubAuthConfig{ + Mode: "auto", + APIURL: "https://api.github.com", + }, + } + c.Agent = AgentV2Config{ + Kind: "claude_code", + MaxConcurrent: 10, + MaxTurns: 20, + StallTimeoutMs: 300000, + MaxRetryBackoffMs: 300000, + MaxContinuationRetries: 10, + SessionReuse: true, + } + c.Git = GitV2Config{ + BranchPrefix: "symphony/", + UseWorktrees: true, + PushRemote: "origin", + AuthorName: "Symphony", + AuthorEmail: "symphony@noreply.github.com", + } + c.Workspace = WorkspaceV2Config{ + RemoveOnTerminal: true, + } + c.Polling = PollingV2Config{IntervalMs: 30000} + c.Hooks = HooksV2Config{TimeoutMs: 60000} + c.PullRequest = PullRequestV2Config{ + OpenOnSuccess: true, + DraftByDefault: true, + ReuseExisting: true, + CommentOnIssue: true, + } + c.PromptRouting = PromptRoutingConfig{ + Default: "default.md", + } + c.Server = ServerV2Config{ + Port: 9097, + Host: "0.0.0.0", + } +} + +func (c *SymphonyConfig) resolveEnvVars() { + c.Auth.GitHub.Token = resolveEnvV2(c.Auth.GitHub.Token) + c.Auth.GitHub.AppID = resolveEnvV2(c.Auth.GitHub.AppID) + c.Auth.GitHub.PrivateKey = resolveEnvV2(c.Auth.GitHub.PrivateKey) + c.Auth.GitHub.WebhookSecret = resolveEnvV2(c.Auth.GitHub.WebhookSecret) + c.Auth.GitHub.InstallationID = resolveEnvV2(c.Auth.GitHub.InstallationID) + c.Auth.Linear.APIKey = resolveEnvV2(c.Auth.Linear.APIKey) + c.Tracker.LinearAPIKey = resolveEnvV2(c.Tracker.LinearAPIKey) +} + +func (c *SymphonyConfig) resolvePathExpansion() { + c.Workspace.Root = expandPathV2(c.Workspace.Root) + c.Workspace.RepoCacheDir = expandPathV2(c.Workspace.RepoCacheDir) + c.Workspace.WorktreeDir = expandPathV2(c.Workspace.WorktreeDir) + c.Agent.OpenCode.ConfigFile = expandPathV2(c.Agent.OpenCode.ConfigFile) +} + +func (c *SymphonyConfig) deriveWorkspaceDefaults() { + if c.Workspace.Root == "" { + c.Workspace.Root = filepath.Join(os.TempDir(), "symphony_workspaces") + } + if c.Workspace.RepoCacheDir == "" { + c.Workspace.RepoCacheDir = filepath.Join(c.Workspace.Root, "repo_cache") + } + if c.Workspace.WorktreeDir == "" { + c.Workspace.WorktreeDir = filepath.Join(c.Workspace.Root, "worktrees") + } +} + +func (c *SymphonyConfig) resolveAuthMode() { + mode := c.Auth.GitHub.Mode + if mode == "" { + mode = "auto" + } + switch mode { + case "pat": + c.Auth.GitHub.ResolvedMode = "pat" + case "app": + c.Auth.GitHub.ResolvedMode = "app" + case "auto": + hasApp := c.Auth.GitHub.AppID != "" && c.Auth.GitHub.PrivateKey != "" + hasPAT := c.Auth.GitHub.Token != "" + if hasApp { + c.Auth.GitHub.ResolvedMode = "app" + } else if hasPAT { + c.Auth.GitHub.ResolvedMode = "pat" + } + } +} + +func resolveEnvV2(val string) string { + if strings.HasPrefix(val, "$") { + return os.Getenv(val[1:]) + } + return val +} + +func expandPathV2(p string) string { + if p == "" { + return p + } + if strings.HasPrefix(p, "~/") || p == "~" { + home, err := os.UserHomeDir() + if err == nil { + p = filepath.Join(home, p[1:]) + } + } + return os.ExpandEnv(p) +} diff --git a/internal/config/symphony_config_test.go b/internal/config/symphony_config_test.go new file mode 100644 index 0000000..4ea0d20 --- /dev/null +++ b/internal/config/symphony_config_test.go @@ -0,0 +1,328 @@ +package config + +import ( + "os" + "strings" + "testing" +) + +func TestParseSymphonyConfig_Minimal(t *testing.T) { + yaml := ` +tracker: + kind: github + owner: myorg + project_number: 42 +auth: + github: + token: test-token-123 +agent: + kind: claude_code +` + cfg, err := ParseSymphonyConfig([]byte(yaml)) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + if cfg.Tracker.Kind != "github" { + t.Errorf("tracker.kind = %q, want 'github'", cfg.Tracker.Kind) + } + if cfg.Tracker.Owner != "myorg" { + t.Errorf("tracker.owner = %q, want 'myorg'", cfg.Tracker.Owner) + } + if cfg.Tracker.ProjectNumber != 42 { + t.Errorf("tracker.project_number = %d, want 42", cfg.Tracker.ProjectNumber) + } + if cfg.Auth.GitHub.Token != "test-token-123" { + t.Errorf("auth.github.token = %q, want 'test-token-123'", cfg.Auth.GitHub.Token) + } + if cfg.Auth.GitHub.ResolvedMode != "pat" { + t.Errorf("auth.github.resolved_mode = %q, want 'pat'", cfg.Auth.GitHub.ResolvedMode) + } + if cfg.Agent.Kind != "claude_code" { + t.Errorf("agent.kind = %q, want 'claude_code'", cfg.Agent.Kind) + } +} + +func TestParseSymphonyConfig_Defaults(t *testing.T) { + yaml := ` +tracker: + kind: github + owner: myorg + project_number: 1 +auth: + github: + token: tok +agent: + kind: claude_code +` + cfg, err := ParseSymphonyConfig([]byte(yaml)) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + // Check defaults applied + if cfg.Tracker.ProjectScope != "organization" { + t.Errorf("default project_scope = %q, want 'organization'", cfg.Tracker.ProjectScope) + } + if cfg.Tracker.StatusFieldName != "Status" { + t.Errorf("default status_field_name = %q, want 'Status'", cfg.Tracker.StatusFieldName) + } + if cfg.Agent.MaxConcurrent != 10 { + t.Errorf("default max_concurrent = %d, want 10", cfg.Agent.MaxConcurrent) + } + if cfg.Agent.MaxTurns != 20 { + t.Errorf("default max_turns = %d, want 20", cfg.Agent.MaxTurns) + } + if cfg.Git.BranchPrefix != "symphony/" { + t.Errorf("default branch_prefix = %q, want 'symphony/'", cfg.Git.BranchPrefix) + } + if !cfg.Git.UseWorktrees { + t.Error("default use_worktrees should be true") + } + if cfg.Polling.IntervalMs != 30000 { + t.Errorf("default interval_ms = %d, want 30000", cfg.Polling.IntervalMs) + } + if !cfg.PullRequest.OpenOnSuccess { + t.Error("default open_on_success should be true") + } + if !cfg.PullRequest.DraftByDefault { + t.Error("default draft_by_default should be true") + } + if cfg.PromptRouting.Default != "default.md" { + t.Errorf("default prompt_routing.default = %q, want 'default.md'", cfg.PromptRouting.Default) + } + if cfg.Server.Port != 9097 { + t.Errorf("default server.port = %d, want 9097", cfg.Server.Port) + } +} + +func TestParseSymphonyConfig_FullSchema(t *testing.T) { + yaml := ` +tracker: + kind: github + owner: myorg + project_number: 42 + project_scope: user + status_field_name: MyStatus + active_values: [Ready, Working] + terminal_values: [Done] + blocked_values: [Blocked] + priority_field_name: Urgency + priority_value_map: + P0: 0 + P1: 1 + executable_item_types: [issue, draft_issue] + require_issue_backing: false + repo_allowlist: [myorg/repo1] + repo_denylist: [myorg/repo2] + required_labels: [agent-ready] +auth: + github: + mode: pat + token: ghp_test123 + api_url: https://api.github.example.com + webhook_secret: whsec_test +agent: + kind: claude_code + command: /usr/local/bin/claude + max_concurrent: 5 + max_turns: 10 + stall_timeout_ms: 600000 + max_retry_backoff_ms: 120000 + max_continuation_retries: 3 + session_reuse: false + budget: + max_cost_per_item_usd: 10.0 + max_cost_total_usd: 100.0 + max_tokens_per_item: 500000 + claude: + model: opus + permission_profile: bypassPermissions + allowed_tools: [Read, Edit, Write] +git: + branch_prefix: agent/ + fetch_depth: 1 + use_worktrees: false + push_remote: upstream + author_name: Bot + author_email: bot@example.com +polling: + interval_ms: 60000 +pull_request: + open_on_success: true + draft_by_default: false + reuse_existing: false + handoff_status: Human Review + comment_on_issue: false + required_checks: [ci/build, ci/test] +hooks: + before_run: "echo before" + after_run: "echo after" + timeout_ms: 30000 +prompt_routing: + field_name: Type + routes: + bug: bug_fix.md + feature: feature.md + default: default.md +server: + port: 8080 + host: 127.0.0.1 +` + cfg, err := ParseSymphonyConfig([]byte(yaml)) + if err != nil { + t.Fatalf("parse error: %v", err) + } + + // Spot-check overrides + if cfg.Tracker.ProjectScope != "user" { + t.Errorf("project_scope = %q, want 'user'", cfg.Tracker.ProjectScope) + } + if cfg.Agent.MaxConcurrent != 5 { + t.Errorf("max_concurrent = %d, want 5", cfg.Agent.MaxConcurrent) + } + if cfg.Agent.Budget.MaxCostPerItemUSD != 10.0 { + t.Errorf("budget.max_cost_per_item_usd = %f, want 10.0", cfg.Agent.Budget.MaxCostPerItemUSD) + } + if cfg.Agent.Claude.Model != "opus" { + t.Errorf("claude.model = %q, want 'opus'", cfg.Agent.Claude.Model) + } + if len(cfg.Agent.Claude.AllowedTools) != 3 { + t.Errorf("claude.allowed_tools len = %d, want 3", len(cfg.Agent.Claude.AllowedTools)) + } + if cfg.Git.BranchPrefix != "agent/" { + t.Errorf("branch_prefix = %q, want 'agent/'", cfg.Git.BranchPrefix) + } + if cfg.PullRequest.HandoffStatus != "Human Review" { + t.Errorf("handoff_status = %q, want 'Human Review'", cfg.PullRequest.HandoffStatus) + } + if len(cfg.PullRequest.RequiredChecks) != 2 { + t.Errorf("required_checks len = %d, want 2", len(cfg.PullRequest.RequiredChecks)) + } + if cfg.PromptRouting.FieldName != "Type" { + t.Errorf("field_name = %q, want 'Type'", cfg.PromptRouting.FieldName) + } + if cfg.PromptRouting.Routes["bug"] != "bug_fix.md" { + t.Errorf("routes[bug] = %q, want 'bug_fix.md'", cfg.PromptRouting.Routes["bug"]) + } + if cfg.Hooks.BeforeRun != "echo before" { + t.Errorf("hooks.before_run = %q, want 'echo before'", cfg.Hooks.BeforeRun) + } + if cfg.Server.Port != 8080 { + t.Errorf("server.port = %d, want 8080", cfg.Server.Port) + } +} + +func TestParseSymphonyConfig_EnvVarResolution(t *testing.T) { + os.Setenv("SYMPHONY_TEST_TOKEN", "resolved-token-xyz") + defer os.Unsetenv("SYMPHONY_TEST_TOKEN") + + yaml := ` +tracker: + kind: github + owner: org + project_number: 1 +auth: + github: + token: $SYMPHONY_TEST_TOKEN +agent: + kind: claude_code +` + cfg, err := ParseSymphonyConfig([]byte(yaml)) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if cfg.Auth.GitHub.Token != "resolved-token-xyz" { + t.Errorf("token = %q, want 'resolved-token-xyz'", cfg.Auth.GitHub.Token) + } + if cfg.Auth.GitHub.ResolvedMode != "pat" { + t.Errorf("resolved auth mode = %q, want 'pat'", cfg.Auth.GitHub.ResolvedMode) + } +} + +func TestValidateSymphonyConfig_Valid(t *testing.T) { + yaml := ` +tracker: + kind: github + owner: org + project_number: 1 +auth: + github: + token: test-token +agent: + kind: claude_code +` + cfg, err := ParseSymphonyConfig([]byte(yaml)) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if err := ValidateSymphonyConfig(cfg); err != nil { + t.Errorf("expected valid, got: %v", err) + } +} + +func TestValidateSymphonyConfig_MissingRequired(t *testing.T) { + tests := []struct { + name string + yaml string + wantErr string + }{ + { + "missing tracker.kind", + `tracker: {owner: org, project_number: 1} +auth: {github: {token: t}} +agent: {kind: claude_code}`, + "tracker.kind is required", + }, + { + "missing tracker.owner", + `tracker: {kind: github, project_number: 1} +auth: {github: {token: t}} +agent: {kind: claude_code}`, + "tracker.owner is required", + }, + { + "missing project_number", + `tracker: {kind: github, owner: org} +auth: {github: {token: t}} +agent: {kind: claude_code}`, + "tracker.project_number must be > 0", + }, + { + "missing auth", + `tracker: {kind: github, owner: org, project_number: 1} +agent: {kind: claude_code}`, + "no credentials found", + }, + { + "invalid agent kind", + `tracker: {kind: github, owner: org, project_number: 1} +auth: {github: {token: t}} +agent: {kind: gpt4}`, + "must be one of claude_code", + }, + { + "invalid tracker kind", + `tracker: {kind: jira, owner: org, project_number: 1} +auth: {github: {token: t}} +agent: {kind: claude_code}`, + "must be 'github' or 'linear'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := ParseSymphonyConfig([]byte(tt.yaml)) + if err != nil { + t.Fatalf("parse error: %v", err) + } + err = ValidateSymphonyConfig(cfg) + if err == nil { + t.Fatal("expected validation error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error %q does not contain %q", err.Error(), tt.wantErr) + } + }) + } +} diff --git a/internal/config/symphony_validate.go b/internal/config/symphony_validate.go new file mode 100644 index 0000000..d6e3abc --- /dev/null +++ b/internal/config/symphony_validate.go @@ -0,0 +1,91 @@ +package config + +import ( + "fmt" + "strings" +) + +// ConfigErrors collects multiple config validation failures. +type ConfigErrors struct { + Errors []string +} + +func (e *ConfigErrors) Error() string { + return fmt.Sprintf("config validation failed:\n - %s", strings.Join(e.Errors, "\n - ")) +} + +func (e *ConfigErrors) add(msg string) { + e.Errors = append(e.Errors, msg) +} + +func (e *ConfigErrors) hasErrors() bool { + return len(e.Errors) > 0 +} + +// ValidateSymphonyConfig checks that all required fields are present and values are sane. +func ValidateSymphonyConfig(cfg *SymphonyConfig) error { + ve := &ConfigErrors{} + + // Tracker + if cfg.Tracker.Kind == "" { + ve.add("tracker.kind is required") + } else { + switch cfg.Tracker.Kind { + case "github": + if cfg.Tracker.Owner == "" { + ve.add("tracker.owner is required for GitHub tracker") + } + if cfg.Tracker.ProjectNumber <= 0 { + ve.add("tracker.project_number must be > 0 for GitHub tracker") + } + case "linear": + // Linear validation will be added in Slice 4 + default: + ve.add(fmt.Sprintf("tracker.kind must be 'github' or 'linear', got %q", cfg.Tracker.Kind)) + } + } + + if len(cfg.Tracker.ActiveValues) == 0 { + ve.add("tracker.active_values must have at least one value") + } + if len(cfg.Tracker.TerminalValues) == 0 { + ve.add("tracker.terminal_values must have at least one value") + } + + // Auth + if cfg.Tracker.Kind == "github" { + if cfg.Auth.GitHub.ResolvedMode == "" { + ve.add("auth.github: no credentials found — set token ($GITHUB_TOKEN) or app_id + private_key") + } + } + + // Agent + validAgentKinds := map[string]bool{"claude_code": true, "opencode": true, "codex": true} + if cfg.Agent.Kind == "" { + ve.add("agent.kind is required") + } else if !validAgentKinds[cfg.Agent.Kind] { + ve.add(fmt.Sprintf("agent.kind must be one of claude_code, opencode, codex — got %q", cfg.Agent.Kind)) + } + + if cfg.Agent.MaxConcurrent <= 0 { + ve.add("agent.max_concurrent must be > 0") + } + if cfg.Agent.MaxTurns <= 0 { + ve.add("agent.max_turns must be > 0") + } + + // Polling + if cfg.Polling.IntervalMs <= 0 { + ve.add("polling.interval_ms must be > 0") + } + + // Prompt routing + if cfg.PromptRouting.Default == "" { + ve.add("prompt_routing.default is required") + } + + if ve.hasErrors() { + return ve + } + return nil +} diff --git a/internal/config/validation.go b/internal/config/validation.go deleted file mode 100644 index 380caee..0000000 --- a/internal/config/validation.go +++ /dev/null @@ -1,59 +0,0 @@ -package config - -import "fmt" - -// ValidationError represents a config validation failure. -type ValidationError struct { - Field string - Message string -} - -func (e *ValidationError) Error() string { - return fmt.Sprintf("config validation: %s: %s", e.Field, e.Message) -} - -// ValidateForDispatch checks that the config is sufficient to start dispatching work. -func ValidateForDispatch(cfg *ServiceConfig) error { - if cfg.Tracker.Kind == "" { - return &ValidationError{Field: "tracker.kind", Message: "required"} - } - if cfg.Tracker.Kind != "github" { - return &ValidationError{Field: "tracker.kind", Message: fmt.Sprintf("unsupported value %q, must be \"github\"", cfg.Tracker.Kind)} - } - if cfg.Tracker.Owner == "" { - return &ValidationError{Field: "tracker.owner", Message: "required"} - } - if cfg.Tracker.ProjectNumber == 0 { - return &ValidationError{Field: "tracker.project_number", Message: "required"} - } - - // Auth validation - if cfg.GitHub.ResolvedAuthMode == "" { - hasPAT := cfg.GitHub.Token != "" - hasApp := cfg.GitHub.AppID != "" && cfg.GitHub.PrivateKey != "" - if !hasPAT && !hasApp { - return &ValidationError{Field: "github", Message: "no auth credentials: set github.token ($GITHUB_TOKEN) or github.app_id + github.private_key"} - } - } - if cfg.GitHub.ResolvedAuthMode == "app" { - if cfg.GitHub.AppID == "" { - return &ValidationError{Field: "github.app_id", Message: "required for app auth mode"} - } - if cfg.GitHub.PrivateKey == "" { - return &ValidationError{Field: "github.private_key", Message: "required for app auth mode"} - } - } - - // Agent validation - if cfg.Agent.Kind == "" { - return &ValidationError{Field: "agent.kind", Message: "required"} - } - switch cfg.Agent.Kind { - case "codex", "claude_code", "opencode": - // valid - default: - return &ValidationError{Field: "agent.kind", Message: fmt.Sprintf("unsupported value %q", cfg.Agent.Kind)} - } - - return nil -} diff --git a/internal/config/validation_test.go b/internal/config/validation_test.go deleted file mode 100644 index 134900c..0000000 --- a/internal/config/validation_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package config_test - -import ( - "testing" - - "github.com/shivamstaq/github-symphony/internal/config" -) - -func TestValidate_MinimalValid(t *testing.T) { - t.Setenv("TK", "ghp_test") - cfg := minimalConfig(t) - if err := config.ValidateForDispatch(cfg); err != nil { - t.Fatalf("expected valid config, got: %v", err) - } -} - -func TestValidate_MissingTrackerKind(t *testing.T) { - t.Setenv("TK", "ghp_test") - cfg := minimalConfig(t) - cfg.Tracker.Kind = "" - err := config.ValidateForDispatch(cfg) - if err == nil { - t.Fatal("expected validation error for missing tracker.kind") - } -} - -func TestValidate_MissingOwner(t *testing.T) { - t.Setenv("TK", "ghp_test") - cfg := minimalConfig(t) - cfg.Tracker.Owner = "" - err := config.ValidateForDispatch(cfg) - if err == nil { - t.Fatal("expected validation error for missing tracker.owner") - } -} - -func TestValidate_MissingAuth(t *testing.T) { - cfg := minimalConfig(t) - cfg.GitHub.Token = "" - cfg.GitHub.AppID = "" - cfg.GitHub.PrivateKey = "" - cfg.GitHub.ResolvedAuthMode = "" - err := config.ValidateForDispatch(cfg) - if err == nil { - t.Fatal("expected validation error for missing auth credentials") - } -} - -func TestValidate_UnsupportedAgentKind(t *testing.T) { - t.Setenv("TK", "ghp_test") - cfg := minimalConfig(t) - cfg.Agent.Kind = "unsupported_agent" - err := config.ValidateForDispatch(cfg) - if err == nil { - t.Fatal("expected validation error for unsupported agent kind") - } -} - -func minimalConfig(t *testing.T) *config.ServiceConfig { - t.Helper() - raw := map[string]any{ - "tracker": map[string]any{ - "kind": "github", - "owner": "org", - "project_number": 1, - }, - "github": map[string]any{ - "token": "$TK", - }, - "agent": map[string]any{ - "kind": "claude_code", - }, - } - cfg, err := config.NewServiceConfig(raw) - if err != nil { - t.Fatal(err) - } - return cfg -} diff --git a/internal/config/watcher.go b/internal/config/watcher.go deleted file mode 100644 index 0a8f609..0000000 --- a/internal/config/watcher.go +++ /dev/null @@ -1,99 +0,0 @@ -package config - -import ( - "log/slog" - "sync" - "time" - - "github.com/fsnotify/fsnotify" -) - -// WatcherCallback is called when WORKFLOW.md changes with the new parsed workflow. -type WatcherCallback func(wf *WorkflowDefinition) - -// Watcher monitors WORKFLOW.md for changes and triggers a callback. -type Watcher struct { - path string - callback WatcherCallback - watcher *fsnotify.Watcher - done chan struct{} - mu sync.Mutex -} - -// NewWatcher creates a file watcher with debounced change detection. -func NewWatcher(path string, callback WatcherCallback) (*Watcher, error) { - fw, err := fsnotify.NewWatcher() - if err != nil { - return nil, err - } - - if err := fw.Add(path); err != nil { - _ = fw.Close() - return nil, err - } - - w := &Watcher{ - path: path, - callback: callback, - watcher: fw, - done: make(chan struct{}), - } - - go w.loop() - return w, nil -} - -// Close stops the watcher. -func (w *Watcher) Close() error { - close(w.done) - return w.watcher.Close() -} - -func (w *Watcher) loop() { - var debounce *time.Timer - - for { - select { - case <-w.done: - if debounce != nil { - debounce.Stop() - } - return - - case event, ok := <-w.watcher.Events: - if !ok { - return - } - if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) { - // Debounce: wait 200ms before triggering reload - if debounce != nil { - debounce.Stop() - } - debounce = time.AfterFunc(200*time.Millisecond, func() { - w.reload() - }) - } - - case err, ok := <-w.watcher.Errors: - if !ok { - return - } - slog.Error("watcher error", "error", err) - } - } -} - -func (w *Watcher) reload() { - w.mu.Lock() - defer w.mu.Unlock() - - slog.Info("workflow file changed, reloading", "path", w.path) - - wf, err := LoadWorkflow(w.path) - if err != nil { - slog.Error("workflow reload failed, keeping last good config", "error", err) - return - } - - w.callback(wf) -} diff --git a/internal/config/watcher_invalid_test.go b/internal/config/watcher_invalid_test.go deleted file mode 100644 index 47e84f0..0000000 --- a/internal/config/watcher_invalid_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package config_test - -import ( - "os" - "path/filepath" - "sync/atomic" - "testing" - "time" - - "github.com/shivamstaq/github-symphony/internal/config" -) - -func TestWatcher_InvalidReloadKeepsGoodConfig(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "WORKFLOW.md") - - // Start with valid config - valid := "---\ntracker:\n kind: github\n---\nprompt v1\n" - os.WriteFile(path, []byte(valid), 0644) - - var callCount atomic.Int32 - - w, err := config.NewWatcher(path, func(wf *config.WorkflowDefinition) { - callCount.Add(1) - }) - if err != nil { - t.Fatal(err) - } - defer w.Close() - - time.Sleep(100 * time.Millisecond) - - // Write invalid YAML — should NOT trigger callback - invalid := "---\nthis is: [broken: yaml\n---\nprompt\n" - os.WriteFile(path, []byte(invalid), 0644) - - time.Sleep(500 * time.Millisecond) - - if callCount.Load() != 0 { - t.Errorf("expected callback NOT to be invoked for invalid reload, but was called %d times", callCount.Load()) - } - - // Now write valid YAML again — SHOULD trigger callback - valid2 := "---\ntracker:\n kind: github\n owner: org2\n---\nprompt v2\n" - os.WriteFile(path, []byte(valid2), 0644) - - time.Sleep(500 * time.Millisecond) - - if callCount.Load() < 1 { - t.Error("expected callback to be invoked after valid reload") - } -} diff --git a/internal/config/watcher_test.go b/internal/config/watcher_test.go deleted file mode 100644 index cd9c578..0000000 --- a/internal/config/watcher_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package config_test - -import ( - "os" - "path/filepath" - "sync/atomic" - "testing" - "time" - - "github.com/shivamstaq/github-symphony/internal/config" -) - -func TestWatcher_DetectsChange(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "WORKFLOW.md") - - initial := `--- -tracker: - kind: github - owner: org1 - project_number: 1 -agent: - kind: claude_code ---- -prompt v1 -` - os.WriteFile(path, []byte(initial), 0644) - - var callCount atomic.Int32 - - w, err := config.NewWatcher(path, func(wf *config.WorkflowDefinition) { - callCount.Add(1) - }) - if err != nil { - t.Fatalf("NewWatcher: %v", err) - } - defer w.Close() - - // Modify the file - time.Sleep(100 * time.Millisecond) - updated := `--- -tracker: - kind: github - owner: org2 - project_number: 2 -agent: - kind: claude_code ---- -prompt v2 -` - os.WriteFile(path, []byte(updated), 0644) - - // Wait for debounced callback - time.Sleep(500 * time.Millisecond) - - if callCount.Load() < 1 { - t.Error("expected watcher callback to be invoked at least once") - } -} diff --git a/internal/config/workflow.go b/internal/config/workflow.go deleted file mode 100644 index 411d790..0000000 --- a/internal/config/workflow.go +++ /dev/null @@ -1,114 +0,0 @@ -package config - -import ( - "os" - "strings" - "text/template" - - "gopkg.in/yaml.v3" -) - -// WorkflowDefinition is the parsed WORKFLOW.md payload. -type WorkflowDefinition struct { - Config map[string]any - PromptTemplate string -} - -// LoadWorkflow reads a WORKFLOW.md file and splits it into config (YAML front matter) -// and prompt template (Markdown body). -func LoadWorkflow(path string) (*WorkflowDefinition, error) { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil, &WorkflowError{ - Kind: ErrMissingWorkflowFile, - Message: path, - Cause: err, - } - } - return nil, &WorkflowError{ - Kind: ErrMissingWorkflowFile, - Message: "cannot read " + path, - Cause: err, - } - } - - content := string(data) - frontMatter, body := splitFrontMatter(content) - - wf := &WorkflowDefinition{ - Config: make(map[string]any), - PromptTemplate: strings.TrimSpace(body), - } - - if frontMatter == "" { - return wf, nil - } - - var raw any - if err := yaml.Unmarshal([]byte(frontMatter), &raw); err != nil { - return nil, &WorkflowError{ - Kind: ErrWorkflowParseError, - Message: "invalid YAML front matter", - Cause: err, - } - } - - m, ok := raw.(map[string]any) - if !ok { - return nil, &WorkflowError{ - Kind: ErrFrontMatterNotAMap, - Message: "front matter must be a YAML map/object", - } - } - - wf.Config = m - - // Validate template syntax at parse time - if wf.PromptTemplate != "" { - if _, err := template.New("prompt").Option("missingkey=error").Parse(wf.PromptTemplate); err != nil { - return nil, &WorkflowError{ - Kind: ErrTemplateParseError, - Message: "invalid prompt template", - Cause: err, - } - } - } - - return wf, nil -} - -// splitFrontMatter splits content into YAML front matter and body. -// Front matter is delimited by leading "---" lines. -func splitFrontMatter(content string) (frontMatter, body string) { - if !strings.HasPrefix(content, "---") { - return "", content - } - - // Find the closing "---" - rest := content[3:] - // Skip the newline after opening --- - if len(rest) > 0 && rest[0] == '\n' { - rest = rest[1:] - } else if len(rest) > 1 && rest[0] == '\r' && rest[1] == '\n' { - rest = rest[2:] - } - - idx := strings.Index(rest, "\n---") - if idx == -1 { - // No closing delimiter — treat entire file as body - return "", content - } - - frontMatter = rest[:idx] - remaining := rest[idx+4:] // skip "\n---" - - // Skip newline after closing --- - if len(remaining) > 0 && remaining[0] == '\n' { - remaining = remaining[1:] - } else if len(remaining) > 1 && remaining[0] == '\r' && remaining[1] == '\n' { - remaining = remaining[2:] - } - - return frontMatter, remaining -} diff --git a/internal/config/workflow_test.go b/internal/config/workflow_test.go deleted file mode 100644 index f209479..0000000 --- a/internal/config/workflow_test.go +++ /dev/null @@ -1,136 +0,0 @@ -package config_test - -import ( - "os" - "path/filepath" - "testing" - - "github.com/shivamstaq/github-symphony/internal/config" -) - -func TestLoadWorkflow_WithFrontMatter(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "WORKFLOW.md") - content := `--- -tracker: - kind: github - owner: myorg - project_number: 42 -agent: - kind: claude_code ---- -You are working on {{.work_item.title}}. - -Fix the bug described in the issue. -` - os.WriteFile(path, []byte(content), 0644) - - wf, err := config.LoadWorkflow(path) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Config should have tracker.kind - tracker, ok := wf.Config["tracker"].(map[string]any) - if !ok { - t.Fatal("expected tracker to be a map") - } - if tracker["kind"] != "github" { - t.Errorf("expected tracker.kind=github, got %v", tracker["kind"]) - } - if tracker["owner"] != "myorg" { - t.Errorf("expected tracker.owner=myorg, got %v", tracker["owner"]) - } - - // Prompt template should be trimmed body - if wf.PromptTemplate == "" { - t.Fatal("expected non-empty prompt template") - } - if wf.PromptTemplate[:15] != "You are working" { - t.Errorf("unexpected prompt start: %q", wf.PromptTemplate[:15]) - } -} - -func TestLoadWorkflow_NoFrontMatter(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "WORKFLOW.md") - content := `Just a prompt with no front matter.` - os.WriteFile(path, []byte(content), 0644) - - wf, err := config.LoadWorkflow(path) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(wf.Config) != 0 { - t.Errorf("expected empty config, got %v", wf.Config) - } - if wf.PromptTemplate != "Just a prompt with no front matter." { - t.Errorf("unexpected prompt: %q", wf.PromptTemplate) - } -} - -func TestLoadWorkflow_MissingFile(t *testing.T) { - _, err := config.LoadWorkflow("/nonexistent/WORKFLOW.md") - if err == nil { - t.Fatal("expected error for missing file") - } - - var wfErr *config.WorkflowError - if !config.AsWorkflowError(err, &wfErr) { - t.Fatalf("expected WorkflowError, got %T: %v", err, err) - } - if wfErr.Kind != config.ErrMissingWorkflowFile { - t.Errorf("expected ErrMissingWorkflowFile, got %v", wfErr.Kind) - } -} - -func TestLoadWorkflow_InvalidYAML(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "WORKFLOW.md") - content := `--- -this is: [not: valid: yaml ---- -prompt body -` - os.WriteFile(path, []byte(content), 0644) - - _, err := config.LoadWorkflow(path) - if err == nil { - t.Fatal("expected error for invalid YAML") - } - - var wfErr *config.WorkflowError - if !config.AsWorkflowError(err, &wfErr) { - t.Fatalf("expected WorkflowError, got %T: %v", err, err) - } - if wfErr.Kind != config.ErrWorkflowParseError { - t.Errorf("expected ErrWorkflowParseError, got %v", wfErr.Kind) - } -} - -func TestLoadWorkflow_NonMapYAML(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "WORKFLOW.md") - content := `--- -- this -- is -- a list ---- -prompt body -` - os.WriteFile(path, []byte(content), 0644) - - _, err := config.LoadWorkflow(path) - if err == nil { - t.Fatal("expected error for non-map YAML") - } - - var wfErr *config.WorkflowError - if !config.AsWorkflowError(err, &wfErr) { - t.Fatalf("expected WorkflowError, got %T: %v", err, err) - } - if wfErr.Kind != config.ErrFrontMatterNotAMap { - t.Errorf("expected ErrFrontMatterNotAMap, got %v", wfErr.Kind) - } -} diff --git a/internal/domain/fsm.go b/internal/domain/fsm.go new file mode 100644 index 0000000..0948d4f --- /dev/null +++ b/internal/domain/fsm.go @@ -0,0 +1,185 @@ +package domain + +import ( + "fmt" + "time" +) + +// ItemState represents the FSM state of a work item in the orchestrator. +type ItemState string + +const ( + StateOpen ItemState = "open" // exists in tracker, not yet picked up + StateQueued ItemState = "queued" // claimed, awaiting dispatch slot + StatePreparing ItemState = "preparing" // workspace being created + StateRunning ItemState = "running" // agent actively working + StatePaused ItemState = "paused" // between-turn pause + StateCompleted ItemState = "completed" // agent finished with commits + StateHandedOff ItemState = "handed_off" // PR created, status moved + StateNeedsHuman ItemState = "needs_human" // requires human intervention + StateFailed ItemState = "failed" // unrecoverable +) + +// AllStates is the complete set of valid states. +var AllStates = []ItemState{ + StateOpen, StateQueued, StatePreparing, StateRunning, + StatePaused, StateCompleted, StateHandedOff, + StateNeedsHuman, StateFailed, +} + +// IsTerminal returns true if the state means the item has left the orchestrator's active management. +func (s ItemState) IsTerminal() bool { + return s == StateHandedOff || s == StateFailed +} + +// IsActive returns true if the item is being actively worked on. +func (s ItemState) IsActive() bool { + return s == StateRunning || s == StatePaused || s == StatePreparing +} + +// Event represents a trigger that causes a state transition. +type Event string + +const ( + EventClaim Event = "claim" + EventDispatch Event = "dispatch" + EventWorkspaceReady Event = "workspace_ready" + EventTurnCompleted Event = "turn_completed" + EventAgentExitedCommits Event = "agent_exited_with_commits" + EventAgentExitedEmpty Event = "agent_exited_no_commits" + EventPauseRequested Event = "pause_requested" + EventResume Event = "resume" + EventStallDetected Event = "stall_detected" + EventBudgetExceeded Event = "budget_exceeded" + EventError Event = "error" + EventCancelled Event = "cancelled" + EventPRCreated Event = "pr_created" + EventPRMerged Event = "pr_merged" + EventPRClosed Event = "pr_closed" + EventRetryManual Event = "retry_manual" +) + +// AllEvents is the complete set of valid events. +var AllEvents = []Event{ + EventClaim, EventDispatch, EventWorkspaceReady, EventTurnCompleted, + EventAgentExitedCommits, EventAgentExitedEmpty, EventPauseRequested, + EventResume, EventStallDetected, EventBudgetExceeded, EventError, + EventCancelled, EventPRCreated, EventPRMerged, EventPRClosed, + EventRetryManual, +} + +// TransitionGuard is a named condition that must be true for a transition to fire. +type TransitionGuard string + +const ( + GuardNone TransitionGuard = "" + GuardSlotAvailable TransitionGuard = "slot_available" + GuardConcurrencyOK TransitionGuard = "concurrency_ok" + GuardHasRetriesLeft TransitionGuard = "has_retries_left" + GuardMaxRetriesExhausted TransitionGuard = "max_retries_exhausted" +) + +// transition defines a single valid state transition. +type transition struct { + From ItemState + Event Event + To ItemState + Guard TransitionGuard +} + +// transitionTable is the declarative, exhaustive list of valid transitions. +// Any (From, Event) pair not in this table is an invalid transition. +var transitionTable = []transition{ + // Dispatch lifecycle + {StateOpen, EventClaim, StateQueued, GuardSlotAvailable}, + {StateQueued, EventDispatch, StatePreparing, GuardConcurrencyOK}, + {StatePreparing, EventWorkspaceReady, StateRunning, GuardNone}, + {StatePreparing, EventError, StateFailed, GuardNone}, + + // Agent execution + {StateRunning, EventTurnCompleted, StateRunning, GuardNone}, + {StateRunning, EventAgentExitedCommits, StateCompleted, GuardNone}, + {StateRunning, EventAgentExitedEmpty, StateNeedsHuman, GuardNone}, + {StateRunning, EventPauseRequested, StatePaused, GuardNone}, + {StateRunning, EventStallDetected, StateNeedsHuman, GuardNone}, + {StateRunning, EventBudgetExceeded, StateNeedsHuman, GuardNone}, + {StateRunning, EventError, StateQueued, GuardHasRetriesLeft}, + {StateRunning, EventError, StateFailed, GuardMaxRetriesExhausted}, + {StateRunning, EventCancelled, StateOpen, GuardNone}, + + // Pause/resume + {StatePaused, EventResume, StateRunning, GuardNone}, + {StatePaused, EventCancelled, StateOpen, GuardNone}, + + // Write-back + {StateCompleted, EventPRCreated, StateHandedOff, GuardNone}, + {StateCompleted, EventError, StateNeedsHuman, GuardNone}, + + // Post-handoff + {StateHandedOff, EventPRMerged, StateOpen, GuardNone}, + {StateHandedOff, EventPRClosed, StateOpen, GuardNone}, + + // Human intervention + {StateNeedsHuman, EventRetryManual, StateQueued, GuardNone}, + {StateNeedsHuman, EventCancelled, StateOpen, GuardNone}, + + // Failed recovery + {StateFailed, EventRetryManual, StateQueued, GuardNone}, +} + +// ErrInvalidTransition is returned when a transition is not in the table. +var ErrInvalidTransition = fmt.Errorf("invalid state transition") + +// TransitionResult contains the outcome of a transition attempt. +type TransitionResult struct { + From ItemState + To ItemState + Event Event + Guard TransitionGuard +} + +// Transition attempts to move from the given state via the given event. +// It returns the target state and guard (if any), or ErrInvalidTransition. +// +// When multiple transitions match (same From+Event but different guards), +// the caller must supply the correct guard via guardSatisfied. +// If guardSatisfied is nil, only guardless transitions match. +func Transition(current ItemState, event Event, guardSatisfied func(TransitionGuard) bool) (TransitionResult, error) { + if guardSatisfied == nil { + guardSatisfied = func(g TransitionGuard) bool { return g == GuardNone } + } + + for _, t := range transitionTable { + if t.From == current && t.Event == event && guardSatisfied(t.Guard) { + return TransitionResult{ + From: t.From, + To: t.To, + Event: event, + Guard: t.Guard, + }, nil + } + } + return TransitionResult{}, fmt.Errorf("%w: cannot transition from %q via %q", ErrInvalidTransition, current, event) +} + +// ValidTransitions returns all transitions valid from the given state. +func ValidTransitions(current ItemState) []transition { + var result []transition + for _, t := range transitionTable { + if t.From == current { + result = append(result, t) + } + } + return result +} + +// FSMEvent is an immutable record of a state transition, appended to the event log. +type FSMEvent struct { + Timestamp time.Time `json:"timestamp"` + ItemID string `json:"item_id"` + From ItemState `json:"from"` + To ItemState `json:"to"` + Event Event `json:"event"` + Guard TransitionGuard `json:"guard,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} diff --git a/internal/domain/fsm_test.go b/internal/domain/fsm_test.go new file mode 100644 index 0000000..08967b5 --- /dev/null +++ b/internal/domain/fsm_test.go @@ -0,0 +1,241 @@ +package domain + +import ( + "errors" + "testing" +) + +// guardAlways satisfies any guard. +func guardAlways(_ TransitionGuard) bool { return true } + +// guardNoneOnly only satisfies no-guard transitions. +func guardNoneOnly(g TransitionGuard) bool { return g == GuardNone } + +// guardSpecific returns a function that satisfies exactly the given guard. +func guardSpecific(want TransitionGuard) func(TransitionGuard) bool { + return func(g TransitionGuard) bool { return g == want } +} + +func TestTransition_ValidTransitions(t *testing.T) { + tests := []struct { + name string + from ItemState + event Event + guard func(TransitionGuard) bool + to ItemState + }{ + // Dispatch lifecycle + {"open->queued via claim", StateOpen, EventClaim, guardAlways, StateQueued}, + {"queued->preparing via dispatch", StateQueued, EventDispatch, guardAlways, StatePreparing}, + {"preparing->running via workspace_ready", StatePreparing, EventWorkspaceReady, nil, StateRunning}, + {"preparing->failed via error", StatePreparing, EventError, nil, StateFailed}, + + // Agent execution + {"running->running via turn_completed", StateRunning, EventTurnCompleted, nil, StateRunning}, + {"running->completed via agent_exited_with_commits", StateRunning, EventAgentExitedCommits, nil, StateCompleted}, + {"running->needs_human via agent_exited_no_commits", StateRunning, EventAgentExitedEmpty, nil, StateNeedsHuman}, + {"running->paused via pause_requested", StateRunning, EventPauseRequested, nil, StatePaused}, + {"running->needs_human via stall_detected", StateRunning, EventStallDetected, nil, StateNeedsHuman}, + {"running->needs_human via budget_exceeded", StateRunning, EventBudgetExceeded, nil, StateNeedsHuman}, + {"running->queued via error (has retries)", StateRunning, EventError, guardSpecific(GuardHasRetriesLeft), StateQueued}, + {"running->failed via error (max retries)", StateRunning, EventError, guardSpecific(GuardMaxRetriesExhausted), StateFailed}, + {"running->open via cancelled", StateRunning, EventCancelled, nil, StateOpen}, + + // Pause/resume + {"paused->running via resume", StatePaused, EventResume, nil, StateRunning}, + {"paused->open via cancelled", StatePaused, EventCancelled, nil, StateOpen}, + + // Write-back + {"completed->handed_off via pr_created", StateCompleted, EventPRCreated, nil, StateHandedOff}, + {"completed->needs_human via error", StateCompleted, EventError, nil, StateNeedsHuman}, + + // Post-handoff + {"handed_off->open via pr_merged", StateHandedOff, EventPRMerged, nil, StateOpen}, + {"handed_off->open via pr_closed", StateHandedOff, EventPRClosed, nil, StateOpen}, + + // Human intervention + {"needs_human->queued via retry_manual", StateNeedsHuman, EventRetryManual, nil, StateQueued}, + {"needs_human->open via cancelled", StateNeedsHuman, EventCancelled, nil, StateOpen}, + + // Failed recovery + {"failed->queued via retry_manual", StateFailed, EventRetryManual, nil, StateQueued}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Transition(tt.from, tt.event, tt.guard) + if err != nil { + t.Fatalf("expected valid transition, got error: %v", err) + } + if result.To != tt.to { + t.Errorf("expected To=%q, got %q", tt.to, result.To) + } + if result.From != tt.from { + t.Errorf("expected From=%q, got %q", tt.from, result.From) + } + if result.Event != tt.event { + t.Errorf("expected Event=%q, got %q", tt.event, result.Event) + } + }) + } +} + +func TestTransition_InvalidTransitions(t *testing.T) { + tests := []struct { + name string + from ItemState + event Event + }{ + // Can't claim something already queued + {"queued+claim", StateQueued, EventClaim}, + // Can't dispatch from open + {"open+dispatch", StateOpen, EventDispatch}, + // Can't complete from open + {"open+agent_exited_commits", StateOpen, EventAgentExitedCommits}, + // Can't pause from queued + {"queued+pause", StateQueued, EventPauseRequested}, + // Can't resume from running + {"running+resume", StateRunning, EventResume}, + // Can't create PR from running + {"running+pr_created", StateRunning, EventPRCreated}, + // Can't claim from handed_off + {"handed_off+claim", StateHandedOff, EventClaim}, + // Can't dispatch from failed + {"failed+dispatch", StateFailed, EventDispatch}, + // Can't turn_completed from paused + {"paused+turn_completed", StatePaused, EventTurnCompleted}, + // Can't merge PR from completed + {"completed+pr_merged", StateCompleted, EventPRMerged}, + // Can't stall from open + {"open+stall", StateOpen, EventStallDetected}, + // Can't exceed budget from queued + {"queued+budget", StateQueued, EventBudgetExceeded}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Transition(tt.from, tt.event, nil) + if err == nil { + t.Fatal("expected error for invalid transition, got nil") + } + if !errors.Is(err, ErrInvalidTransition) { + t.Errorf("expected ErrInvalidTransition, got: %v", err) + } + }) + } +} + +func TestTransition_GuardedErrorFromRunning(t *testing.T) { + // Running + error with has_retries_left -> queued + result, err := Transition(StateRunning, EventError, guardSpecific(GuardHasRetriesLeft)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.To != StateQueued { + t.Errorf("expected queued, got %q", result.To) + } + + // Running + error with max_retries_exhausted -> failed + result, err = Transition(StateRunning, EventError, guardSpecific(GuardMaxRetriesExhausted)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.To != StateFailed { + t.Errorf("expected failed, got %q", result.To) + } + + // Running + error with no guard satisfied -> invalid + _, err = Transition(StateRunning, EventError, guardNoneOnly) + if err == nil { + t.Fatal("expected error when no guard is satisfied for running+error") + } +} + +func TestValidTransitions(t *testing.T) { + // Open should have exactly 1 valid transition: claim + trans := ValidTransitions(StateOpen) + if len(trans) != 1 { + t.Errorf("expected 1 transition from open, got %d", len(trans)) + } + + // Running should have many valid transitions + trans = ValidTransitions(StateRunning) + if len(trans) < 8 { + t.Errorf("expected >=8 transitions from running, got %d", len(trans)) + } + + // Every state should have at least one exit transition + for _, state := range AllStates { + if state == StateOpen { + // open can only be claimed + continue + } + trans := ValidTransitions(state) + if len(trans) == 0 { + t.Errorf("state %q has no exit transitions (dead state)", state) + } + } +} + +func TestInvariant_NoItemInTwoTerminalStates(t *testing.T) { + // This is a structural invariant: the FSM is deterministic. + // For any (from, event, guard) triple, there is at most one target state. + type key struct { + from ItemState + event Event + guard TransitionGuard + } + seen := make(map[key]ItemState) + for _, tr := range transitionTable { + k := key{tr.From, tr.Event, tr.Guard} + if existing, ok := seen[k]; ok { + t.Errorf("duplicate transition: (%q, %q, %q) -> %q AND %q", + tr.From, tr.Event, tr.Guard, existing, tr.To) + } + seen[k] = tr.To + } +} + +func TestInvariant_AllStatesReachable(t *testing.T) { + reachable := make(map[ItemState]bool) + reachable[StateOpen] = true // initial state + + changed := true + for changed { + changed = false + for _, tr := range transitionTable { + if reachable[tr.From] && !reachable[tr.To] { + reachable[tr.To] = true + changed = true + } + } + } + + for _, state := range AllStates { + if !reachable[state] { + t.Errorf("state %q is unreachable from open", state) + } + } +} + +func TestInvariant_NeedsHumanOnlyFromNoProgress(t *testing.T) { + // needs_human should only be reachable via: + // - agent_exited_no_commits (from running) + // - stall_detected (from running) + // - budget_exceeded (from running) + // - error (from completed, i.e. write-back failed) + allowedEvents := map[Event]bool{ + EventAgentExitedEmpty: true, + EventStallDetected: true, + EventBudgetExceeded: true, + EventError: true, + } + + for _, tr := range transitionTable { + if tr.To == StateNeedsHuman { + if !allowedEvents[tr.Event] { + t.Errorf("needs_human reached via unexpected event %q from %q", tr.Event, tr.From) + } + } + } +} diff --git a/internal/domain/workitem.go b/internal/domain/workitem.go new file mode 100644 index 0000000..e9d1ce7 --- /dev/null +++ b/internal/domain/workitem.go @@ -0,0 +1,63 @@ +package domain + +// WorkItem is the canonical domain model for a project item. +// All packages import this type — no conversion layers needed. +type WorkItem struct { + WorkItemID string + ProjectItemID string + ContentType string // "issue", "draft_issue", "pull_request" + IssueID string + IssueNumber *int + IssueIdentifier string // "owner/repo#number" + Title string + Description string + State string // "open", "closed" + ProjectStatus string + Priority *int + Labels []string + Assignees []string + Milestone string + ProjectFields map[string]any + BlockedBy []BlockerRef + SubIssues []ChildRef + ParentIssue *ParentRef + LinkedPRs []PRRef + Repository *Repository + URL string + CreatedAt string + UpdatedAt string + Pass2Failed bool // true if dependency data is incomplete — do not dispatch +} + +type BlockerRef struct { + ID string + Identifier string + State string +} + +type ChildRef struct { + ID string + Identifier string + State string +} + +type ParentRef struct { + ID string + Identifier string +} + +type PRRef struct { + ID string + Number int + State string + IsDraft bool + URL string +} + +type Repository struct { + Owner string + Name string + FullName string + DefaultBranch string + CloneURLHTTPS string +} diff --git a/internal/engine/budget.go b/internal/engine/budget.go new file mode 100644 index 0000000..a335cfe --- /dev/null +++ b/internal/engine/budget.go @@ -0,0 +1,57 @@ +package engine + +import ( + "github.com/shivamstaq/github-symphony/internal/domain" +) + +// checkBudget evaluates whether a running item has exceeded its cost/token budget. +// Called after each agent update with token information. +func (e *Engine) checkBudget(itemID string) { + entry, ok := e.state.Running[itemID] + if !ok { + return + } + + budget := e.cfg.Agent.Budget + + // Per-item token limit + exceeded := budget.MaxTokensPerItem > 0 && entry.TotalTokens >= budget.MaxTokensPerItem + // Per-item cost limit + if !exceeded && budget.MaxCostPerItemUSD > 0 && entry.CostUSD >= budget.MaxCostPerItemUSD { + exceeded = true + } + // Global cost limit + if !exceeded && budget.MaxCostTotalUSD > 0 { + totalCost := e.state.Totals.CostUSD + entry.CostUSD + if totalCost >= budget.MaxCostTotalUSD { + exceeded = true + } + } + + if exceeded { + // Handle synchronously — we're already in the event loop goroutine + e.handleBudgetExceeded(NewEvent(EvtBudgetExceeded, itemID, nil)) + } +} + +// handleBudgetExceeded kills the worker and transitions to needs_human. +func (e *Engine) handleBudgetExceeded(evt EngineEvent) { + itemID := evt.ItemID + entry, ok := e.state.Running[itemID] + if !ok { + return + } + + e.logger.Warn("budget exceeded, stopping agent", + "item", entry.WorkItem.IssueIdentifier, + "tokens", entry.TotalTokens, + "cost_usd", entry.CostUSD, + ) + + // Kill the worker + entry.CancelFunc() + delete(e.state.Running, itemID) + + // FSM: running -> needs_human + _, _ = e.transition(itemID, domain.EventBudgetExceeded, nil) +} diff --git a/internal/engine/budget_test.go b/internal/engine/budget_test.go new file mode 100644 index 0000000..d9561e0 --- /dev/null +++ b/internal/engine/budget_test.go @@ -0,0 +1,121 @@ +package engine + +import ( + "context" + "testing" + "time" + + agentmock "github.com/shivamstaq/github-symphony/internal/agent/mock" + "github.com/shivamstaq/github-symphony/internal/domain" +) + +func TestBudget_TokenLimitExceeded(t *testing.T) { + item := makeItem("60", 60) + // Use slow agent so it doesn't complete before budget check + mockAgent := &agentmock.MockAgent{ + StopReason: "completed", + NumTurns: 1, + HasCommits: true, + Delay: 5 * time.Second, + } + eng := newTestEngine(t, []domain.WorkItem{item}, mockAgent) + eng.cfg.Agent.Budget.MaxTokensPerItem = 500 + + ctx := context.Background() + eng.handlePollTick(ctx) + + // Wait for dispatch, then simulate high token usage + time.Sleep(20 * time.Millisecond) + if entry, ok := eng.state.Running["60"]; ok { + entry.TotalTokens = 600 // over limit + } + + // Budget check is synchronous — immediately transitions to needs_human + eng.checkBudget("60") + + if eng.state.ItemState("60") != domain.StateNeedsHuman { + t.Errorf("expected needs_human after budget exceeded, got %q", eng.state.ItemState("60")) + } +} + +func TestBudget_CostLimitExceeded(t *testing.T) { + item := makeItem("61", 61) + mockAgent := &agentmock.MockAgent{ + StopReason: "completed", + NumTurns: 1, + HasCommits: true, + Delay: 5 * time.Second, + } + eng := newTestEngine(t, []domain.WorkItem{item}, mockAgent) + eng.cfg.Agent.Budget.MaxCostPerItemUSD = 1.0 + + ctx := context.Background() + eng.handlePollTick(ctx) + + time.Sleep(20 * time.Millisecond) + if entry, ok := eng.state.Running["61"]; ok { + entry.CostUSD = 1.5 + } + + eng.checkBudget("61") + + if eng.state.ItemState("61") != domain.StateNeedsHuman { + t.Errorf("expected needs_human after cost exceeded, got %q", eng.state.ItemState("61")) + } +} + +func TestBudget_UnderLimitNoAction(t *testing.T) { + item := makeItem("62", 62) + mockAgent := agentmock.NewSuccessAgent() + eng := newTestEngine(t, []domain.WorkItem{item}, mockAgent) + eng.cfg.Agent.Budget.MaxTokensPerItem = 100000 + + ctx := context.Background() + eng.handlePollTick(ctx) + + time.Sleep(20 * time.Millisecond) + if entry, ok := eng.state.Running["62"]; ok { + entry.TotalTokens = 500 + } + + eng.checkBudget("62") + + // No budget event should be emitted + select { + case evt := <-eng.eventCh: + if evt.Type == EvtBudgetExceeded { + t.Error("should not trigger budget exceeded when under limit") + } + eng.handleEvent(ctx, evt) + default: + // Good + } +} + +func TestBudget_ZeroMeansNoLimit(t *testing.T) { + item := makeItem("63", 63) + mockAgent := agentmock.NewSuccessAgent() + eng := newTestEngine(t, []domain.WorkItem{item}, mockAgent) + // All budget values default to 0 = no limit + + ctx := context.Background() + eng.handlePollTick(ctx) + + time.Sleep(20 * time.Millisecond) + if entry, ok := eng.state.Running["63"]; ok { + entry.TotalTokens = 999999999 + entry.CostUSD = 999999.0 + } + + eng.checkBudget("63") + + select { + case evt := <-eng.eventCh: + if evt.Type == EvtBudgetExceeded { + t.Error("budget with zero limits should never trigger") + } + eng.handleEvent(ctx, evt) + default: + // Good + } +} diff --git a/internal/orchestrator/eligibility.go b/internal/engine/eligibility.go similarity index 71% rename from internal/orchestrator/eligibility.go rename to internal/engine/eligibility.go index 05dda71..0d04a6f 100644 --- a/internal/orchestrator/eligibility.go +++ b/internal/engine/eligibility.go @@ -1,9 +1,11 @@ -package orchestrator +package engine import ( "fmt" "sort" "strings" + + "github.com/shivamstaq/github-symphony/internal/domain" ) // EligibilityConfig holds the config values needed for eligibility checks. @@ -16,52 +18,37 @@ type EligibilityConfig struct { RepoDenylist []string RequiredLabels []string BlockedStatusValues []string - MaxPerStatus map[string]int // max concurrent agents per project status - MaxPerRepo map[string]int // max concurrent agents per repo (owner/name) + MaxPerStatus map[string]int + MaxPerRepo map[string]int } // IsEligible checks whether a work item should be dispatched. // Returns (eligible, reason) where reason explains why it's ineligible. -func IsEligible(item WorkItem, cfg EligibilityConfig, state *State, maxConcurrent int) (bool, string) { - // Must have a project item ID and title +func IsEligible(item domain.WorkItem, cfg EligibilityConfig, state *State, maxConcurrent int) (bool, string) { if item.ProjectItemID == "" { return false, "missing project_item_id" } if item.Title == "" { return false, "missing title" } - - // Reject items with incomplete dependency data (Pass 2 failed) if item.Pass2Failed { - return false, "incomplete data (dependency fetch failed) — will retry next poll" + return false, "incomplete data (dependency fetch failed)" } - - // Content type must be executable if !containsCI(cfg.ExecutableItemTypes, item.ContentType) { return false, "content_type not executable: " + item.ContentType } - - // Project status must be active if !containsCI(cfg.ActiveValues, item.ProjectStatus) { return false, "project_status not active: " + item.ProjectStatus } - - // Project status must not be terminal if containsCI(cfg.TerminalValues, item.ProjectStatus) { return false, "project_status is terminal: " + item.ProjectStatus } - - // Blocked status values if containsCI(cfg.BlockedStatusValues, item.ProjectStatus) { return false, "project_status is blocked: " + item.ProjectStatus } - - // Issue backing required if cfg.RequireIssueBacking && item.ContentType == "issue" && item.IssueNumber == nil { return false, "require_issue_backing: no issue number" } - - // Issue must be open if item.State != "" && strings.ToLower(item.State) != "open" { return false, "issue state not open: " + item.State } @@ -78,27 +65,22 @@ func IsEligible(item WorkItem, cfg EligibilityConfig, state *State, maxConcurren } // Required labels - if len(cfg.RequiredLabels) > 0 { - for _, req := range cfg.RequiredLabels { - if !containsCI(item.Labels, req) { - return false, "missing required label: " + req - } + for _, req := range cfg.RequiredLabels { + if !containsCI(item.Labels, req) { + return false, "missing required label: " + req } } - // Not already claimed, running, or handed off - if state.Claimed[item.WorkItemID] { - return false, "already claimed" - } - if _, running := state.Running[item.WorkItemID]; running { - return false, "already running" + // FSM state checks (replaces old map checks) + if state.IsClaimedOrRunning(item.WorkItemID) { + return false, "already claimed or running" } - if state.HandedOff != nil && state.HandedOff[item.WorkItemID] { + if state.HandedOff[item.WorkItemID] { return false, "already handed off (PR created)" } // Global concurrency - if len(state.Running) >= maxConcurrent { + if state.RunningCount() >= maxConcurrent { return false, "no available slots" } @@ -130,18 +112,17 @@ func IsEligible(item WorkItem, cfg EligibilityConfig, state *State, maxConcurren } } - // Blocker check: any non-terminal blocking dependency blocks dispatch + // Blocker check for _, b := range item.BlockedBy { if b.State != "" && strings.ToLower(b.State) != "closed" { return false, "blocked by " + b.Identifier + " (state: " + b.State + ")" } } - // Parent/sub-issue check: if this item has open sub-issues, skip it - // and let the sub-issues get dispatched instead + // Sub-issue check for _, child := range item.SubIssues { if child.State != "" && strings.ToLower(child.State) != "closed" { - return false, "has open sub-issues (dispatch children instead): " + child.Identifier + return false, "has open sub-issues: " + child.Identifier } } @@ -149,7 +130,7 @@ func IsEligible(item WorkItem, cfg EligibilityConfig, state *State, maxConcurren } // SortForDispatch sorts work items by priority ascending, then created_at oldest first. -func SortForDispatch(items []WorkItem) { +func SortForDispatch(items []domain.WorkItem) { sort.SliceStable(items, func(i, j int) bool { pi := priorityVal(items[i].Priority) pj := priorityVal(items[j].Priority) @@ -165,7 +146,7 @@ func SortForDispatch(items []WorkItem) { func priorityVal(p *int) int { if p == nil { - return 999999 // nil priority sorts last + return 999999 } return *p } diff --git a/internal/engine/eligibility_test.go b/internal/engine/eligibility_test.go new file mode 100644 index 0000000..e14df4b --- /dev/null +++ b/internal/engine/eligibility_test.go @@ -0,0 +1,137 @@ +package engine + +import ( + "testing" + + "github.com/shivamstaq/github-symphony/internal/domain" +) + +func makeEligItem(id string, status string, repo *domain.Repository) domain.WorkItem { + num := 1 + return domain.WorkItem{ + WorkItemID: id, + ProjectItemID: "proj-" + id, + ContentType: "issue", + IssueNumber: &num, + Title: "Issue " + id, + State: "open", + ProjectStatus: status, + Repository: repo, + } +} + +func baseEligCfg() EligibilityConfig { + return EligibilityConfig{ + ActiveValues: []string{"Todo", "In Progress"}, + TerminalValues: []string{"Done"}, + ExecutableItemTypes: []string{"issue"}, + } +} + +func TestIsEligible_PerStatusConcurrency(t *testing.T) { + cfg := baseEligCfg() + cfg.MaxPerStatus = map[string]int{"todo": 1} + + state := NewState() + state.Running["existing"] = &RunningEntry{ + WorkItem: makeEligItem("existing", "Todo", nil), + } + + item := makeEligItem("new", "Todo", nil) + eligible, reason := IsEligible(item, cfg, state, 10) + + if eligible { + t.Errorf("should be ineligible due to per-status limit, got eligible") + } + if reason == "" { + t.Error("expected a reason string for ineligibility") + } +} + +func TestIsEligible_PerStatusDifferentStatusAllowed(t *testing.T) { + cfg := baseEligCfg() + cfg.MaxPerStatus = map[string]int{"todo": 1} + + state := NewState() + state.Running["existing"] = &RunningEntry{ + WorkItem: makeEligItem("existing", "Todo", nil), + } + + item := makeEligItem("new2", "In Progress", nil) + eligible, _ := IsEligible(item, cfg, state, 10) + if !eligible { + t.Error("item with different status should be eligible") + } +} + +func TestIsEligible_PerRepoConcurrency(t *testing.T) { + repo := &domain.Repository{Owner: "org", Name: "repo", FullName: "org/repo"} + cfg := baseEligCfg() + cfg.MaxPerRepo = map[string]int{"org/repo": 1} + + state := NewState() + state.Running["existing"] = &RunningEntry{ + WorkItem: makeEligItem("existing", "Todo", repo), + } + + item := makeEligItem("new", "Todo", repo) + eligible, reason := IsEligible(item, cfg, state, 10) + + if eligible { + t.Errorf("should be ineligible due to per-repo limit, got eligible") + } + if reason == "" { + t.Error("expected a reason string") + } +} + +func TestIsEligible_PerRepoDifferentRepoAllowed(t *testing.T) { + repo := &domain.Repository{Owner: "org", Name: "repo", FullName: "org/repo"} + otherRepo := &domain.Repository{Owner: "org", Name: "other", FullName: "org/other"} + cfg := baseEligCfg() + cfg.MaxPerRepo = map[string]int{"org/repo": 1} + + state := NewState() + state.Running["existing"] = &RunningEntry{ + WorkItem: makeEligItem("existing", "Todo", repo), + } + + item := makeEligItem("new2", "Todo", otherRepo) + eligible, _ := IsEligible(item, cfg, state, 10) + if !eligible { + t.Error("item in different repo should be eligible") + } +} + +func TestIsEligible_PerStatusNotConfigured(t *testing.T) { + cfg := baseEligCfg() + // No MaxPerStatus set + + state := NewState() + state.Running["existing"] = &RunningEntry{ + WorkItem: makeEligItem("existing", "Todo", nil), + } + + item := makeEligItem("new", "Todo", nil) + eligible, _ := IsEligible(item, cfg, state, 10) + if !eligible { + t.Error("should be eligible when per-status limit is not configured") + } +} + +func TestIsEligible_PerRepoNotConfigured(t *testing.T) { + repo := &domain.Repository{Owner: "org", Name: "repo", FullName: "org/repo"} + cfg := baseEligCfg() + // No MaxPerRepo set + + state := NewState() + state.Running["existing"] = &RunningEntry{ + WorkItem: makeEligItem("existing", "Todo", repo), + } + + item := makeEligItem("new", "Todo", repo) + eligible, _ := IsEligible(item, cfg, state, 10) + if !eligible { + t.Error("should be eligible when per-repo limit is not configured") + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..2ebf3d0 --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,647 @@ +package engine + +import ( + "context" + "log/slog" + "time" + + "github.com/shivamstaq/github-symphony/internal/agent" + "github.com/shivamstaq/github-symphony/internal/codehost" + "github.com/shivamstaq/github-symphony/internal/config" + "github.com/shivamstaq/github-symphony/internal/domain" + "github.com/shivamstaq/github-symphony/internal/prompt" + "github.com/shivamstaq/github-symphony/internal/state" + "github.com/shivamstaq/github-symphony/internal/tracker" + "github.com/shivamstaq/github-symphony/internal/workspace" +) + +// Engine is the central orchestration loop. +// A single goroutine processes events sequentially — no mutexes on State. +type Engine struct { + cfg *config.SymphonyConfig + state *State + eventLog *EventLog + tracker tracker.Tracker + agent agent.Agent + codeHost codehost.CodeHost + store *state.Store + wsMgr *workspace.Manager + router *prompt.Router + eventCh chan EngineEvent + logger *slog.Logger + + eligCfg EligibilityConfig +} + +// Deps contains the dependencies injected into the engine. +type Deps struct { + Config *config.SymphonyConfig + Tracker tracker.Tracker + Agent agent.Agent + CodeHost codehost.CodeHost + Store *state.Store + Workspace *workspace.Manager + PromptRouter *prompt.Router + EventLog *EventLog + Logger *slog.Logger +} + +// New creates a new Engine with the given dependencies. +func New(deps Deps) *Engine { + cfg := deps.Config + e := &Engine{ + cfg: cfg, + state: NewState(), + eventLog: deps.EventLog, + tracker: deps.Tracker, + agent: deps.Agent, + codeHost: deps.CodeHost, + store: deps.Store, + wsMgr: deps.Workspace, + router: deps.PromptRouter, + eventCh: make(chan EngineEvent, 256), + logger: deps.Logger, + eligCfg: EligibilityConfig{ + ActiveValues: cfg.Tracker.ActiveValues, + TerminalValues: cfg.Tracker.TerminalValues, + ExecutableItemTypes: cfg.Tracker.ExecutableItemTypes, + RequireIssueBacking: cfg.Tracker.RequireIssueBacking, + RepoAllowlist: cfg.Tracker.RepoAllowlist, + RepoDenylist: cfg.Tracker.RepoDenylist, + RequiredLabels: cfg.Tracker.RequiredLabels, + BlockedStatusValues: cfg.Tracker.BlockedValues, + MaxPerStatus: cfg.Agent.MaxConcurrentByStatus, + MaxPerRepo: cfg.Agent.MaxConcurrentByRepo, + }, + } + return e +} + +// Run starts the engine's main event loop. Blocks until ctx is cancelled. +func (e *Engine) Run(ctx context.Context) error { + // Restore persisted state + e.restoreState() + + pollInterval := time.Duration(e.cfg.Polling.IntervalMs) * time.Millisecond + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + // Immediate first poll + e.eventCh <- NewEvent(EvtPollTick, "", PollTickPayload{}) + + for { + select { + case <-ctx.Done(): + e.handleShutdown() + return ctx.Err() + + case <-ticker.C: + e.eventCh <- NewEvent(EvtPollTick, "", PollTickPayload{}) + + case evt := <-e.eventCh: + e.handleEvent(ctx, evt) + } + } +} + +// Emit sends an event to the engine's event channel (thread-safe). +func (e *Engine) Emit(evt EngineEvent) { + select { + case e.eventCh <- evt: + default: + e.logger.Warn("event channel full, dropping event", "type", evt.Type) + } +} + +// State returns a snapshot of the current state (for TUI/API). +func (e *Engine) GetState() *State { + return e.state +} + +// HandlePollTick is the exported version of handlePollTick for testing. +func (e *Engine) HandlePollTick(ctx context.Context) { + e.handlePollTick(ctx) +} + +// ProcessOneEvent attempts to process one event from the channel. +// Returns true if an event was processed, false if the channel was empty. +func (e *Engine) ProcessOneEvent(ctx context.Context) bool { + select { + case evt := <-e.eventCh: + e.handleEvent(ctx, evt) + return true + default: + return false + } +} + +// handleEvent dispatches a single event to its typed handler. +func (e *Engine) handleEvent(ctx context.Context, evt EngineEvent) { + switch evt.Type { + case EvtPollTick: + e.handlePollTick(ctx) + case EvtWorkspaceReady: + e.handleWorkspaceReady(evt) + case EvtAgentExited: + e.handleAgentExited(evt) + case EvtAgentUpdate: + e.handleAgentUpdate(evt) + case EvtPauseRequested: + e.handlePause(evt) + case EvtResumeRequested: + e.handleResume(evt) + case EvtCancelRequested: + e.handleCancel(evt) + case EvtStallDetected: + e.handleStallDetected(evt) + case EvtBudgetExceeded: + e.handleBudgetExceeded(evt) + case EvtRetryDue: + e.handleRetryDue(ctx, evt) + default: + e.logger.Debug("unhandled event type", "type", evt.Type) + } +} + +// handleWorkspaceReady transitions an item from preparing to running. +func (e *Engine) handleWorkspaceReady(evt EngineEvent) { + _, _ = e.transition(evt.ItemID, domain.EventWorkspaceReady, nil) +} + +// handlePollTick fetches candidates and dispatches eligible items. +func (e *Engine) handlePollTick(ctx context.Context) { + now := time.Now() + e.state.LastPollAt = &now + + items, err := e.tracker.FetchCandidates(ctx) + if err != nil { + e.logger.Error("poll failed", "error", err) + return + } + + SortForDispatch(items) + + dispatched := 0 + for _, item := range items { + eligible, reason := IsEligible(item, e.eligCfg, e.state, e.cfg.Agent.MaxConcurrent) + if !eligible { + e.logger.Debug("ineligible", "item", item.IssueIdentifier, "reason", reason) + continue + } + + if err := e.dispatchItem(ctx, item); err != nil { + e.logger.Error("dispatch failed", "item", item.IssueIdentifier, "error", err) + continue + } + dispatched++ + } + + if dispatched > 0 { + e.logger.Info("poll complete", "candidates", len(items), "dispatched", dispatched) + } + + // Fire due retries + e.fireDueRetries() + + // Detect stalled workers + e.detectStalls() + + // Reconcile running items with tracker state + e.reconcileRunningItems(ctx) +} + +// dispatchItem claims an item and launches an agent worker. +func (e *Engine) dispatchItem(ctx context.Context, item domain.WorkItem) error { + // FSM: open -> queued + _, err := e.transition(item.WorkItemID, domain.EventClaim, func(g domain.TransitionGuard) bool { + return g == domain.GuardSlotAvailable || g == domain.GuardNone + }) + if err != nil { + return err + } + + // FSM: queued -> preparing + _, err = e.transition(item.WorkItemID, domain.EventDispatch, func(g domain.TransitionGuard) bool { + return g == domain.GuardConcurrencyOK || g == domain.GuardNone + }) + if err != nil { + return err + } + + e.state.DispatchTotal++ + + // Launch worker goroutine + workerCtx, cancel := context.WithCancel(ctx) + entry := &RunningEntry{ + WorkItem: item, + CancelFunc: cancel, + Phase: domain.StatePreparing, + StartedAt: time.Now(), + LastActivityAt: time.Now(), + } + e.state.Running[item.WorkItemID] = entry + + // Create workspace if manager is available + if e.wsMgr != nil && item.Repository != nil { + issueNum := 0 + if item.IssueNumber != nil { + issueNum = *item.IssueNumber + } + ws, err := e.wsMgr.CreateForWorkItem(ctx, workspace.WorkItemRef{ + Owner: item.Repository.Owner, + Repo: item.Repository.Name, + IssueNumber: issueNum, + CloneURL: item.Repository.CloneURLHTTPS, + BaseBranch: item.Repository.DefaultBranch, + }) + if err != nil { + e.logger.Error("workspace creation failed", "item", item.IssueIdentifier, "error", err) + delete(e.state.Running, item.WorkItemID) + cancel() + _, _ = e.transition(item.WorkItemID, domain.EventError, nil) + return err + } + entry.WorkspacePath = ws.Path + entry.BranchName = ws.BranchName + } + + // Transition to running + _, _ = e.transition(item.WorkItemID, domain.EventWorkspaceReady, nil) + entry.Phase = domain.StateRunning + + go e.runWorker(workerCtx, item, entry) + + return nil +} + +// renderPrompt builds the prompt text for a work item using the template router. +func (e *Engine) renderPrompt(item domain.WorkItem, entry *RunningEntry) string { + promptText := item.Title + "\n\n" + item.Description + if e.router == nil { + return promptText + } + + tmpl, err := e.router.SelectTemplate(item) + if err != nil { + return promptText + } + + repoFullName := "" + repoDefaultBranch := "" + baseBranch := "" + if item.Repository != nil { + repoFullName = item.Repository.FullName + repoDefaultBranch = item.Repository.DefaultBranch + baseBranch = item.Repository.DefaultBranch + } + + rendered, err := prompt.Render(tmpl, prompt.RenderInput{ + WorkItem: map[string]any{ + "title": item.Title, + "description": item.Description, + "identifier": item.IssueIdentifier, + }, + Repository: map[string]any{ + "full_name": repoFullName, + "default_branch": repoDefaultBranch, + }, + BranchName: entry.BranchName, + BaseBranch: baseBranch, + }) + if err == nil && rendered != "" { + return rendered + } + return promptText +} + +// drainSession reads from a session's channels until done or cancelled. +// Returns the final agent.Result. +func (e *Engine) drainSession(ctx context.Context, itemID string, session *agent.Session) agent.Result { + for { + select { + case update, ok := <-session.Updates: + if !ok { + continue + } + e.Emit(NewEvent(EvtAgentUpdate, itemID, AgentUpdatePayload{Update: update})) + + case result, ok := <-session.Done: + if !ok { + return agent.Result{StopReason: agent.StopCompleted} + } + return result + + case <-ctx.Done(): + return agent.Result{StopReason: agent.StopCancelled} + } + } +} + +// runWorker executes the agent lifecycle in a goroutine. +// Supports multi-turn: runs up to MaxTurns sessions, re-checking between turns. +func (e *Engine) runWorker(ctx context.Context, item domain.WorkItem, entry *RunningEntry) { + maxTurns := e.cfg.Agent.MaxTurns + if maxTurns <= 0 { + maxTurns = 1 + } + + for turn := 1; turn <= maxTurns; turn++ { + if ctx.Err() != nil { + e.Emit(NewEvent(EvtAgentExited, item.WorkItemID, AgentExitedPayload{ + Result: agent.Result{StopReason: agent.StopCancelled}, + })) + return + } + + promptText := e.renderPrompt(item, entry) + session, err := e.agent.Start(ctx, agent.StartConfig{ + WorkDir: entry.WorkspacePath, + Prompt: promptText, + Title: item.Title, + MaxTurns: 1, // single turn per session; we loop externally + }) + if err != nil { + e.Emit(NewEvent(EvtAgentExited, item.WorkItemID, AgentExitedPayload{ + Result: agent.Result{StopReason: agent.StopFailed, Error: err}, + })) + return + } + + entry.Session = session + if turn == 1 { + e.state.Totals.SessionsStarted++ + } + + result := e.drainSession(ctx, item.WorkItemID, session) + + // Cancelled or failed — exit immediately + if result.StopReason == agent.StopCancelled { + e.Emit(NewEvent(EvtAgentExited, item.WorkItemID, AgentExitedPayload{Result: result})) + return + } + if result.StopReason == agent.StopFailed || result.Error != nil { + e.Emit(NewEvent(EvtAgentExited, item.WorkItemID, AgentExitedPayload{Result: result})) + return + } + + // Has commits — success, exit with commits + if result.HasCommits { + e.Emit(NewEvent(EvtAgentExited, item.WorkItemID, AgentExitedPayload{Result: result})) + return + } + + // No commits yet — continue to next turn if turns remain + if turn < maxTurns { + entry.TurnsCompleted++ + e.logger.Info("turn complete, continuing", "item", item.IssueIdentifier, "turn", turn, "max", maxTurns) + } + } + + // All turns exhausted without commits + e.Emit(NewEvent(EvtAgentExited, item.WorkItemID, AgentExitedPayload{ + Result: agent.Result{StopReason: agent.StopCompleted, HasCommits: false}, + })) +} + +// handleAgentExited processes the result of a completed worker. +func (e *Engine) handleAgentExited(evt EngineEvent) { + payload := evt.Payload.(AgentExitedPayload) + itemID := evt.ItemID + result := payload.Result + + entry, ok := e.state.Running[itemID] + if !ok { + // Worker was already removed by stall/budget/reconcile handler. + // The FSM transition was already applied — do not overwrite it. + e.logger.Debug("agent exited but worker already removed", "item", itemID) + return + } + + // Accumulate metrics + e.state.Totals.SecondsRunning += time.Since(entry.StartedAt).Seconds() + e.state.Totals.InputTokens += int64(entry.InputTokens) + e.state.Totals.OutputTokens += int64(entry.OutputTokens) + e.state.Totals.TotalTokens += int64(entry.TotalTokens) + e.state.Totals.CostUSD += entry.CostUSD + + // Remove from running + delete(e.state.Running, itemID) + + switch { + case result.StopReason == agent.StopCancelled: + _, _ = e.transition(itemID, domain.EventCancelled, nil) + + case result.StopReason == agent.StopFailed || result.Error != nil: + item := domain.WorkItem{} + attempt := 0 + if entry != nil { + item = entry.WorkItem + attempt = entry.RetryAttempt + } + e.handleWorkerError(itemID, item, result, attempt) + + case result.HasCommits: + // FSM: running -> completed + _, _ = e.transition(itemID, domain.EventAgentExitedCommits, nil) + // Perform handoff (push branch, create PR, update status) + handoffItem := domain.WorkItem{} + if entry != nil { + handoffItem = entry.WorkItem + } + e.performHandoff(itemID, handoffItem, entry) + + default: + // No commits — needs human intervention + _, _ = e.transition(itemID, domain.EventAgentExitedEmpty, nil) + } +} + +// handleAgentUpdate processes a progress update from a running agent. +func (e *Engine) handleAgentUpdate(evt EngineEvent) { + payload := evt.Payload.(AgentUpdatePayload) + entry, ok := e.state.Running[evt.ItemID] + if !ok { + return + } + entry.LastActivityAt = time.Now() + entry.InputTokens += payload.Update.Tokens.Input + entry.OutputTokens += payload.Update.Tokens.Output + entry.TotalTokens += payload.Update.Tokens.Total + + if payload.Update.Kind == agent.UpdateTurnDone { + entry.TurnsCompleted++ + } + + // Check budget after each update + e.checkBudget(evt.ItemID) +} + +// handlePause sets the pause flag on a running item. +func (e *Engine) handlePause(evt EngineEvent) { + if entry, ok := e.state.Running[evt.ItemID]; ok { + entry.Paused = true + _, _ = e.transition(evt.ItemID, domain.EventPauseRequested, nil) + e.logger.Info("paused", "item", evt.ItemID) + } +} + +// handleResume clears the pause flag. +func (e *Engine) handleResume(evt EngineEvent) { + _, _ = e.transition(evt.ItemID, domain.EventResume, nil) + if entry, ok := e.state.Running[evt.ItemID]; ok { + entry.Paused = false + } + e.logger.Info("resumed", "item", evt.ItemID) +} + +// handleCancel cancels a running or paused item. +func (e *Engine) handleCancel(evt EngineEvent) { + if entry, ok := e.state.Running[evt.ItemID]; ok { + entry.CancelFunc() + delete(e.state.Running, evt.ItemID) + } + _, _ = e.transition(evt.ItemID, domain.EventCancelled, nil) + e.logger.Info("cancelled", "item", evt.ItemID) +} + +// handleShutdown performs graceful shutdown. +// Cancels all running agent processes and persists state. +func (e *Engine) handleShutdown() { + running := len(e.state.Running) + e.logger.Info("shutting down engine", "running_workers", running) + + // Cancel all running workers — context cancellation sends SIGKILL to agent processes + for id, entry := range e.state.Running { + e.logger.Info("cancelling worker", "item", entry.WorkItem.IssueIdentifier) + entry.CancelFunc() + _, _ = e.transition(id, domain.EventCancelled, nil) + } + + // Persist state before exit + e.persistState() + if e.eventLog != nil { + _ = e.eventLog.Close() + } +} + +// restoreState loads persisted handoffs, retries, and totals from the store. +func (e *Engine) restoreState() { + if e.store == nil { + return + } + + // Restore handoffs + handoffs, err := e.store.LoadHandoffs() + if err != nil { + e.logger.Warn("failed to load handoffs", "error", err) + } else { + for _, id := range handoffs { + e.state.HandedOff[id] = true + e.state.SetItemState(id, domain.StateHandedOff) + } + if len(handoffs) > 0 { + e.logger.Info("restored handoffs", "count", len(handoffs)) + } + } + + // Restore retries + retries, err := e.store.LoadRetries() + if err != nil { + e.logger.Warn("failed to load retries", "error", err) + } else { + for _, r := range retries { + e.state.RetryQueue[r.WorkItemID] = &RetryEntry{ + WorkItemID: r.WorkItemID, + Attempt: r.Attempt, + DueAt: time.UnixMilli(r.DueAtMs), + Error: r.Error, + } + e.state.SetItemState(r.WorkItemID, domain.StateQueued) + } + if len(retries) > 0 { + e.logger.Info("restored retries", "count", len(retries)) + } + } + + // Restore totals + totals, err := e.store.LoadTotals() + if err != nil { + e.logger.Warn("failed to load totals", "error", err) + } else { + e.state.Totals.InputTokens = totals.InputTokens + e.state.Totals.OutputTokens = totals.OutputTokens + e.state.Totals.TotalTokens = totals.TotalTokens + e.state.Totals.SecondsRunning = totals.SecondsRunning + e.state.Totals.SessionsStarted = totals.SessionsStarted + } +} + +// persistState saves handoffs, retries, and totals to the store. +func (e *Engine) persistState() { + if e.store == nil { + return + } + + // Persist handoffs + for id := range e.state.HandedOff { + if err := e.store.SaveHandoff(id); err != nil { + e.logger.Warn("failed to save handoff", "item", id, "error", err) + } + } + + // Persist retries + for _, re := range e.state.RetryQueue { + if err := e.store.SaveRetry(state.RetryRecord{ + WorkItemID: re.WorkItemID, + Attempt: re.Attempt, + DueAtMs: re.DueAt.UnixMilli(), + Error: re.Error, + }); err != nil { + e.logger.Warn("failed to save retry", "item", re.WorkItemID, "error", err) + } + } + + // Persist totals + if err := e.store.SaveTotals(state.AgentTotalsRecord{ + InputTokens: e.state.Totals.InputTokens, + OutputTokens: e.state.Totals.OutputTokens, + TotalTokens: e.state.Totals.TotalTokens, + SecondsRunning: e.state.Totals.SecondsRunning, + SessionsStarted: e.state.Totals.SessionsStarted, + }); err != nil { + e.logger.Warn("failed to save totals", "error", err) + } + + e.logger.Info("state persisted", + "handoffs", len(e.state.HandedOff), + "retries", len(e.state.RetryQueue), + ) +} + +// transition performs an FSM transition with event logging. +func (e *Engine) transition(itemID string, event domain.Event, guard func(domain.TransitionGuard) bool) (domain.TransitionResult, error) { + current := e.state.ItemState(itemID) + result, err := domain.Transition(current, event, guard) + if err != nil { + e.logger.Error("invalid transition", "item", itemID, "from", current, "event", event, "error", err) + return result, err + } + + e.state.SetItemState(itemID, result.To) + + // Log to event store + if e.eventLog != nil { + _ = e.eventLog.Append(domain.FSMEvent{ + Timestamp: time.Now(), + ItemID: itemID, + From: result.From, + To: result.To, + Event: event, + Guard: result.Guard, + }) + } + + e.logger.Debug("transition", "item", itemID, "from", result.From, "to", result.To, "event", event) + return result, nil +} + diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go new file mode 100644 index 0000000..fc05b4f --- /dev/null +++ b/internal/engine/engine_test.go @@ -0,0 +1,265 @@ +package engine + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "testing" + "time" + + "github.com/shivamstaq/github-symphony/internal/agent" + agentmock "github.com/shivamstaq/github-symphony/internal/agent/mock" + "github.com/shivamstaq/github-symphony/internal/config" + "github.com/shivamstaq/github-symphony/internal/domain" + "github.com/shivamstaq/github-symphony/internal/tracker" +) + +// mockTracker implements tracker.Tracker for testing. +type mockTracker struct { + items []domain.WorkItem +} + +func (m *mockTracker) FetchCandidates(ctx context.Context) ([]domain.WorkItem, error) { + return m.items, nil +} +func (m *mockTracker) FetchStates(ctx context.Context, ids []string) ([]domain.WorkItem, error) { + // Return items that match the requested IDs + idSet := make(map[string]bool, len(ids)) + for _, id := range ids { + idSet[id] = true + } + var result []domain.WorkItem + for _, item := range m.items { + if idSet[item.WorkItemID] { + result = append(result, item) + } + } + return result, nil +} +func (m *mockTracker) ValidateConfig(ctx context.Context, input tracker.ValidationInput) ([]tracker.ValidationProblem, error) { + return nil, nil +} +func (m *mockTracker) CreateMissingFields(ctx context.Context, problems []tracker.ValidationProblem) error { + return nil +} + +func newTestEngine(t *testing.T, items []domain.WorkItem, mockAgent agent.Agent) *Engine { + t.Helper() + tmpDir := t.TempDir() + evtLogPath := filepath.Join(tmpDir, "events.jsonl") + evtLog, err := NewEventLog(evtLogPath) + if err != nil { + t.Fatal(err) + } + + cfg := &config.SymphonyConfig{} + cfg.Tracker.ActiveValues = []string{"Todo"} + cfg.Tracker.TerminalValues = []string{"Done"} + cfg.Tracker.ExecutableItemTypes = []string{"issue"} + cfg.Agent.MaxConcurrent = 5 + cfg.Agent.MaxTurns = 10 + cfg.Agent.MaxContinuationRetries = 3 + cfg.Agent.MaxRetryBackoffMs = 1000 + cfg.Polling.IntervalMs = 100 + + return New(Deps{ + Config: cfg, + Tracker: &mockTracker{items: items}, + Agent: mockAgent, + EventLog: evtLog, + Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})), + }) +} + +func makeItem(id string, issueNum int) domain.WorkItem { + return domain.WorkItem{ + WorkItemID: id, + ProjectItemID: "proj-" + id, + ContentType: "issue", + IssueNumber: &issueNum, + IssueIdentifier: "org/repo#" + id, + Title: "Test issue " + id, + Description: "Fix something", + State: "open", + ProjectStatus: "Todo", + } +} + +func TestEngine_DispatchAndHandoff(t *testing.T) { + item := makeItem("42", 42) + mockAgent := agentmock.NewSuccessAgent() + eng := newTestEngine(t, []domain.WorkItem{item}, mockAgent) + + // Run one poll tick + ctx := context.Background() + eng.handlePollTick(ctx) + + // Wait for worker goroutine to complete and events to be processed + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + // Process any pending events + select { + case evt := <-eng.eventCh: + eng.handleEvent(ctx, evt) + default: + } + + if eng.state.HandedOff["42"] { + break + } + time.Sleep(10 * time.Millisecond) + } + + if !eng.state.HandedOff["42"] { + t.Fatal("expected item 42 to be handed off") + } + + st := eng.state.ItemState("42") + if st != domain.StateHandedOff { + t.Errorf("expected state handed_off, got %q", st) + } + + if eng.state.HandoffTotal != 1 { + t.Errorf("expected handoff_total=1, got %d", eng.state.HandoffTotal) + } +} + +func TestEngine_NoCommitsNeedsHuman(t *testing.T) { + item := makeItem("43", 43) + mockAgent := agentmock.NewNoCommitsAgent() + eng := newTestEngine(t, []domain.WorkItem{item}, mockAgent) + + ctx := context.Background() + eng.handlePollTick(ctx) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + select { + case evt := <-eng.eventCh: + eng.handleEvent(ctx, evt) + default: + } + + st := eng.state.ItemState("43") + if st == domain.StateNeedsHuman { + break + } + time.Sleep(10 * time.Millisecond) + } + + st := eng.state.ItemState("43") + if st != domain.StateNeedsHuman { + t.Errorf("expected state needs_human, got %q", st) + } + + // Must NOT be retried + if _, inRetry := eng.state.RetryQueue["43"]; inRetry { + t.Error("no-commits exit should NOT schedule a retry") + } +} + +func TestEngine_ErrorWithRetry(t *testing.T) { + item := makeItem("44", 44) + mockAgent := agentmock.NewFailAgent(context.DeadlineExceeded) + eng := newTestEngine(t, []domain.WorkItem{item}, mockAgent) + + ctx := context.Background() + eng.handlePollTick(ctx) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + select { + case evt := <-eng.eventCh: + eng.handleEvent(ctx, evt) + default: + } + + st := eng.state.ItemState("44") + if st == domain.StateQueued { + break + } + time.Sleep(10 * time.Millisecond) + } + + st := eng.state.ItemState("44") + if st != domain.StateQueued { + t.Errorf("expected state queued (retry), got %q", st) + } + + re, ok := eng.state.RetryQueue["44"] + if !ok { + t.Fatal("expected retry entry for item 44") + } + if re.Attempt != 1 { + t.Errorf("expected attempt=1, got %d", re.Attempt) + } +} + +func TestEngine_HandedOffNotReDispatched(t *testing.T) { + item := makeItem("45", 45) + mockAgent := agentmock.NewSuccessAgent() + eng := newTestEngine(t, []domain.WorkItem{item}, mockAgent) + + ctx := context.Background() + + // First dispatch -> handoff + eng.handlePollTick(ctx) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + select { + case evt := <-eng.eventCh: + eng.handleEvent(ctx, evt) + default: + } + if eng.state.HandedOff["45"] { + break + } + time.Sleep(10 * time.Millisecond) + } + + if !eng.state.HandedOff["45"] { + t.Fatal("expected item 45 to be handed off") + } + + // Second poll — item should NOT be dispatched again + prevDispatch := eng.state.DispatchTotal + eng.handlePollTick(ctx) + + // Drain events + for { + select { + case evt := <-eng.eventCh: + eng.handleEvent(ctx, evt) + default: + goto done + } + } +done: + + if eng.state.DispatchTotal != prevDispatch { + t.Error("handed-off item was re-dispatched — this is the critical cost leak bug") + } +} + +func TestEngine_EligibilityBlocksIneligible(t *testing.T) { + // Item with terminal status should not be dispatched + item := domain.WorkItem{ + WorkItemID: "99", + ProjectItemID: "proj-99", + ContentType: "issue", + Title: "Done issue", + State: "open", + ProjectStatus: "Done", + } + mockAgent := agentmock.NewSuccessAgent() + eng := newTestEngine(t, []domain.WorkItem{item}, mockAgent) + + ctx := context.Background() + eng.handlePollTick(ctx) + + // Should not dispatch + if eng.state.DispatchTotal != 0 { + t.Error("terminal-status item should not be dispatched") + } +} diff --git a/internal/engine/eventlog.go b/internal/engine/eventlog.go new file mode 100644 index 0000000..a6c0b3d --- /dev/null +++ b/internal/engine/eventlog.go @@ -0,0 +1,46 @@ +package engine + +import ( + "encoding/json" + "fmt" + "os" + "sync" + + "github.com/shivamstaq/github-symphony/internal/domain" +) + +// EventLog is an append-only JSONL writer for FSM state transitions. +type EventLog struct { + mu sync.Mutex + file *os.File +} + +// NewEventLog opens (or creates) the event log file for appending. +func NewEventLog(path string) (*EventLog, error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return nil, fmt.Errorf("open event log: %w", err) + } + return &EventLog{file: f}, nil +} + +// Append writes an FSM event to the log. +func (l *EventLog) Append(evt domain.FSMEvent) error { + data, err := json.Marshal(evt) + if err != nil { + return fmt.Errorf("marshal event: %w", err) + } + data = append(data, '\n') + + l.mu.Lock() + defer l.mu.Unlock() + _, err = l.file.Write(data) + return err +} + +// Close flushes and closes the event log. +func (l *EventLog) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + return l.file.Close() +} diff --git a/internal/engine/events.go b/internal/engine/events.go new file mode 100644 index 0000000..1cc74e3 --- /dev/null +++ b/internal/engine/events.go @@ -0,0 +1,91 @@ +package engine + +import ( + "time" + + "github.com/shivamstaq/github-symphony/internal/agent" + "github.com/shivamstaq/github-symphony/internal/domain" +) + +// EventType classifies engine events flowing through the central event loop. +type EventType string + +const ( + EvtPollTick EventType = "poll_tick" + EvtItemDiscovered EventType = "item_discovered" + EvtItemClaimed EventType = "item_claimed" + EvtDispatch EventType = "dispatch" + EvtWorkspaceReady EventType = "workspace_ready" + EvtTurnStarted EventType = "turn_started" + EvtTurnCompleted EventType = "turn_completed" + EvtAgentExited EventType = "agent_exited" + EvtAgentUpdate EventType = "agent_update" + EvtPauseRequested EventType = "pause_requested" + EvtResumeRequested EventType = "resume_requested" + EvtCancelRequested EventType = "cancel_requested" + EvtStallDetected EventType = "stall_detected" + EvtBudgetExceeded EventType = "budget_exceeded" + EvtPRCreated EventType = "pr_created" + EvtWritebackError EventType = "writeback_error" + EvtReconcile EventType = "reconcile" + EvtRetryDue EventType = "retry_due" + EvtWebhookReceived EventType = "webhook_received" + EvtShutdown EventType = "shutdown" + EvtConfigReload EventType = "config_reload" +) + +// EngineEvent is the typed message flowing through the central event loop. +type EngineEvent struct { + Type EventType + Timestamp time.Time + ItemID string // work item ID (empty for system events) + Payload any +} + +// Typed payloads for each event type. + +type PollTickPayload struct{} + +type ItemDiscoveredPayload struct { + Items []domain.WorkItem +} + +type DispatchPayload struct { + Item domain.WorkItem +} + +type AgentExitedPayload struct { + Result agent.Result +} + +type AgentUpdatePayload struct { + Update agent.Update +} + +type PRCreatedPayload struct { + Number int + URL string +} + +type WritebackErrorPayload struct { + Error error +} + +type RetryDuePayload struct { + Attempt int +} + +type StallDetectedPayload struct { + LastActivity time.Time + Threshold time.Duration +} + +// NewEvent creates an EngineEvent with the current timestamp. +func NewEvent(typ EventType, itemID string, payload any) EngineEvent { + return EngineEvent{ + Type: typ, + Timestamp: time.Now(), + ItemID: itemID, + Payload: payload, + } +} diff --git a/internal/engine/handoff.go b/internal/engine/handoff.go new file mode 100644 index 0000000..73ca028 --- /dev/null +++ b/internal/engine/handoff.go @@ -0,0 +1,174 @@ +package engine + +import ( + "context" + "fmt" + "strings" + + "github.com/shivamstaq/github-symphony/internal/codehost" + "github.com/shivamstaq/github-symphony/internal/domain" +) + +// HandoffInput contains the data needed to evaluate handoff. +type HandoffInput struct { + HasPR bool + CurrentProjectStatus string + HandoffProjectStatus string // configured target status for handoff + RequiredChecks []string + PassedChecks []string +} + +// HandoffResult is the outcome of handoff evaluation. +type HandoffResult struct { + IsHandoff bool + Reason string +} + +// EvaluateHandoff determines whether a work item has met the handoff criteria. +// +// Rules: +// 1. handoff_project_status must be configured (non-empty) +// 2. A PR must exist +// 3. Project status must match handoff_project_status +// 4. If required_checks are configured, all must be in passed_checks +func EvaluateHandoff(input HandoffInput) HandoffResult { + if input.HandoffProjectStatus == "" { + return HandoffResult{IsHandoff: false, Reason: "handoff_status not configured"} + } + + if !input.HasPR { + return HandoffResult{IsHandoff: false, Reason: "no PR linked"} + } + + if !strings.EqualFold(input.CurrentProjectStatus, input.HandoffProjectStatus) { + return HandoffResult{IsHandoff: false, Reason: "status not at handoff value"} + } + + // Check required CI checks + if len(input.RequiredChecks) > 0 { + passed := make(map[string]bool, len(input.PassedChecks)) + for _, c := range input.PassedChecks { + passed[c] = true + } + for _, req := range input.RequiredChecks { + if !passed[req] { + return HandoffResult{IsHandoff: false, Reason: "required check not passed: " + req} + } + } + } + + return HandoffResult{IsHandoff: true, Reason: "PR + status + checks met"} +} + +// performHandoff pushes the branch, creates a PR, comments on the issue, +// updates project status, and transitions the item to handed_off state. +func (e *Engine) performHandoff(itemID string, item domain.WorkItem, entry *RunningEntry) { + // Verify item is still in completed state (may have been cancelled/stalled concurrently) + if current := e.state.ItemState(itemID); current != domain.StateCompleted { + e.logger.Warn("handoff skipped — item no longer in completed state", + "item", item.IssueIdentifier, "current_state", current) + return + } + + ctx := context.Background() + + if e.codeHost != nil && item.Repository != nil { + baseBranch := item.Repository.DefaultBranch + if baseBranch == "" { + baseBranch = "main" + } + branchName := "" + if entry != nil { + branchName = entry.BranchName + } + if branchName == "" { + branchName = fmt.Sprintf("%s%s_%s_%d", + e.cfg.Git.BranchPrefix, + item.Repository.Owner, + item.Repository.Name, + safeIssueNum(item.IssueNumber)) + } + + // Push branch to remote before creating PR + if e.wsMgr != nil && entry != nil && entry.WorkspacePath != "" { + pushRemote := e.cfg.Git.PushRemote + if pushRemote == "" { + pushRemote = "origin" + } + if err := e.wsMgr.PushBranch(entry.WorkspacePath, pushRemote, branchName); err != nil { + e.logger.Error("branch push failed", "item", item.IssueIdentifier, "error", err) + _, _ = e.transition(itemID, domain.EventError, nil) + return + } + } + + prResult, err := e.codeHost.UpsertPR(ctx, codehost.PRParams{ + Owner: item.Repository.Owner, + Repo: item.Repository.Name, + Title: fmt.Sprintf("[Symphony] %s", item.Title), + Body: fmt.Sprintf("Automated PR for %s\n\nGenerated by Symphony.", item.IssueIdentifier), + HeadBranch: branchName, + BaseBranch: baseBranch, + Draft: e.cfg.PullRequest.DraftByDefault, + }) + if err != nil { + e.logger.Error("PR creation failed", "item", item.IssueIdentifier, "error", err) + _, _ = e.transition(itemID, domain.EventError, nil) + return + } + + e.logger.Info("PR created", "item", item.IssueIdentifier, "pr", prResult.URL) + + // Comment on issue + if e.cfg.PullRequest.CommentOnIssue && item.IssueNumber != nil { + body := fmt.Sprintf("Symphony created PR #%d: %s", prResult.Number, prResult.URL) + _, _ = e.codeHost.CommentOnItem(ctx, codehost.ItemRef{ + Owner: item.Repository.Owner, + Repo: item.Repository.Name, + Number: *item.IssueNumber, + }, body) + } + + // Update project status to handoff value + if e.cfg.PullRequest.HandoffStatus != "" { + meta, err := e.codeHost.FetchProjectMeta(ctx, codehost.ProjectMetaParams{ + Owner: e.cfg.Tracker.Owner, + ProjectNumber: e.cfg.Tracker.ProjectNumber, + Scope: e.cfg.Tracker.ProjectScope, + FieldName: e.cfg.Tracker.StatusFieldName, + }) + if err == nil { + if optionID, ok := meta.Options[e.cfg.PullRequest.HandoffStatus]; ok { + _ = e.codeHost.UpdateProjectStatus(ctx, codehost.StatusUpdateParams{ + ProjectID: meta.ProjectID, + ItemID: item.ProjectItemID, + FieldID: meta.FieldID, + OptionID: optionID, + }) + } + } + } + } + + if _, err := e.transition(itemID, domain.EventPRCreated, nil); err != nil { + e.logger.Error("handoff transition failed", "item", item.IssueIdentifier, "error", err) + return + } + e.state.HandedOff[itemID] = true + e.state.HandoffTotal++ + e.state.Totals.Writebacks++ + + // Persist handoff immediately + if e.store != nil { + _ = e.store.SaveHandoff(itemID) + } + + e.logger.Info("handoff complete", "item", item.IssueIdentifier) +} + +func safeIssueNum(n *int) int { + if n == nil { + return 0 + } + return *n +} diff --git a/internal/engine/handoff_test.go b/internal/engine/handoff_test.go new file mode 100644 index 0000000..8acd200 --- /dev/null +++ b/internal/engine/handoff_test.go @@ -0,0 +1,89 @@ +package engine + +import "testing" + +func TestEvaluateHandoff_AllConditionsMet(t *testing.T) { + result := EvaluateHandoff(HandoffInput{ + HasPR: true, + CurrentProjectStatus: "Human Review", + HandoffProjectStatus: "Human Review", + }) + if !result.IsHandoff { + t.Errorf("expected handoff, got: %s", result.Reason) + } +} + +func TestEvaluateHandoff_NoPR(t *testing.T) { + result := EvaluateHandoff(HandoffInput{ + HasPR: false, + CurrentProjectStatus: "Human Review", + HandoffProjectStatus: "Human Review", + }) + if result.IsHandoff { + t.Error("should not handoff without PR") + } + if result.Reason != "no PR linked" { + t.Errorf("unexpected reason: %s", result.Reason) + } +} + +func TestEvaluateHandoff_WrongStatus(t *testing.T) { + result := EvaluateHandoff(HandoffInput{ + HasPR: true, + CurrentProjectStatus: "In Progress", + HandoffProjectStatus: "Human Review", + }) + if result.IsHandoff { + t.Error("should not handoff with wrong status") + } +} + +func TestEvaluateHandoff_NotConfigured(t *testing.T) { + result := EvaluateHandoff(HandoffInput{ + HasPR: true, + HandoffProjectStatus: "", // not configured + }) + if result.IsHandoff { + t.Error("should not handoff when not configured") + } +} + +func TestEvaluateHandoff_CaseInsensitive(t *testing.T) { + result := EvaluateHandoff(HandoffInput{ + HasPR: true, + CurrentProjectStatus: "human review", + HandoffProjectStatus: "Human Review", + }) + if !result.IsHandoff { + t.Errorf("case-insensitive match should trigger handoff: %s", result.Reason) + } +} + +func TestEvaluateHandoff_RequiredChecksMet(t *testing.T) { + result := EvaluateHandoff(HandoffInput{ + HasPR: true, + CurrentProjectStatus: "Human Review", + HandoffProjectStatus: "Human Review", + RequiredChecks: []string{"ci/build", "ci/test"}, + PassedChecks: []string{"ci/build", "ci/test", "ci/lint"}, + }) + if !result.IsHandoff { + t.Errorf("all checks passed, expected handoff: %s", result.Reason) + } +} + +func TestEvaluateHandoff_RequiredChecksMissing(t *testing.T) { + result := EvaluateHandoff(HandoffInput{ + HasPR: true, + CurrentProjectStatus: "Human Review", + HandoffProjectStatus: "Human Review", + RequiredChecks: []string{"ci/build", "ci/test"}, + PassedChecks: []string{"ci/build"}, // missing ci/test + }) + if result.IsHandoff { + t.Error("should not handoff with missing check") + } + if result.Reason != "required check not passed: ci/test" { + t.Errorf("unexpected reason: %s", result.Reason) + } +} diff --git a/internal/engine/reconcile.go b/internal/engine/reconcile.go new file mode 100644 index 0000000..fa8bba9 --- /dev/null +++ b/internal/engine/reconcile.go @@ -0,0 +1,102 @@ +package engine + +import ( + "context" + "strings" + + "github.com/shivamstaq/github-symphony/internal/domain" +) + +// ReconcileAction describes what to do with a running item after state refresh. +type ReconcileAction string + +const ( + ActionKeep ReconcileAction = "keep" // still active, continue + ActionTerminate ReconcileAction = "terminate" // terminal status or closed + ActionStop ReconcileAction = "stop" // non-active, non-terminal +) + +// ClassifyRefreshed determines the reconciliation action for a work item +// based on its refreshed state from the tracker. +func ClassifyRefreshed(item domain.WorkItem, activeValues, terminalValues []string) ReconcileAction { + // Closed issue → terminate + if item.State != "" && strings.ToLower(item.State) == "closed" { + return ActionTerminate + } + + // Terminal project status → terminate + for _, v := range terminalValues { + if strings.EqualFold(item.ProjectStatus, v) { + return ActionTerminate + } + } + + // Active status + open → keep running + for _, v := range activeValues { + if strings.EqualFold(item.ProjectStatus, v) { + if item.State == "" || strings.ToLower(item.State) == "open" { + return ActionKeep + } + } + } + + // Not active, not terminal → stop (moved to a non-active status) + return ActionStop +} + +// reconcileRunningItems refreshes the state of all running items from the tracker +// and applies the appropriate action. +func (e *Engine) reconcileRunningItems(ctx context.Context) { + if len(e.state.Running) == 0 || e.tracker == nil { + return + } + + // Collect IDs of running items + ids := make([]string, 0, len(e.state.Running)) + for id := range e.state.Running { + ids = append(ids, id) + } + + // Fetch fresh state from tracker + items, err := e.tracker.FetchStates(ctx, ids) + if err != nil { + e.logger.Error("reconcile fetch failed", "error", err) + return + } + + // Build lookup + fresh := make(map[string]domain.WorkItem, len(items)) + for _, item := range items { + fresh[item.WorkItemID] = item + } + + // Apply actions + for itemID, entry := range e.state.Running { + item, found := fresh[itemID] + if !found { + // Item disappeared from project — treat as terminated + e.logger.Warn("item missing from tracker, cancelling", "item", entry.WorkItem.IssueIdentifier) + entry.CancelFunc() + delete(e.state.Running, itemID) + _, _ = e.transition(itemID, domain.EventCancelled, nil) + continue + } + + action := ClassifyRefreshed(item, e.cfg.Tracker.ActiveValues, e.cfg.Tracker.TerminalValues) + switch action { + case ActionKeep: + // Update snapshot + entry.WorkItem = item + case ActionTerminate, ActionStop: + e.logger.Info("reconcile: stopping worker", + "item", entry.WorkItem.IssueIdentifier, + "action", action, + "status", item.ProjectStatus, + "state", item.State, + ) + entry.CancelFunc() + delete(e.state.Running, itemID) + _, _ = e.transition(itemID, domain.EventCancelled, nil) + } + } +} diff --git a/internal/engine/reconcile_test.go b/internal/engine/reconcile_test.go new file mode 100644 index 0000000..bfee5e6 --- /dev/null +++ b/internal/engine/reconcile_test.go @@ -0,0 +1,47 @@ +package engine + +import ( + "testing" + + "github.com/shivamstaq/github-symphony/internal/domain" +) + +func TestClassifyRefreshed_ClosedIssue(t *testing.T) { + item := domain.WorkItem{State: "closed", ProjectStatus: "Todo"} + action := ClassifyRefreshed(item, []string{"Todo"}, []string{"Done"}) + if action != ActionTerminate { + t.Errorf("closed issue should terminate, got %q", action) + } +} + +func TestClassifyRefreshed_TerminalStatus(t *testing.T) { + item := domain.WorkItem{State: "open", ProjectStatus: "Done"} + action := ClassifyRefreshed(item, []string{"Todo"}, []string{"Done"}) + if action != ActionTerminate { + t.Errorf("terminal status should terminate, got %q", action) + } +} + +func TestClassifyRefreshed_ActiveAndOpen(t *testing.T) { + item := domain.WorkItem{State: "open", ProjectStatus: "Todo"} + action := ClassifyRefreshed(item, []string{"Todo", "In Progress"}, []string{"Done"}) + if action != ActionKeep { + t.Errorf("active + open should keep, got %q", action) + } +} + +func TestClassifyRefreshed_NonActiveStatus(t *testing.T) { + item := domain.WorkItem{State: "open", ProjectStatus: "Backlog"} + action := ClassifyRefreshed(item, []string{"Todo"}, []string{"Done"}) + if action != ActionStop { + t.Errorf("non-active status should stop, got %q", action) + } +} + +func TestClassifyRefreshed_CaseInsensitive(t *testing.T) { + item := domain.WorkItem{State: "OPEN", ProjectStatus: "todo"} + action := ClassifyRefreshed(item, []string{"Todo"}, []string{"Done"}) + if action != ActionKeep { + t.Errorf("case-insensitive match should keep, got %q", action) + } +} diff --git a/internal/engine/retry.go b/internal/engine/retry.go new file mode 100644 index 0000000..59f876d --- /dev/null +++ b/internal/engine/retry.go @@ -0,0 +1,151 @@ +package engine + +import ( + "context" + "time" + + "github.com/shivamstaq/github-symphony/internal/agent" + "github.com/shivamstaq/github-symphony/internal/domain" +) + +// RetryDelay calculates exponential backoff capped at maxMs. +// Base delay is 10s, doubling each attempt: 10s, 20s, 40s, 80s, ... +func RetryDelay(attempt, maxMs int) time.Duration { + delayMs := 10000 // 10s base + for i := 1; i < attempt; i++ { + delayMs *= 2 + } + if maxMs > 0 && delayMs > maxMs { + delayMs = maxMs + } + return time.Duration(delayMs) * time.Millisecond +} + +// scheduleRetry adds a work item to the retry queue with exponential backoff. +func (e *Engine) scheduleRetry(itemID string, item domain.WorkItem, attempt int, errMsg string) { + delay := RetryDelay(attempt, e.cfg.Agent.MaxRetryBackoffMs) + e.state.RetryQueue[itemID] = &RetryEntry{ + WorkItemID: itemID, + IssueIdentifier: item.IssueIdentifier, + Attempt: attempt, + DueAt: time.Now().Add(delay), + Error: errMsg, + WorkItem: &item, // store for re-dispatch + } + e.logger.Info("retry scheduled", + "item", item.IssueIdentifier, + "attempt", attempt, + "due_in", delay, + ) +} + +// handleWorkerError decides between retry and failure based on retry count. +// Called when an agent exits with an error or StopFailed. +func (e *Engine) handleWorkerError(itemID string, item domain.WorkItem, result agent.Result, previousAttempt int) { + maxRetries := e.cfg.Agent.MaxContinuationRetries + + if previousAttempt >= maxRetries { + // Max retries exhausted → failed (terminal) + _, _ = e.transition(itemID, domain.EventError, func(g domain.TransitionGuard) bool { + return g == domain.GuardMaxRetriesExhausted + }) + e.state.ErrorTotal++ + e.logger.Warn("max retries exhausted", + "item", item.IssueIdentifier, + "attempts", previousAttempt, + ) + } else { + // Has retries left → queued + schedule retry + _, _ = e.transition(itemID, domain.EventError, func(g domain.TransitionGuard) bool { + return g == domain.GuardHasRetriesLeft + }) + errMsg := "" + if result.Error != nil { + errMsg = result.Error.Error() + } + e.scheduleRetry(itemID, item, previousAttempt+1, errMsg) + } +} + +// fireDueRetries scans the retry queue and emits EvtRetryDue for entries past their due time. +func (e *Engine) fireDueRetries() { + now := time.Now() + for itemID, re := range e.state.RetryQueue { + if now.After(re.DueAt) { + e.Emit(NewEvent(EvtRetryDue, itemID, RetryDuePayload{Attempt: re.Attempt})) + } + } +} + +// handleRetryDue re-dispatches a work item whose retry timer has fired. +func (e *Engine) handleRetryDue(ctx context.Context, evt EngineEvent) { + re, ok := e.state.RetryQueue[evt.ItemID] + if !ok { + return + } + + // Remove from retry queue + delete(e.state.RetryQueue, evt.ItemID) + + if re.WorkItem == nil { + e.logger.Warn("retry entry has no stored work item, skipping", "item", evt.ItemID) + // Release the item back to open so it can be re-fetched + e.state.SetItemState(evt.ItemID, domain.StateOpen) + return + } + + e.logger.Info("retry firing", "item", re.IssueIdentifier, "attempt", re.Attempt) + + // Re-check eligibility before re-dispatch + eligible, reason := IsEligible(*re.WorkItem, e.eligCfg, e.state, e.cfg.Agent.MaxConcurrent) + if !eligible { + e.logger.Info("retry item no longer eligible, releasing", + "item", re.IssueIdentifier, + "reason", reason, + ) + e.state.SetItemState(evt.ItemID, domain.StateOpen) + return + } + + // Dispatch with retry attempt tracking + if err := e.dispatchItemWithRetry(ctx, *re.WorkItem, re.Attempt); err != nil { + e.logger.Error("retry dispatch failed", + "item", re.IssueIdentifier, + "error", err, + ) + // Re-queue with incremented attempt + e.scheduleRetry(evt.ItemID, *re.WorkItem, re.Attempt+1, err.Error()) + } +} + +// dispatchItemWithRetry is like dispatchItem but carries the retry attempt count. +func (e *Engine) dispatchItemWithRetry(ctx context.Context, item domain.WorkItem, attempt int) error { + // The item is already in StateQueued from the error transition. + // Transition: queued -> preparing + _, err := e.transition(item.WorkItemID, domain.EventDispatch, func(g domain.TransitionGuard) bool { + return g == domain.GuardConcurrencyOK || g == domain.GuardNone + }) + if err != nil { + return err + } + + e.state.DispatchTotal++ + + workerCtx, cancel := context.WithCancel(ctx) + entry := &RunningEntry{ + WorkItem: item, + CancelFunc: cancel, + Phase: domain.StatePreparing, + RetryAttempt: attempt, + StartedAt: time.Now(), + LastActivityAt: time.Now(), + } + e.state.Running[item.WorkItemID] = entry + + _, _ = e.transition(item.WorkItemID, domain.EventWorkspaceReady, nil) + entry.Phase = domain.StateRunning + + go e.runWorker(workerCtx, item, entry) + + return nil +} diff --git a/internal/engine/retry_test.go b/internal/engine/retry_test.go new file mode 100644 index 0000000..3c6772d --- /dev/null +++ b/internal/engine/retry_test.go @@ -0,0 +1,39 @@ +package engine + +import ( + "testing" + "time" +) + +func TestRetryDelay_ExponentialBackoff(t *testing.T) { + tests := []struct { + attempt int + maxMs int + wantMs int + }{ + {1, 300000, 10000}, // 10s + {2, 300000, 20000}, // 20s + {3, 300000, 40000}, // 40s + {4, 300000, 80000}, // 80s + {5, 300000, 160000}, // 160s + {6, 300000, 300000}, // capped at 300s + {7, 300000, 300000}, // still capped + {1, 5000, 5000}, // capped at low max + } + + for _, tt := range tests { + got := RetryDelay(tt.attempt, tt.maxMs) + wantDur := time.Duration(tt.wantMs) * time.Millisecond + if got != wantDur { + t.Errorf("RetryDelay(%d, %d) = %v, want %v", tt.attempt, tt.maxMs, got, wantDur) + } + } +} + +func TestRetryDelay_ZeroMaxUnlimited(t *testing.T) { + // maxMs=0 means no cap + got := RetryDelay(10, 0) + if got < time.Second { + t.Errorf("expected large delay, got %v", got) + } +} diff --git a/internal/engine/stall.go b/internal/engine/stall.go new file mode 100644 index 0000000..8ca5b03 --- /dev/null +++ b/internal/engine/stall.go @@ -0,0 +1,54 @@ +package engine + +import ( + "time" + + "github.com/shivamstaq/github-symphony/internal/domain" +) + +// detectStalls scans running entries and fires EvtStallDetected for workers +// that haven't reported activity within the configured stall timeout. +func (e *Engine) detectStalls() { + stallTimeoutMs := e.cfg.Agent.StallTimeoutMs + if stallTimeoutMs <= 0 { + return // stall detection disabled + } + threshold := time.Duration(stallTimeoutMs) * time.Millisecond + now := time.Now() + + for itemID, entry := range e.state.Running { + if entry.Phase != domain.StateRunning { + continue + } + elapsed := now.Sub(entry.LastActivityAt) + if elapsed > threshold { + e.Emit(NewEvent(EvtStallDetected, itemID, StallDetectedPayload{ + LastActivity: entry.LastActivityAt, + Threshold: threshold, + })) + } + } +} + +// handleStallDetected kills the stalled worker and transitions to needs_human. +func (e *Engine) handleStallDetected(evt EngineEvent) { + itemID := evt.ItemID + entry, ok := e.state.Running[itemID] + if !ok { + return + } + + payload := evt.Payload.(StallDetectedPayload) + e.logger.Warn("stall detected, killing worker", + "item", entry.WorkItem.IssueIdentifier, + "last_activity", payload.LastActivity, + "threshold", payload.Threshold, + ) + + // Kill the worker process + entry.CancelFunc() + delete(e.state.Running, itemID) + + // FSM: running -> needs_human + _, _ = e.transition(itemID, domain.EventStallDetected, nil) +} diff --git a/internal/engine/stall_test.go b/internal/engine/stall_test.go new file mode 100644 index 0000000..02fd3fd --- /dev/null +++ b/internal/engine/stall_test.go @@ -0,0 +1,108 @@ +package engine + +import ( + "context" + "testing" + "time" + + agentmock "github.com/shivamstaq/github-symphony/internal/agent/mock" + "github.com/shivamstaq/github-symphony/internal/domain" +) + +func TestStallDetection_StalledWorkerNeedsHuman(t *testing.T) { + item := makeItem("50", 50) + // Use a slow mock that takes 5 seconds per turn + mockAgent := &agentmock.MockAgent{ + StopReason: "completed", + NumTurns: 1, + HasCommits: true, + Delay: 5 * time.Second, + } + eng := newTestEngine(t, []domain.WorkItem{item}, mockAgent) + eng.cfg.Agent.StallTimeoutMs = 50 // 50ms stall timeout + + ctx := context.Background() + eng.handlePollTick(ctx) + + // Wait for dispatch, then backdate LastActivityAt + time.Sleep(20 * time.Millisecond) + if entry, ok := eng.state.Running["50"]; ok { + entry.LastActivityAt = time.Now().Add(-200 * time.Millisecond) + } + + // Run stall detection + eng.detectStalls() + + // Process stall event + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + select { + case evt := <-eng.eventCh: + eng.handleEvent(ctx, evt) + default: + } + if eng.state.ItemState("50") == domain.StateNeedsHuman { + break + } + time.Sleep(5 * time.Millisecond) + } + + if eng.state.ItemState("50") != domain.StateNeedsHuman { + t.Errorf("expected needs_human after stall, got %q", eng.state.ItemState("50")) + } + + // Worker should be removed from running + if _, running := eng.state.Running["50"]; running { + t.Error("stalled worker should be removed from running") + } +} + +func TestStallDetection_ActiveWorkerNotStalled(t *testing.T) { + item := makeItem("51", 51) + mockAgent := agentmock.NewSuccessAgent() + eng := newTestEngine(t, []domain.WorkItem{item}, mockAgent) + eng.cfg.Agent.StallTimeoutMs = 60000 // 60s timeout + + ctx := context.Background() + eng.handlePollTick(ctx) + + // Worker just started — should not be stalled + eng.detectStalls() + + select { + case evt := <-eng.eventCh: + if evt.Type == EvtStallDetected { + t.Error("active worker should not trigger stall detection") + } + eng.handleEvent(ctx, evt) + default: + // Good — no stall event + } +} + +func TestStallDetection_DisabledWhenZero(t *testing.T) { + item := makeItem("52", 52) + mockAgent := agentmock.NewSuccessAgent() + eng := newTestEngine(t, []domain.WorkItem{item}, mockAgent) + eng.cfg.Agent.StallTimeoutMs = 0 // disabled + + ctx := context.Background() + eng.handlePollTick(ctx) + + // Backdate activity + if entry, ok := eng.state.Running["52"]; ok { + entry.LastActivityAt = time.Now().Add(-time.Hour) + } + + eng.detectStalls() + + select { + case evt := <-eng.eventCh: + if evt.Type == EvtStallDetected { + t.Error("stall detection should be disabled when timeout=0") + } + eng.handleEvent(ctx, evt) + default: + // Good + } +} diff --git a/internal/engine/state.go b/internal/engine/state.go new file mode 100644 index 0000000..a062ddb --- /dev/null +++ b/internal/engine/state.go @@ -0,0 +1,117 @@ +package engine + +import ( + "context" + "time" + + "github.com/shivamstaq/github-symphony/internal/agent" + "github.com/shivamstaq/github-symphony/internal/domain" +) + +// RunningEntry tracks one active worker. +type RunningEntry struct { + WorkItem domain.WorkItem + Session *agent.Session + CancelFunc context.CancelFunc + Phase domain.ItemState + Paused bool + RetryAttempt int + StartedAt time.Time + LastActivityAt time.Time + InputTokens int + OutputTokens int + TotalTokens int + CostUSD float64 + TurnsCompleted int + WorkspacePath string + BranchName string +} + +// RetryEntry is a scheduled retry. +type RetryEntry struct { + WorkItemID string + IssueIdentifier string + Attempt int + DueAt time.Time + Error string + WorkItem *domain.WorkItem // stored for re-dispatch without re-fetch +} + +// AgentTotals accumulates lifetime agent metrics. +type AgentTotals struct { + InputTokens int64 + OutputTokens int64 + TotalTokens int64 + SecondsRunning float64 + Writebacks int64 + SessionsStarted int64 + CostUSD float64 +} + +// State is the engine's authoritative runtime state. +// Only the engine's event loop goroutine reads/writes this — no mutexes needed. +type State struct { + // Item FSM states (work item ID -> current state) + ItemStates map[string]domain.ItemState + + // Running workers + Running map[string]*RunningEntry + + // Retry queue + RetryQueue map[string]*RetryEntry + + // Handed-off items (prevents re-dispatch) + HandedOff map[string]bool + + // Aggregate metrics + Totals AgentTotals + + // Poll tracking + LastPollAt *time.Time + PendingRefresh bool + + // Counters + DispatchTotal int64 + ErrorTotal int64 + HandoffTotal int64 +} + +// NewState creates an initialized empty state. +func NewState() *State { + return &State{ + ItemStates: make(map[string]domain.ItemState), + Running: make(map[string]*RunningEntry), + RetryQueue: make(map[string]*RetryEntry), + HandedOff: make(map[string]bool), + } +} + +// ItemState returns the current FSM state for a work item, defaulting to StateOpen. +func (s *State) ItemState(itemID string) domain.ItemState { + if st, ok := s.ItemStates[itemID]; ok { + return st + } + return domain.StateOpen +} + +// SetItemState updates the FSM state for a work item. +func (s *State) SetItemState(itemID string, state domain.ItemState) { + if state == domain.StateOpen { + delete(s.ItemStates, itemID) + } else { + s.ItemStates[itemID] = state + } +} + +// IsClaimedOrRunning returns true if the item is in any active state. +func (s *State) IsClaimedOrRunning(itemID string) bool { + st := s.ItemState(itemID) + return st == domain.StateQueued || st == domain.StatePreparing || + st == domain.StateRunning || st == domain.StatePaused || + st == domain.StateCompleted +} + +// RunningCount returns the number of currently running workers. +func (s *State) RunningCount() int { + return len(s.Running) +} diff --git a/internal/github/graphql.go b/internal/github/graphql.go index 07e25b6..0853106 100644 --- a/internal/github/graphql.go +++ b/internal/github/graphql.go @@ -496,13 +496,6 @@ func (c *GraphQLClient) ConvertDraftIssue(ctx context.Context, itemID, repoID st return "", nil } -func getBool(m map[string]any, key string) bool { - if v, ok := m[key].(bool); ok { - return v - } - return false -} - func getString(m map[string]any, key string) string { if v, ok := m[key].(string); ok { return v diff --git a/internal/logging/jsonl.go b/internal/logging/jsonl.go new file mode 100644 index 0000000..8103366 --- /dev/null +++ b/internal/logging/jsonl.go @@ -0,0 +1,98 @@ +package logging + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" +) + +// SetupJSONL creates a slog.Logger that writes JSON lines to a file. +// If the file's parent directory doesn't exist, it is created. +// Returns the logger and the file (caller should close on shutdown). +func SetupJSONL(path string, level string) (*slog.Logger, *os.File, error) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, nil, fmt.Errorf("create log dir: %w", err) + } + + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return nil, nil, fmt.Errorf("open log file: %w", err) + } + + opts := &slog.HandlerOptions{Level: ParseLevel(level)} + handler := slog.NewJSONHandler(f, opts) + return slog.New(handler), f, nil +} + +// SetupMulti creates a logger that writes to both stderr (text) and a JSONL file. +// This is the default setup for Symphony: human-readable TUI output + structured file log. +func SetupMulti(jsonlPath string, level string) (*slog.Logger, *os.File, error) { + dir := filepath.Dir(jsonlPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, nil, fmt.Errorf("create log dir: %w", err) + } + + f, err := os.OpenFile(jsonlPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return nil, nil, fmt.Errorf("open log file: %w", err) + } + + opts := &slog.HandlerOptions{Level: ParseLevel(level)} + handler := &multiHandler{ + file: slog.NewJSONHandler(f, opts), + console: slog.NewTextHandler(os.Stderr, opts), + } + return slog.New(handler), f, nil +} + +// multiHandler writes to both file and console handlers. +type multiHandler struct { + file slog.Handler + console slog.Handler +} + +func (h *multiHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.file.Enabled(ctx, level) || h.console.Enabled(ctx, level) +} + +func (h *multiHandler) Handle(ctx context.Context, r slog.Record) error { + // Write to both; file errors are silent to avoid blocking + _ = h.file.Handle(ctx, r) + return h.console.Handle(ctx, r) +} + +func (h *multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &multiHandler{ + file: h.file.WithAttrs(attrs), + console: h.console.WithAttrs(attrs), + } +} + +func (h *multiHandler) WithGroup(name string) slog.Handler { + return &multiHandler{ + file: h.file.WithGroup(name), + console: h.console.WithGroup(name), + } +} + +// SetupFileOnly creates a JSONL logger that only writes to a file (no stderr). +// Used for per-agent session logs. +func SetupFileOnly(path string) (*slog.Logger, io.Closer, error) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, nil, fmt.Errorf("create log dir: %w", err) + } + + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return nil, nil, fmt.Errorf("open log file: %w", err) + } + + opts := &slog.HandlerOptions{Level: slog.LevelDebug} + handler := slog.NewJSONHandler(f, opts) + return slog.New(handler), f, nil +} diff --git a/internal/orchestrator/concurrency_test.go b/internal/orchestrator/concurrency_test.go deleted file mode 100644 index dbb48d9..0000000 --- a/internal/orchestrator/concurrency_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package orchestrator_test - -import ( - "testing" - - "github.com/shivamstaq/github-symphony/internal/orchestrator" -) - -func TestIsEligible_PerStatusLimit(t *testing.T) { - item := orchestrator.WorkItem{ - WorkItemID: "github:new:issue", - ProjectItemID: "new", - ContentType: "issue", - Title: "New item", - State: "open", - ProjectStatus: "Todo", - IssueNumber: intPtr(5), - Repository: &orchestrator.Repository{FullName: "org/repo"}, - } - - cfg := orchestrator.EligibilityConfig{ - ActiveValues: []string{"Todo"}, - ExecutableItemTypes: []string{"issue"}, - MaxPerStatus: map[string]int{"todo": 1}, // only 1 "Todo" at a time - } - - state := &orchestrator.State{ - Running: map[string]*orchestrator.RunningEntry{ - "existing": {WorkItem: orchestrator.WorkItem{ProjectStatus: "Todo"}}, - }, - Claimed: make(map[string]bool), - } - - eligible, reason := orchestrator.IsEligible(item, cfg, state, 10) - if eligible { - t.Error("should be ineligible: per-status limit for Todo is 1 and 1 already running") - } - if reason == "" { - t.Error("expected a reason") - } -} - -func TestIsEligible_PerStatusLimit_DifferentStatus(t *testing.T) { - item := orchestrator.WorkItem{ - WorkItemID: "github:new:issue", - ProjectItemID: "new", - ContentType: "issue", - Title: "New item", - State: "open", - ProjectStatus: "In Progress", - IssueNumber: intPtr(5), - Repository: &orchestrator.Repository{FullName: "org/repo"}, - } - - cfg := orchestrator.EligibilityConfig{ - ActiveValues: []string{"Todo", "In Progress"}, - ExecutableItemTypes: []string{"issue"}, - MaxPerStatus: map[string]int{"todo": 1}, // limit only on Todo - } - - state := &orchestrator.State{ - Running: map[string]*orchestrator.RunningEntry{ - "existing": {WorkItem: orchestrator.WorkItem{ProjectStatus: "Todo"}}, - }, - Claimed: make(map[string]bool), - } - - eligible, _ := orchestrator.IsEligible(item, cfg, state, 10) - if !eligible { - t.Error("should be eligible: per-status limit only applies to Todo, this is In Progress") - } -} - -func TestIsEligible_PerRepoLimit(t *testing.T) { - item := orchestrator.WorkItem{ - WorkItemID: "github:new:issue", - ProjectItemID: "new", - ContentType: "issue", - Title: "New item", - State: "open", - ProjectStatus: "Todo", - IssueNumber: intPtr(5), - Repository: &orchestrator.Repository{FullName: "org/busy-repo"}, - } - - cfg := orchestrator.EligibilityConfig{ - ActiveValues: []string{"Todo"}, - ExecutableItemTypes: []string{"issue"}, - MaxPerRepo: map[string]int{"org/busy-repo": 1}, - } - - state := &orchestrator.State{ - Running: map[string]*orchestrator.RunningEntry{ - "existing": {WorkItem: orchestrator.WorkItem{ - Repository: &orchestrator.Repository{FullName: "org/busy-repo"}, - }}, - }, - Claimed: make(map[string]bool), - } - - eligible, reason := orchestrator.IsEligible(item, cfg, state, 10) - if eligible { - t.Error("should be ineligible: per-repo limit for org/busy-repo is 1") - } - if reason == "" { - t.Error("expected a reason") - } -} diff --git a/internal/orchestrator/dependency_test.go b/internal/orchestrator/dependency_test.go deleted file mode 100644 index fc8da54..0000000 --- a/internal/orchestrator/dependency_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package orchestrator_test - -import ( - "testing" - - "github.com/shivamstaq/github-symphony/internal/orchestrator" -) - -func baseCfg() orchestrator.EligibilityConfig { - return orchestrator.EligibilityConfig{ - ActiveValues: []string{"Todo"}, - ExecutableItemTypes: []string{"issue"}, - } -} - -func baseState() *orchestrator.State { - return &orchestrator.State{ - Running: make(map[string]*orchestrator.RunningEntry), - Claimed: make(map[string]bool), - } -} - -func baseItem(id string) orchestrator.WorkItem { - num := 1 - return orchestrator.WorkItem{ - WorkItemID: id, - ProjectItemID: "p1", - ContentType: "issue", - Title: "Test", - State: "open", - ProjectStatus: "Todo", - IssueNumber: &num, - Repository: &orchestrator.Repository{FullName: "org/repo"}, - } -} - -// --- Blocking dependency tests --- - -func TestIsEligible_BlockedByOpenDependency(t *testing.T) { - item := baseItem("github:p1:i1") - item.BlockedBy = []orchestrator.BlockerRef{ - {Identifier: "org/repo#10", State: "open"}, - } - - eligible, reason := orchestrator.IsEligible(item, baseCfg(), baseState(), 10) - if eligible { - t.Error("should be ineligible: blocked by open dependency") - } - if reason == "" { - t.Error("expected reason") - } -} - -func TestIsEligible_BlockedByClosedDependency(t *testing.T) { - item := baseItem("github:p1:i1") - item.BlockedBy = []orchestrator.BlockerRef{ - {Identifier: "org/repo#10", State: "closed"}, - } - - eligible, _ := orchestrator.IsEligible(item, baseCfg(), baseState(), 10) - if !eligible { - t.Error("should be eligible: blocker is closed") - } -} - -func TestIsEligible_MultipleBlockers_OneOpen(t *testing.T) { - item := baseItem("github:p1:i1") - item.BlockedBy = []orchestrator.BlockerRef{ - {Identifier: "org/repo#10", State: "closed"}, - {Identifier: "org/repo#11", State: "open"}, - } - - eligible, _ := orchestrator.IsEligible(item, baseCfg(), baseState(), 10) - if eligible { - t.Error("should be ineligible: one blocker still open") - } -} - -func TestIsEligible_MultipleBlockers_AllClosed(t *testing.T) { - item := baseItem("github:p1:i1") - item.BlockedBy = []orchestrator.BlockerRef{ - {Identifier: "org/repo#10", State: "closed"}, - {Identifier: "org/repo#11", State: "closed"}, - } - - eligible, _ := orchestrator.IsEligible(item, baseCfg(), baseState(), 10) - if !eligible { - t.Error("should be eligible: all blockers closed") - } -} - -// --- Parent/sub-issue tests --- - -func TestIsEligible_ParentWithOpenSubIssues(t *testing.T) { - item := baseItem("github:p1:parent") - item.SubIssues = []orchestrator.ChildRef{ - {Identifier: "org/repo#20", State: "open"}, - {Identifier: "org/repo#21", State: "closed"}, - } - - eligible, reason := orchestrator.IsEligible(item, baseCfg(), baseState(), 10) - if eligible { - t.Error("parent with open sub-issues should be ineligible") - } - if reason == "" { - t.Error("expected reason mentioning sub-issues") - } -} - -func TestIsEligible_ParentWithAllClosedSubIssues(t *testing.T) { - item := baseItem("github:p1:parent") - item.SubIssues = []orchestrator.ChildRef{ - {Identifier: "org/repo#20", State: "closed"}, - {Identifier: "org/repo#21", State: "closed"}, - } - - eligible, _ := orchestrator.IsEligible(item, baseCfg(), baseState(), 10) - if !eligible { - t.Error("parent with all sub-issues closed should be eligible") - } -} - -func TestIsEligible_ParentWithNoSubIssues(t *testing.T) { - item := baseItem("github:p1:parent") - // No SubIssues — normal issue dispatch - - eligible, _ := orchestrator.IsEligible(item, baseCfg(), baseState(), 10) - if !eligible { - t.Error("item with no sub-issues should be eligible for normal dispatch") - } -} - -func TestIsEligible_SubIssueBlockedByExternal(t *testing.T) { - // A sub-issue that itself has a blocking dependency - item := baseItem("github:p1:sub") - item.BlockedBy = []orchestrator.BlockerRef{ - {Identifier: "org/other-repo#99", State: "open"}, - } - // No SubIssues of its own - - eligible, _ := orchestrator.IsEligible(item, baseCfg(), baseState(), 10) - if eligible { - t.Error("sub-issue blocked by external issue should be ineligible") - } -} - -func TestIsEligible_ParentBlockedAndHasSubIssues(t *testing.T) { - // Parent is blocked AND has open sub-issues — blocked takes precedence - item := baseItem("github:p1:parent") - item.BlockedBy = []orchestrator.BlockerRef{ - {Identifier: "org/repo#5", State: "open"}, - } - item.SubIssues = []orchestrator.ChildRef{ - {Identifier: "org/repo#20", State: "open"}, - } - - eligible, reason := orchestrator.IsEligible(item, baseCfg(), baseState(), 10) - if eligible { - t.Error("should be ineligible") - } - // Blocker check comes before sub-issue check, so reason should mention blocker - if reason == "" { - t.Error("expected reason") - } -} diff --git a/internal/orchestrator/eligibility_test.go b/internal/orchestrator/eligibility_test.go deleted file mode 100644 index b86e123..0000000 --- a/internal/orchestrator/eligibility_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package orchestrator_test - -import ( - "testing" - - "github.com/shivamstaq/github-symphony/internal/orchestrator" -) - -func TestIsEligible_BasicActive(t *testing.T) { - item := orchestrator.WorkItem{ - WorkItemID: "github:item1:issue1", - ProjectItemID: "item1", - ContentType: "issue", - Title: "Fix bug", - State: "open", - ProjectStatus: "Todo", - IssueNumber: intPtr(1), - Repository: &orchestrator.Repository{Owner: "org", Name: "repo", FullName: "org/repo"}, - } - - cfg := orchestrator.EligibilityConfig{ - ActiveValues: []string{"Todo", "Ready", "In Progress"}, - TerminalValues: []string{"Done", "Closed"}, - ExecutableItemTypes: []string{"issue"}, - RequireIssueBacking: true, - } - - state := &orchestrator.State{ - Running: make(map[string]*orchestrator.RunningEntry), - Claimed: make(map[string]bool), - } - - eligible, reason := orchestrator.IsEligible(item, cfg, state, 10) - if !eligible { - t.Fatalf("expected eligible, got ineligible: %s", reason) - } -} - -func TestIsEligible_TerminalStatus(t *testing.T) { - item := orchestrator.WorkItem{ - WorkItemID: "github:item1:issue1", - ProjectItemID: "item1", - ContentType: "issue", - Title: "Done task", - State: "open", - ProjectStatus: "Done", - IssueNumber: intPtr(1), - Repository: &orchestrator.Repository{Owner: "org", Name: "repo", FullName: "org/repo"}, - } - - cfg := orchestrator.EligibilityConfig{ - ActiveValues: []string{"Todo"}, - TerminalValues: []string{"Done"}, - ExecutableItemTypes: []string{"issue"}, - } - state := &orchestrator.State{Running: make(map[string]*orchestrator.RunningEntry), Claimed: make(map[string]bool)} - - eligible, _ := orchestrator.IsEligible(item, cfg, state, 10) - if eligible { - t.Error("terminal status should be ineligible") - } -} - -func TestIsEligible_ClosedIssue(t *testing.T) { - item := orchestrator.WorkItem{ - WorkItemID: "github:item1:issue1", - ProjectItemID: "item1", - ContentType: "issue", - Title: "Closed", - State: "closed", - ProjectStatus: "Todo", - IssueNumber: intPtr(1), - Repository: &orchestrator.Repository{Owner: "org", Name: "repo", FullName: "org/repo"}, - } - - cfg := orchestrator.EligibilityConfig{ - ActiveValues: []string{"Todo"}, - ExecutableItemTypes: []string{"issue"}, - RequireIssueBacking: true, - } - state := &orchestrator.State{Running: make(map[string]*orchestrator.RunningEntry), Claimed: make(map[string]bool)} - - eligible, _ := orchestrator.IsEligible(item, cfg, state, 10) - if eligible { - t.Error("closed issue should be ineligible") - } -} - -func TestIsEligible_AlreadyClaimed(t *testing.T) { - item := orchestrator.WorkItem{ - WorkItemID: "github:item1:issue1", - ProjectItemID: "item1", - ContentType: "issue", - Title: "Claimed", - State: "open", - ProjectStatus: "Todo", - IssueNumber: intPtr(1), - Repository: &orchestrator.Repository{Owner: "org", Name: "repo", FullName: "org/repo"}, - } - - cfg := orchestrator.EligibilityConfig{ - ActiveValues: []string{"Todo"}, - ExecutableItemTypes: []string{"issue"}, - } - state := &orchestrator.State{ - Running: make(map[string]*orchestrator.RunningEntry), - Claimed: map[string]bool{"github:item1:issue1": true}, - } - - eligible, _ := orchestrator.IsEligible(item, cfg, state, 10) - if eligible { - t.Error("already claimed should be ineligible") - } -} - -func TestIsEligible_NoSlots(t *testing.T) { - item := orchestrator.WorkItem{ - WorkItemID: "github:item1:issue1", - ProjectItemID: "item1", - ContentType: "issue", - Title: "No slots", - State: "open", - ProjectStatus: "Todo", - IssueNumber: intPtr(1), - Repository: &orchestrator.Repository{Owner: "org", Name: "repo", FullName: "org/repo"}, - } - - cfg := orchestrator.EligibilityConfig{ - ActiveValues: []string{"Todo"}, - ExecutableItemTypes: []string{"issue"}, - } - state := &orchestrator.State{ - Running: map[string]*orchestrator.RunningEntry{ - "other": {}, - }, - Claimed: make(map[string]bool), - } - - eligible, _ := orchestrator.IsEligible(item, cfg, state, 1) // max=1, 1 running - if eligible { - t.Error("no available slots should be ineligible") - } -} - -func TestIsEligible_BlockedByDependency(t *testing.T) { - item := orchestrator.WorkItem{ - WorkItemID: "github:item1:issue1", - ProjectItemID: "item1", - ContentType: "issue", - Title: "Blocked", - State: "open", - ProjectStatus: "Todo", - IssueNumber: intPtr(1), - Repository: &orchestrator.Repository{Owner: "org", Name: "repo", FullName: "org/repo"}, - BlockedBy: []orchestrator.BlockerRef{ - {Identifier: "org/repo#2", State: "open"}, - }, - } - - cfg := orchestrator.EligibilityConfig{ - ActiveValues: []string{"Todo"}, - ExecutableItemTypes: []string{"issue"}, - } - state := &orchestrator.State{Running: make(map[string]*orchestrator.RunningEntry), Claimed: make(map[string]bool)} - - eligible, _ := orchestrator.IsEligible(item, cfg, state, 10) - if eligible { - t.Error("blocked issue should be ineligible") - } -} - -func TestSortForDispatch(t *testing.T) { - items := []orchestrator.WorkItem{ - {WorkItemID: "c", Priority: intPtr(3), CreatedAt: "2024-01-03"}, - {WorkItemID: "a", Priority: intPtr(1), CreatedAt: "2024-01-01"}, - {WorkItemID: "b", Priority: intPtr(1), CreatedAt: "2024-01-02"}, - } - - orchestrator.SortForDispatch(items) - - if items[0].WorkItemID != "a" { - t.Errorf("expected first=a (priority 1, oldest), got %s", items[0].WorkItemID) - } - if items[1].WorkItemID != "b" { - t.Errorf("expected second=b (priority 1, newer), got %s", items[1].WorkItemID) - } - if items[2].WorkItemID != "c" { - t.Errorf("expected third=c (priority 3), got %s", items[2].WorkItemID) - } -} - -func intPtr(i int) *int { return &i } diff --git a/internal/orchestrator/events.go b/internal/orchestrator/events.go deleted file mode 100644 index 7fd7318..0000000 --- a/internal/orchestrator/events.go +++ /dev/null @@ -1,93 +0,0 @@ -package orchestrator - -import ( - "sync" - "time" -) - -// Event represents a significant orchestrator event for the TUI/logging. -type Event struct { - Time time.Time - WorkItemID string - Issue string // human-readable issue identifier - Kind EventKind - Message string - Meta map[string]any -} - -// EventKind identifies the type of orchestrator event. -type EventKind string - -const ( - EventDispatched EventKind = "dispatched" - EventTurnStarted EventKind = "turn_started" - EventTurnCompleted EventKind = "turn_completed" - EventPRCreated EventKind = "pr_created" - EventHandoff EventKind = "handoff" - EventBlocked EventKind = "blocked" - EventRetryScheduled EventKind = "retry_scheduled" - EventRetryFired EventKind = "retry_fired" - EventReconciled EventKind = "reconciled" - EventWorkspaceCreated EventKind = "workspace_created" - EventError EventKind = "error" - EventShutdown EventKind = "shutdown" -) - -// EventBus distributes events to subscribers. -type EventBus struct { - mu sync.RWMutex - subscribers []chan Event - recent []Event - maxRecent int -} - -// NewEventBus creates an event bus with a ring buffer of recent events. -func NewEventBus(maxRecent int) *EventBus { - if maxRecent <= 0 { - maxRecent = 100 - } - return &EventBus{maxRecent: maxRecent} -} - -// Subscribe returns a channel that receives events. -func (eb *EventBus) Subscribe() chan Event { - ch := make(chan Event, 50) - eb.mu.Lock() - eb.subscribers = append(eb.subscribers, ch) - eb.mu.Unlock() - return ch -} - -// Emit sends an event to all subscribers and stores in recent buffer. -func (eb *EventBus) Emit(e Event) { - if e.Time.IsZero() { - e.Time = time.Now() - } - - eb.mu.Lock() - // Ring buffer - eb.recent = append(eb.recent, e) - if len(eb.recent) > eb.maxRecent { - eb.recent = eb.recent[len(eb.recent)-eb.maxRecent:] - } - subs := make([]chan Event, len(eb.subscribers)) - copy(subs, eb.subscribers) - eb.mu.Unlock() - - for _, ch := range subs { - select { - case ch <- e: - default: - // subscriber too slow, drop event - } - } -} - -// Recent returns the most recent events. -func (eb *EventBus) Recent() []Event { - eb.mu.RLock() - defer eb.mu.RUnlock() - result := make([]Event, len(eb.recent)) - copy(result, eb.recent) - return result -} diff --git a/internal/orchestrator/handoff.go b/internal/orchestrator/handoff.go deleted file mode 100644 index cb51d33..0000000 --- a/internal/orchestrator/handoff.go +++ /dev/null @@ -1,78 +0,0 @@ -package orchestrator - -import "strings" - -// HandoffInput contains the inputs needed to evaluate whether a handoff condition is met. -type HandoffInput struct { - HasPR bool - CurrentProjectStatus string - HandoffProjectStatus string // from pull_request.handoff_project_status config - RequiredChecks []string - PassedChecks []string -} - -// HandoffResult is the outcome of handoff evaluation. -type HandoffResult struct { - IsHandoff bool - MissingChecks []string -} - -// EvaluateHandoff determines whether a deterministic handoff condition is met. -// -// Rules (from spec Section 7.5): -// - PR alone is NOT sufficient. -// - If handoff_project_status is not configured, handoff never triggers. -// - Default: PR exists AND project status == configured handoff value. -// - Strong: PR + status + all required checks passed. -func EvaluateHandoff(input HandoffInput) HandoffResult { - // If no handoff status configured, handoff never triggers - if input.HandoffProjectStatus == "" { - return HandoffResult{IsHandoff: false} - } - - // Must have a PR - if !input.HasPR { - return HandoffResult{IsHandoff: false} - } - - // Status must match handoff value (case-insensitive) - if !strings.EqualFold(input.CurrentProjectStatus, input.HandoffProjectStatus) { - return HandoffResult{IsHandoff: false} - } - - // If required checks are configured, all must pass - if len(input.RequiredChecks) > 0 { - passedSet := make(map[string]bool, len(input.PassedChecks)) - for _, c := range input.PassedChecks { - passedSet[strings.ToLower(c)] = true - } - - var missing []string - for _, req := range input.RequiredChecks { - if !passedSet[strings.ToLower(req)] { - missing = append(missing, req) - } - } - if len(missing) > 0 { - return HandoffResult{IsHandoff: false, MissingChecks: missing} - } - } - - return HandoffResult{IsHandoff: true} -} - -// RetryBackoffMs computes the delay for a failure-driven retry. -// Formula: min(10000 * 2^(attempt-1), maxMs) -func RetryBackoffMs(attempt, maxMs int) int { - delay := 10000 - for i := 1; i < attempt; i++ { - delay *= 2 - if delay > maxMs { - return maxMs - } - } - if delay > maxMs { - return maxMs - } - return delay -} diff --git a/internal/orchestrator/handoff_test.go b/internal/orchestrator/handoff_test.go deleted file mode 100644 index 43d5780..0000000 --- a/internal/orchestrator/handoff_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package orchestrator_test - -import ( - "testing" - - "github.com/shivamstaq/github-symphony/internal/orchestrator" -) - -func TestIsHandoff_PRAndStatusTransition(t *testing.T) { - result := orchestrator.EvaluateHandoff(orchestrator.HandoffInput{ - HasPR: true, - CurrentProjectStatus: "Human Review", - HandoffProjectStatus: "Human Review", - }) - if !result.IsHandoff { - t.Error("expected handoff when PR exists and status matches handoff value") - } -} - -func TestIsHandoff_PROnly_NotSufficient(t *testing.T) { - result := orchestrator.EvaluateHandoff(orchestrator.HandoffInput{ - HasPR: true, - CurrentProjectStatus: "In Progress", - HandoffProjectStatus: "Human Review", - }) - if result.IsHandoff { - t.Error("PR alone should not be sufficient for handoff") - } -} - -func TestIsHandoff_StatusOnly_NotSufficient(t *testing.T) { - result := orchestrator.EvaluateHandoff(orchestrator.HandoffInput{ - HasPR: false, - CurrentProjectStatus: "Human Review", - HandoffProjectStatus: "Human Review", - }) - if result.IsHandoff { - t.Error("status alone without PR should not be sufficient for handoff") - } -} - -func TestIsHandoff_NoHandoffConfigured(t *testing.T) { - result := orchestrator.EvaluateHandoff(orchestrator.HandoffInput{ - HasPR: true, - CurrentProjectStatus: "In Progress", - HandoffProjectStatus: "", // not configured - }) - if result.IsHandoff { - t.Error("handoff should never trigger when handoff_project_status is not configured") - } -} - -func TestIsHandoff_WithRequiredChecks_AllPass(t *testing.T) { - result := orchestrator.EvaluateHandoff(orchestrator.HandoffInput{ - HasPR: true, - CurrentProjectStatus: "Human Review", - HandoffProjectStatus: "Human Review", - RequiredChecks: []string{"lint", "test"}, - PassedChecks: []string{"lint", "test"}, - }) - if !result.IsHandoff { - t.Error("expected handoff when PR + status + all required checks pass") - } -} - -func TestIsHandoff_WithRequiredChecks_Missing(t *testing.T) { - result := orchestrator.EvaluateHandoff(orchestrator.HandoffInput{ - HasPR: true, - CurrentProjectStatus: "Human Review", - HandoffProjectStatus: "Human Review", - RequiredChecks: []string{"lint", "test"}, - PassedChecks: []string{"lint"}, // missing "test" - }) - if result.IsHandoff { - t.Error("handoff should not trigger when required checks are missing") - } -} - -func TestRetryBackoff(t *testing.T) { - tests := []struct { - attempt int - maxMs int - want int - }{ - {1, 300000, 10000}, - {2, 300000, 20000}, - {3, 300000, 40000}, - {4, 300000, 80000}, - {5, 300000, 160000}, - {6, 300000, 300000}, // capped - {10, 300000, 300000}, - } - - for _, tt := range tests { - got := orchestrator.RetryBackoffMs(tt.attempt, tt.maxMs) - if got != tt.want { - t.Errorf("RetryBackoffMs(%d, %d) = %d, want %d", tt.attempt, tt.maxMs, got, tt.want) - } - } -} diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go deleted file mode 100644 index f8b5f26..0000000 --- a/internal/orchestrator/orchestrator.go +++ /dev/null @@ -1,567 +0,0 @@ -package orchestrator - -import ( - "context" - "fmt" - "log/slog" - "sync" - "time" -) - -// WorkItemSource fetches work items from GitHub. -type WorkItemSource interface { - FetchCandidates(ctx context.Context) ([]WorkItem, error) - FetchStates(ctx context.Context, workItemIDs []string) ([]WorkItem, error) -} - -// WorkerRunner executes a work item and returns the outcome. -type WorkerRunner interface { - Run(ctx context.Context, item WorkItem, attempt *int) WorkerResult -} - -// OrchestratorConfig holds configuration for the orchestrator. -type OrchestratorConfig struct { - PollIntervalMs int - MaxConcurrentAgents int - StallTimeoutMs int - MaxRetryBackoffMs int - Eligibility EligibilityConfig - ActiveValues []string - TerminalValues []string - MaxContinuationRetries int // hard limit on continuation retries per item (default: 10) -} - -// Orchestrator owns the poll loop and all mutable scheduling state. -type Orchestrator struct { - cfg OrchestratorConfig - source WorkItemSource - runner WorkerRunner - state *State - results chan WorkerResult - Events *EventBus - mu sync.RWMutex -} - -// New creates an Orchestrator. -func New(cfg OrchestratorConfig, source WorkItemSource, runner WorkerRunner) *Orchestrator { - if cfg.MaxRetryBackoffMs <= 0 { - cfg.MaxRetryBackoffMs = 300000 - } - return &Orchestrator{ - cfg: cfg, - source: source, - runner: runner, - Events: NewEventBus(200), - state: &State{ - PollIntervalMs: cfg.PollIntervalMs, - MaxConcurrentAgents: cfg.MaxConcurrentAgents, - Running: make(map[string]*RunningEntry), - Claimed: make(map[string]bool), - RetryAttempts: make(map[string]*RetryEntry), - Completed: make(map[string]bool), - HandedOff: make(map[string]bool), - }, - results: make(chan WorkerResult, 100), - } -} - -// Run starts the poll loop and blocks until ctx is cancelled. -func (o *Orchestrator) Run(ctx context.Context) { - slog.Info("orchestrator starting poll loop", "interval_ms", o.cfg.PollIntervalMs) - - // Immediate first tick - o.RunOnce(ctx) - - ticker := time.NewTicker(time.Duration(o.cfg.PollIntervalMs) * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - slog.Info("orchestrator shutting down") - return - case result := <-o.results: - o.handleWorkerResult(result) - case <-ticker.C: - o.RunOnce(ctx) - } - } -} - -// Shutdown gracefully stops all running workers. -func (o *Orchestrator) Shutdown(ctx context.Context) { - o.mu.Lock() - running := make(map[string]*RunningEntry, len(o.state.Running)) - for k, v := range o.state.Running { - running[k] = v - } - o.mu.Unlock() - - slog.Info("shutting down orchestrator", "running_count", len(running)) - - // Cancel all running workers - for id, entry := range running { - if entry.CancelFunc != nil { - slog.Info("cancelling worker", "work_item_id", id) - entry.CancelFunc() - } - } - - // Wait for workers to drain (with timeout from context) - deadline, hasDeadline := ctx.Deadline() - for { - o.mu.RLock() - remaining := len(o.state.Running) - o.mu.RUnlock() - - if remaining == 0 { - break - } - - if hasDeadline && time.Now().After(deadline) { - slog.Warn("shutdown grace period expired", "remaining_workers", remaining) - break - } - - // Drain results - select { - case result := <-o.results: - o.handleWorkerResult(result) - case <-time.After(100 * time.Millisecond): - } - } - - o.ProcessResults() -} - -// SetPendingRefresh marks the orchestrator for an extra reconciliation pass. -func (o *Orchestrator) SetPendingRefresh() { - o.mu.Lock() - o.state.PendingRefresh = true - o.mu.Unlock() -} - -// RestoreRetry adds a retry entry (e.g., from bbolt on startup). -func (o *Orchestrator) RestoreRetry(entry RetryEntry) { - o.mu.Lock() - defer o.mu.Unlock() - o.state.RetryAttempts[entry.WorkItemID] = &entry - o.state.Claimed[entry.WorkItemID] = true -} - -// RestoreHandoff marks an item as handed off (from bbolt on startup). -func (o *Orchestrator) RestoreHandoff(workItemID string) { - o.mu.Lock() - defer o.mu.Unlock() - if o.state.HandedOff == nil { - o.state.HandedOff = make(map[string]bool) - } - o.state.HandedOff[workItemID] = true -} - -// RunOnce executes one poll-and-dispatch tick per spec Section 8.1: -// 1. Reconcile running work items -// 2. Fire due retries -// 3. Validate config (caller responsibility) -// 4. Fetch candidates -// 5. Sort and dispatch -func (o *Orchestrator) RunOnce(ctx context.Context) { - now := time.Now() - o.mu.Lock() - o.state.LastPollAt = &now - o.mu.Unlock() - - // 1. Process pending results - o.ProcessResults() - - // 2. Reconcile: stall detection - o.reconcileStalled() - - // 3. Reconcile: GitHub state refresh - o.reconcileGitHubState(ctx) - - // 4. Fire due retries - o.fireDueRetries(ctx) - - // 5. Fetch candidates - items, err := o.source.FetchCandidates(ctx) - if err != nil { - slog.Error("candidate fetch failed", "error", err) - o.mu.Lock() - o.state.ErrorTotal++ - o.mu.Unlock() - return - } - - // 6. Sort for dispatch - SortForDispatch(items) - - // 7. Dispatch eligible items - for _, item := range items { - o.mu.RLock() - slots := o.cfg.MaxConcurrentAgents - len(o.state.Running) - o.mu.RUnlock() - - if slots <= 0 { - break - } - - o.mu.RLock() - eligible, reason := IsEligible(item, o.cfg.Eligibility, o.state, o.cfg.MaxConcurrentAgents) - o.mu.RUnlock() - - if !eligible { - slog.Debug("item not eligible", "work_item_id", item.WorkItemID, "reason", reason) - continue - } - - o.dispatch(ctx, item, nil) - } -} - -func (o *Orchestrator) reconcileStalled() { - o.mu.RLock() - stalled := DetectStalled(o.state, o.cfg.StallTimeoutMs) - o.mu.RUnlock() - - for _, id := range stalled { - slog.Warn("stalled worker detected, cancelling", "work_item_id", id) - o.mu.Lock() - entry, exists := o.state.Running[id] - if exists && entry.CancelFunc != nil { - entry.CancelFunc() - entry.Phase = PhaseStalled - } - o.mu.Unlock() - } -} - -func (o *Orchestrator) reconcileGitHubState(ctx context.Context) { - o.mu.RLock() - var runningIDs []string - for id := range o.state.Running { - runningIDs = append(runningIDs, id) - } - pendingRefresh := o.state.PendingRefresh - o.mu.RUnlock() - - if len(runningIDs) == 0 && !pendingRefresh { - return - } - - if len(runningIDs) > 0 { - refreshed, err := o.source.FetchStates(ctx, runningIDs) - if err != nil { - slog.Debug("reconciliation refresh failed, keeping workers", "error", err) - } else { - refreshMap := make(map[string]WorkItem, len(refreshed)) - for _, item := range refreshed { - refreshMap[item.WorkItemID] = item - } - - o.mu.Lock() - for _, id := range runningIDs { - item, found := refreshMap[id] - if !found { - continue - } - - action := ClassifyRefreshed(item, o.cfg.ActiveValues, o.cfg.TerminalValues) - switch action { - case ActionTerminate: - slog.Info("terminating worker (terminal state)", "work_item_id", id) - if entry, ok := o.state.Running[id]; ok && entry.CancelFunc != nil { - entry.CancelFunc() - entry.Phase = PhaseCanceled - } - case ActionStop: - slog.Info("stopping worker (non-active state)", "work_item_id", id) - if entry, ok := o.state.Running[id]; ok && entry.CancelFunc != nil { - entry.CancelFunc() - entry.Phase = PhaseCanceled - } - case ActionKeep: - if entry, ok := o.state.Running[id]; ok { - entry.WorkItem = item - } - } - } - o.mu.Unlock() - } - } - - if pendingRefresh { - o.mu.Lock() - o.state.PendingRefresh = false - o.mu.Unlock() - } -} - -func (o *Orchestrator) fireDueRetries(ctx context.Context) { - now := time.Now() - - o.mu.Lock() - var dueEntries []RetryEntry - for _, entry := range o.state.RetryAttempts { - if now.After(entry.DueAt) || now.Equal(entry.DueAt) { - dueEntries = append(dueEntries, *entry) - } - } - o.mu.Unlock() - - if len(dueEntries) == 0 { - return - } - - // Fetch current candidates to check eligibility - candidates, err := o.source.FetchCandidates(ctx) - if err != nil { - slog.Warn("retry fetch failed", "error", err) - return - } - - candidateMap := make(map[string]WorkItem, len(candidates)) - for _, c := range candidates { - candidateMap[c.WorkItemID] = c - } - - for _, entry := range dueEntries { - item, found := candidateMap[entry.WorkItemID] - if !found { - // Item no longer in candidates — release claim - slog.Info("retry: item no longer candidate, releasing", "work_item_id", entry.WorkItemID) - o.mu.Lock() - delete(o.state.RetryAttempts, entry.WorkItemID) - delete(o.state.Claimed, entry.WorkItemID) - o.mu.Unlock() - continue - } - - o.mu.RLock() - eligible, reason := IsEligible(item, o.cfg.Eligibility, o.state, o.cfg.MaxConcurrentAgents) - o.mu.RUnlock() - - if !eligible { - if reason == "no available slots" { - // Requeue with slot error - slog.Info("retry: no slots, requeuing", "work_item_id", entry.WorkItemID) - o.mu.Lock() - o.state.RetryAttempts[entry.WorkItemID].DueAt = time.Now().Add(5 * time.Second) - o.state.RetryAttempts[entry.WorkItemID].Error = "no available orchestrator slots" - o.mu.Unlock() - } else { - // No longer eligible — release - slog.Info("retry: item no longer eligible, releasing", "work_item_id", entry.WorkItemID, "reason", reason) - o.mu.Lock() - delete(o.state.RetryAttempts, entry.WorkItemID) - delete(o.state.Claimed, entry.WorkItemID) - o.mu.Unlock() - } - continue - } - - // Dispatch the retry - attempt := entry.Attempt - o.dispatch(ctx, item, &attempt) - } -} - -func (o *Orchestrator) dispatch(ctx context.Context, item WorkItem, attempt *int) { - // Create a cancellable context for this worker - workerCtx, cancel := context.WithCancel(ctx) - - o.mu.Lock() - o.state.Running[item.WorkItemID] = &RunningEntry{ - WorkItem: item, - CancelFunc: cancel, - IssueIdentifier: item.IssueIdentifier, - Repository: repoFullName(item.Repository), - RetryAttempt: attempt, - Phase: PhaseLaunchingAgent, - StartedAt: time.Now(), - } - o.state.Claimed[item.WorkItemID] = true - delete(o.state.RetryAttempts, item.WorkItemID) - o.state.AgentTotals.SessionsStarted++ - o.state.DispatchTotal++ - o.mu.Unlock() - - slog.Info("dispatching work item", - "work_item_id", item.WorkItemID, - "issue", item.IssueIdentifier, - "repository", repoFullName(item.Repository), - "attempt", attempt, - ) - - o.Events.Emit(Event{ - WorkItemID: item.WorkItemID, - Issue: item.IssueIdentifier, - Kind: EventDispatched, - Message: fmt.Sprintf("Dispatched to %s", repoFullName(item.Repository)), - }) - - go func() { - result := o.runner.Run(workerCtx, item, attempt) - o.results <- result - }() -} - -// ProcessResults drains completed worker results and updates state. -func (o *Orchestrator) ProcessResults() { - for { - select { - case result := <-o.results: - o.handleWorkerResult(result) - default: - return - } - } -} - -func (o *Orchestrator) handleWorkerResult(result WorkerResult) { - o.mu.Lock() - defer o.mu.Unlock() - - entry, exists := o.state.Running[result.WorkItemID] - if exists { - elapsed := time.Since(entry.StartedAt).Seconds() - o.state.AgentTotals.SecondsRunning += elapsed - o.state.AgentTotals.InputTokens += int64(entry.InputTokens) - o.state.AgentTotals.OutputTokens += int64(entry.OutputTokens) - o.state.AgentTotals.TotalTokens += int64(entry.TotalTokens) - } - - delete(o.state.Running, result.WorkItemID) - - switch result.Outcome { - case OutcomeHandoff: - o.state.Completed[result.WorkItemID] = true - o.state.HandedOff[result.WorkItemID] = true - delete(o.state.Claimed, result.WorkItemID) - o.state.HandoffTotal++ - slog.Info("work item handed off", "work_item_id", result.WorkItemID) - o.Events.Emit(Event{WorkItemID: result.WorkItemID, Issue: issueIDFromEntry(entry), Kind: EventHandoff, Message: "PR created, handed off for review"}) - - case OutcomeNormal: - o.state.Completed[result.WorkItemID] = true - continuationAttempt := 1 - if entry != nil && entry.RetryAttempt != nil { - continuationAttempt = *entry.RetryAttempt + 1 - } - - // Hard limit on continuation retries to prevent infinite loops - maxCont := o.cfg.MaxContinuationRetries - if maxCont <= 0 { - maxCont = 10 // default - } - if continuationAttempt > maxCont { - slog.Warn("max continuation retries exceeded, releasing", - "work_item_id", result.WorkItemID, - "attempt", continuationAttempt, - "max", maxCont, - ) - delete(o.state.Claimed, result.WorkItemID) - o.state.ErrorTotal++ - break - } - - // Increase delay with each continuation to avoid rapid-fire re-invocations - // 1st: 5s, 2nd: 10s, 3rd: 20s, 4th+: 30s - contDelay := time.Duration(min(5000*(1<<(continuationAttempt-1)), 30000)) * time.Millisecond - - o.state.RetryAttempts[result.WorkItemID] = &RetryEntry{ - WorkItemID: result.WorkItemID, - IssueIdentifier: issueIDFromEntry(entry), - Attempt: continuationAttempt, - DueAt: time.Now().Add(contDelay), - } - slog.Info("work item normal exit, scheduling continuation", - "work_item_id", result.WorkItemID, - "attempt", continuationAttempt, - "max", maxCont, - "delay_ms", contDelay.Milliseconds(), - ) - - case OutcomeFailure: - attempt := 1 - if entry != nil && entry.RetryAttempt != nil { - attempt = *entry.RetryAttempt + 1 - } - backoff := RetryBackoffMs(attempt, o.cfg.MaxRetryBackoffMs) - o.state.RetryAttempts[result.WorkItemID] = &RetryEntry{ - WorkItemID: result.WorkItemID, - IssueIdentifier: issueIDFromEntry(entry), - Attempt: attempt, - DueAt: time.Now().Add(time.Duration(backoff) * time.Millisecond), - Error: errString(result.Error), - } - o.state.ErrorTotal++ - slog.Warn("work item failed, scheduling retry", - "work_item_id", result.WorkItemID, - "attempt", attempt, - "backoff_ms", backoff, - "error", result.Error, - ) - } -} - -// GetState returns a snapshot of the current orchestrator state. -func (o *Orchestrator) GetState() State { - o.mu.RLock() - defer o.mu.RUnlock() - - s := *o.state - running := make(map[string]*RunningEntry, len(o.state.Running)) - for k, v := range o.state.Running { - running[k] = v - } - s.Running = running - - retries := make(map[string]*RetryEntry, len(o.state.RetryAttempts)) - for k, v := range o.state.RetryAttempts { - retries[k] = v - } - s.RetryAttempts = retries - - return s -} - -// GetRetryEntries returns all current retry entries (for persisting to bbolt). -func (o *Orchestrator) GetRetryEntries() []RetryEntry { - o.mu.RLock() - defer o.mu.RUnlock() - entries := make([]RetryEntry, 0, len(o.state.RetryAttempts)) - for _, e := range o.state.RetryAttempts { - entries = append(entries, *e) - } - return entries -} - -// InjectRunning adds a running entry directly (for testing). -func (o *Orchestrator) InjectRunning(id string, entry *RunningEntry) { - o.mu.Lock() - defer o.mu.Unlock() - o.state.Running[id] = entry - o.state.Claimed[id] = true -} - -func repoFullName(r *Repository) string { - if r == nil { - return "" - } - return r.FullName -} - -func issueIDFromEntry(entry *RunningEntry) string { - if entry == nil { - return "" - } - return entry.IssueIdentifier -} - -func errString(err error) string { - if err == nil { - return "" - } - return err.Error() -} diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go deleted file mode 100644 index 0035c72..0000000 --- a/internal/orchestrator/orchestrator_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package orchestrator_test - -import ( - "context" - "sync" - "testing" - "time" - - "github.com/shivamstaq/github-symphony/internal/orchestrator" -) - -// mockSource implements orchestrator.WorkItemSource for testing. -type mockSource struct { - mu sync.Mutex - items []orchestrator.WorkItem -} - -func (m *mockSource) FetchCandidates(_ context.Context) ([]orchestrator.WorkItem, error) { - m.mu.Lock() - defer m.mu.Unlock() - return m.items, nil -} - -func (m *mockSource) FetchStates(_ context.Context, _ []string) ([]orchestrator.WorkItem, error) { - m.mu.Lock() - defer m.mu.Unlock() - return m.items, nil -} - -// mockRunner implements orchestrator.WorkerRunner for testing. -type mockRunner struct { - mu sync.Mutex - launched []string -} - -func (m *mockRunner) Run(_ context.Context, item orchestrator.WorkItem, _ *int) orchestrator.WorkerResult { - m.mu.Lock() - m.launched = append(m.launched, item.WorkItemID) - m.mu.Unlock() - - // Simulate agent doing work - time.Sleep(50 * time.Millisecond) - - return orchestrator.WorkerResult{ - WorkItemID: item.WorkItemID, - Outcome: orchestrator.OutcomeNormal, - } -} - -func TestOrchestrator_DispatchesSingleItem(t *testing.T) { - source := &mockSource{ - items: []orchestrator.WorkItem{ - { - WorkItemID: "github:item1:issue1", - ProjectItemID: "item1", - ContentType: "issue", - Title: "Test issue", - State: "open", - ProjectStatus: "Todo", - IssueNumber: intPtr(1), - Repository: &orchestrator.Repository{Owner: "org", Name: "repo", FullName: "org/repo"}, - }, - }, - } - - runner := &mockRunner{} - - orch := orchestrator.New(orchestrator.OrchestratorConfig{ - PollIntervalMs: 100, - MaxConcurrentAgents: 5, - Eligibility: orchestrator.EligibilityConfig{ - ActiveValues: []string{"Todo", "In Progress"}, - TerminalValues: []string{"Done"}, - ExecutableItemTypes: []string{"issue"}, - }, - }, source, runner) - - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - - orch.RunOnce(ctx) - - // Wait for worker to complete - time.Sleep(200 * time.Millisecond) - orch.ProcessResults() - - runner.mu.Lock() - defer runner.mu.Unlock() - - if len(runner.launched) != 1 { - t.Fatalf("expected 1 dispatch, got %d", len(runner.launched)) - } - if runner.launched[0] != "github:item1:issue1" { - t.Errorf("wrong item dispatched: %s", runner.launched[0]) - } -} - -func TestOrchestrator_RespectsMaxConcurrency(t *testing.T) { - source := &mockSource{ - items: []orchestrator.WorkItem{ - {WorkItemID: "i1", ProjectItemID: "p1", ContentType: "issue", Title: "A", State: "open", ProjectStatus: "Todo", IssueNumber: intPtr(1), Repository: &orchestrator.Repository{FullName: "o/r"}}, - {WorkItemID: "i2", ProjectItemID: "p2", ContentType: "issue", Title: "B", State: "open", ProjectStatus: "Todo", IssueNumber: intPtr(2), Repository: &orchestrator.Repository{FullName: "o/r"}}, - {WorkItemID: "i3", ProjectItemID: "p3", ContentType: "issue", Title: "C", State: "open", ProjectStatus: "Todo", IssueNumber: intPtr(3), Repository: &orchestrator.Repository{FullName: "o/r"}}, - }, - } - - runner := &mockRunner{} - - orch := orchestrator.New(orchestrator.OrchestratorConfig{ - PollIntervalMs: 100, - MaxConcurrentAgents: 2, // Only 2 slots - Eligibility: orchestrator.EligibilityConfig{ - ActiveValues: []string{"Todo"}, - ExecutableItemTypes: []string{"issue"}, - }, - }, source, runner) - - ctx := context.Background() - orch.RunOnce(ctx) - - // Check that only 2 were dispatched (max concurrency) - state := orch.GetState() - if len(state.Running) > 2 { - t.Errorf("expected at most 2 running, got %d", len(state.Running)) - } -} diff --git a/internal/orchestrator/reconcile.go b/internal/orchestrator/reconcile.go deleted file mode 100644 index b99f589..0000000 --- a/internal/orchestrator/reconcile.go +++ /dev/null @@ -1,77 +0,0 @@ -package orchestrator - -import ( - "strings" - "time" -) - -// ReconcileAction indicates what should happen to a running work item after refresh. -type ReconcileAction int - -const ( - ActionKeep ReconcileAction = iota // still active, update snapshot - ActionTerminate // terminal — stop and cleanup workspace - ActionStop // non-active, non-terminal — stop without cleanup -) - -// DetectStalled returns work item IDs that have exceeded the stall timeout. -// If stallTimeoutMs <= 0, stall detection is disabled. -func DetectStalled(state *State, stallTimeoutMs int) []string { - if stallTimeoutMs <= 0 { - return nil - } - - threshold := time.Duration(stallTimeoutMs) * time.Millisecond - now := time.Now() - var stalled []string - - for id, entry := range state.Running { - var lastActivity time.Time - if entry.LastAgentTimestamp != nil { - lastActivity = *entry.LastAgentTimestamp - } else { - lastActivity = entry.StartedAt - } - - if now.Sub(lastActivity) > threshold { - stalled = append(stalled, id) - } - } - - return stalled -} - -// ClassifyRefreshed determines what action to take on a running work item -// based on its refreshed GitHub state. -func ClassifyRefreshed(item WorkItem, activeValues, terminalValues []string) ReconcileAction { - statusLower := strings.ToLower(item.ProjectStatus) - stateLower := strings.ToLower(item.State) - - // Check terminal conditions - isTerminalStatus := containsLower(terminalValues, statusLower) - isTerminalState := stateLower == "closed" - - if isTerminalStatus || isTerminalState { - return ActionTerminate - } - - // Check active conditions - isActiveStatus := containsLower(activeValues, statusLower) - isOpenState := stateLower == "open" || stateLower == "" - - if isActiveStatus && isOpenState { - return ActionKeep - } - - // Neither active nor terminal - return ActionStop -} - -func containsLower(list []string, val string) bool { - for _, v := range list { - if strings.ToLower(v) == val { - return true - } - } - return false -} diff --git a/internal/orchestrator/reconcile_test.go b/internal/orchestrator/reconcile_test.go deleted file mode 100644 index cb7ecb7..0000000 --- a/internal/orchestrator/reconcile_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package orchestrator_test - -import ( - "testing" - "time" - - "github.com/shivamstaq/github-symphony/internal/orchestrator" -) - -func TestReconcileStalled_KillsStalled(t *testing.T) { - now := time.Now() - state := &orchestrator.State{ - Running: map[string]*orchestrator.RunningEntry{ - "stalled": { - WorkItem: orchestrator.WorkItem{WorkItemID: "stalled"}, - StartedAt: now.Add(-10 * time.Minute), // started 10 min ago - // No LastAgentTimestamp — never heard from agent - }, - "fresh": { - WorkItem: orchestrator.WorkItem{WorkItemID: "fresh"}, - StartedAt: now.Add(-1 * time.Minute), - LastAgentTimestamp: timePtr(now.Add(-30 * time.Second)), // heard 30s ago - }, - }, - Claimed: map[string]bool{"stalled": true, "fresh": true}, - RetryAttempts: make(map[string]*orchestrator.RetryEntry), - } - - stalled := orchestrator.DetectStalled(state, 300000) // 5 min stall timeout - - if len(stalled) != 1 { - t.Fatalf("expected 1 stalled, got %d", len(stalled)) - } - if stalled[0] != "stalled" { - t.Errorf("expected 'stalled', got %q", stalled[0]) - } -} - -func TestReconcileStalled_DisabledWhenZero(t *testing.T) { - state := &orchestrator.State{ - Running: map[string]*orchestrator.RunningEntry{ - "old": { - WorkItem: orchestrator.WorkItem{WorkItemID: "old"}, - StartedAt: time.Now().Add(-1 * time.Hour), - }, - }, - } - - stalled := orchestrator.DetectStalled(state, 0) // disabled - if len(stalled) != 0 { - t.Errorf("stall detection disabled but got %d stalled", len(stalled)) - } -} - -func TestClassifyRefreshedItem_Terminal(t *testing.T) { - item := orchestrator.WorkItem{ - WorkItemID: "i1", - ProjectStatus: "Done", - State: "closed", - } - - action := orchestrator.ClassifyRefreshed(item, []string{"Todo", "In Progress"}, []string{"Done", "Closed"}) - if action != orchestrator.ActionTerminate { - t.Errorf("expected ActionTerminate, got %v", action) - } -} - -func TestClassifyRefreshedItem_StillActive(t *testing.T) { - item := orchestrator.WorkItem{ - WorkItemID: "i1", - ProjectStatus: "In Progress", - State: "open", - } - - action := orchestrator.ClassifyRefreshed(item, []string{"Todo", "In Progress"}, []string{"Done"}) - if action != orchestrator.ActionKeep { - t.Errorf("expected ActionKeep, got %v", action) - } -} - -func TestClassifyRefreshedItem_NeitherActiveNorTerminal(t *testing.T) { - item := orchestrator.WorkItem{ - WorkItemID: "i1", - ProjectStatus: "Blocked", - State: "open", - } - - action := orchestrator.ClassifyRefreshed(item, []string{"Todo", "In Progress"}, []string{"Done"}) - if action != orchestrator.ActionStop { - t.Errorf("expected ActionStop (non-active, non-terminal), got %v", action) - } -} - -func timePtr(t time.Time) *time.Time { return &t } diff --git a/internal/orchestrator/retry_fire_test.go b/internal/orchestrator/retry_fire_test.go deleted file mode 100644 index 213afae..0000000 --- a/internal/orchestrator/retry_fire_test.go +++ /dev/null @@ -1,197 +0,0 @@ -package orchestrator_test - -import ( - "context" - "sync" - "testing" - "time" - - "github.com/shivamstaq/github-symphony/internal/orchestrator" -) - -// retrySource tracks calls and returns configurable items. -type retrySource struct { - mu sync.Mutex - items []orchestrator.WorkItem - calls int -} - -func (s *retrySource) FetchCandidates(_ context.Context) ([]orchestrator.WorkItem, error) { - s.mu.Lock() - defer s.mu.Unlock() - s.calls++ - return s.items, nil -} - -func (s *retrySource) FetchStates(_ context.Context, _ []string) ([]orchestrator.WorkItem, error) { - s.mu.Lock() - defer s.mu.Unlock() - return s.items, nil -} - -func TestOrchestrator_RetryTimerFires(t *testing.T) { - num := 1 - item := orchestrator.WorkItem{ - WorkItemID: "github:p1:i1", ProjectItemID: "p1", - ContentType: "issue", Title: "Retry test", State: "open", - ProjectStatus: "Todo", IssueNumber: &num, - Repository: &orchestrator.Repository{FullName: "org/repo"}, - } - - source := &retrySource{items: []orchestrator.WorkItem{item}} - runner := &mockRunner{} - - orch := orchestrator.New(orchestrator.OrchestratorConfig{ - PollIntervalMs: 50, - MaxConcurrentAgents: 5, - MaxRetryBackoffMs: 300000, - Eligibility: orchestrator.EligibilityConfig{ - ActiveValues: []string{"Todo"}, - ExecutableItemTypes: []string{"issue"}, - }, - ActiveValues: []string{"Todo"}, - TerminalValues: []string{"Done"}, - }, source, runner) - - // Manually inject a due retry entry - orch.RestoreRetry(orchestrator.RetryEntry{ - WorkItemID: "github:p1:i1", - Attempt: 1, - DueAt: time.Now().Add(-1 * time.Second), // already due - }) - - ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) - defer cancel() - orch.Run(ctx) - - // The retry should have fired and dispatched the item - runner.mu.Lock() - launched := len(runner.launched) - runner.mu.Unlock() - - if launched < 1 { - t.Errorf("expected retry to fire and dispatch, got %d launches", launched) - } -} - -func TestOrchestrator_RetryReleasesNonEligible(t *testing.T) { - // Source returns empty — item is gone - source := &retrySource{items: nil} - runner := &noopRunner{} - - orch := orchestrator.New(orchestrator.OrchestratorConfig{ - PollIntervalMs: 50, - MaxConcurrentAgents: 5, - Eligibility: orchestrator.EligibilityConfig{ - ActiveValues: []string{"Todo"}, - ExecutableItemTypes: []string{"issue"}, - }, - }, source, runner) - - orch.RestoreRetry(orchestrator.RetryEntry{ - WorkItemID: "github:p1:gone", - Attempt: 1, - DueAt: time.Now().Add(-1 * time.Second), - }) - - // Run one tick - orch.RunOnce(context.Background()) - - state := orch.GetState() - if _, exists := state.RetryAttempts["github:p1:gone"]; exists { - t.Error("expected retry to be released for missing item") - } - if state.Claimed["github:p1:gone"] { - t.Error("expected claim to be released") - } -} - -func TestOrchestrator_ReconciliationTerminatesWorker(t *testing.T) { - num := 1 - source := &retrySource{ - items: []orchestrator.WorkItem{{ - WorkItemID: "github:p1:i1", ProjectItemID: "p1", - ContentType: "issue", Title: "Closing", State: "closed", - ProjectStatus: "Done", IssueNumber: &num, - Repository: &orchestrator.Repository{FullName: "org/repo"}, - }}, - } - - // Use a slow runner so the item is still "running" when reconciliation happens - slowRunner := &slowMockRunner{delay: 2 * time.Second} - - orch := orchestrator.New(orchestrator.OrchestratorConfig{ - PollIntervalMs: 50, - MaxConcurrentAgents: 5, - Eligibility: orchestrator.EligibilityConfig{ - ActiveValues: []string{"Todo"}, - ExecutableItemTypes: []string{"issue"}, - }, - ActiveValues: []string{"Todo"}, - TerminalValues: []string{"Done"}, - }, source, slowRunner) - - // Manually add a running entry - ctx := context.Background() - workerCtx, cancel := context.WithCancel(ctx) - orch.InjectRunning("github:p1:i1", &orchestrator.RunningEntry{ - WorkItem: orchestrator.WorkItem{ - WorkItemID: "github:p1:i1", ProjectStatus: "Todo", State: "open", - }, - CancelFunc: cancel, - StartedAt: time.Now(), - }) - - // Run reconciliation - orch.RunOnce(ctx) - - // The cancel should have been called - select { - case <-workerCtx.Done(): - // Good — worker was cancelled - default: - t.Error("expected worker to be cancelled by reconciliation") - } -} - -func TestOrchestrator_ShutdownCancelsWorkers(t *testing.T) { - source := &retrySource{} - runner := &slowMockRunner{delay: 5 * time.Second} - - orch := orchestrator.New(orchestrator.OrchestratorConfig{ - PollIntervalMs: 1000, - MaxConcurrentAgents: 5, - Eligibility: orchestrator.EligibilityConfig{ - ActiveValues: []string{"Todo"}, - ExecutableItemTypes: []string{"issue"}, - }, - }, source, runner) - - // Add a running entry with cancel - _, cancel := context.WithCancel(context.Background()) - orch.InjectRunning("github:p1:i1", &orchestrator.RunningEntry{ - WorkItem: orchestrator.WorkItem{WorkItemID: "github:p1:i1"}, - CancelFunc: cancel, - StartedAt: time.Now(), - }) - - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer shutdownCancel() - - orch.Shutdown(shutdownCtx) - - // Should have processed without hanging -} - -type slowMockRunner struct { - delay time.Duration -} - -func (r *slowMockRunner) Run(ctx context.Context, item orchestrator.WorkItem, _ *int) orchestrator.WorkerResult { - select { - case <-ctx.Done(): - return orchestrator.WorkerResult{WorkItemID: item.WorkItemID, Outcome: orchestrator.OutcomeFailure, Error: ctx.Err()} - case <-time.After(r.delay): - return orchestrator.WorkerResult{WorkItemID: item.WorkItemID, Outcome: orchestrator.OutcomeNormal} - } -} diff --git a/internal/orchestrator/run_test.go b/internal/orchestrator/run_test.go deleted file mode 100644 index f51b148..0000000 --- a/internal/orchestrator/run_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package orchestrator_test - -import ( - "context" - "sync/atomic" - "testing" - "time" - - "github.com/shivamstaq/github-symphony/internal/orchestrator" -) - -type countingSource struct { - fetchCount atomic.Int32 -} - -func (s *countingSource) FetchCandidates(_ context.Context) ([]orchestrator.WorkItem, error) { - s.fetchCount.Add(1) - return nil, nil -} - -func (s *countingSource) FetchStates(_ context.Context, _ []string) ([]orchestrator.WorkItem, error) { - return nil, nil -} - -type noopRunner struct{} - -func (n *noopRunner) Run(_ context.Context, item orchestrator.WorkItem, _ *int) orchestrator.WorkerResult { - return orchestrator.WorkerResult{WorkItemID: item.WorkItemID, Outcome: orchestrator.OutcomeNormal} -} - -func TestOrchestrator_Run_TicksOnInterval(t *testing.T) { - source := &countingSource{} - runner := &noopRunner{} - - orch := orchestrator.New(orchestrator.OrchestratorConfig{ - PollIntervalMs: 50, // 50ms interval for fast test - MaxConcurrentAgents: 5, - Eligibility: orchestrator.EligibilityConfig{ - ActiveValues: []string{"Todo"}, - ExecutableItemTypes: []string{"issue"}, - }, - }, source, runner) - - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - - orch.Run(ctx) - - // At 50ms interval over 200ms, should get ~4-5 ticks (1 immediate + 3-4 from ticker) - count := source.fetchCount.Load() - if count < 3 { - t.Errorf("expected at least 3 fetches in 200ms at 50ms interval, got %d", count) - } -} - -func TestOrchestrator_SetPendingRefresh(t *testing.T) { - source := &countingSource{} - runner := &noopRunner{} - - orch := orchestrator.New(orchestrator.OrchestratorConfig{ - PollIntervalMs: 1000, - MaxConcurrentAgents: 5, - Eligibility: orchestrator.EligibilityConfig{ - ActiveValues: []string{"Todo"}, - ExecutableItemTypes: []string{"issue"}, - }, - }, source, runner) - - orch.SetPendingRefresh() - state := orch.GetState() - if !state.PendingRefresh { - t.Error("expected PendingRefresh=true after SetPendingRefresh") - } -} diff --git a/internal/orchestrator/source_bridge.go b/internal/orchestrator/source_bridge.go deleted file mode 100644 index 784801c..0000000 --- a/internal/orchestrator/source_bridge.go +++ /dev/null @@ -1,53 +0,0 @@ -package orchestrator - -import ( - "context" - - ghub "github.com/shivamstaq/github-symphony/internal/github" -) - -// SourceBridge wraps github.Source to implement WorkItemSource. -type SourceBridge struct { - source *ghub.Source - priorityMap map[string]int -} - -// NewSourceBridge creates a bridge from github.Source to orchestrator.WorkItemSource. -func NewSourceBridge(source *ghub.Source, priorityMap map[string]int) *SourceBridge { - return &SourceBridge{source: source, priorityMap: priorityMap} -} - -func (b *SourceBridge) FetchCandidates(ctx context.Context) ([]WorkItem, error) { - raw, err := b.source.FetchCandidateRaw(ctx) - if err != nil { - return nil, err - } - var items []WorkItem - for _, r := range raw { - n := ghub.NormalizeWorkItem(r, b.priorityMap) - items = append(items, ConvertNormalizedItem(n)) - } - return items, nil -} - -func (b *SourceBridge) FetchStates(ctx context.Context, workItemIDs []string) ([]WorkItem, error) { - raw, err := b.source.FetchStateRaw(ctx) - if err != nil { - return nil, err - } - - idSet := make(map[string]bool, len(workItemIDs)) - for _, id := range workItemIDs { - idSet[id] = true - } - - var matched []WorkItem - for _, r := range raw { - n := ghub.NormalizeWorkItem(r, b.priorityMap) - item := ConvertNormalizedItem(n) - if idSet[item.WorkItemID] { - matched = append(matched, item) - } - } - return matched, nil -} diff --git a/internal/orchestrator/types.go b/internal/orchestrator/types.go deleted file mode 100644 index 7e09e59..0000000 --- a/internal/orchestrator/types.go +++ /dev/null @@ -1,179 +0,0 @@ -package orchestrator - -import ( - "context" - "time" -) - -// WorkItemState is the orchestrator's internal claim state for a work item. -type WorkItemState string - -const ( - StateUnclaimed WorkItemState = "unclaimed" - StateClaimed WorkItemState = "claimed" - StateRunning WorkItemState = "running" - StateRetryQueue WorkItemState = "retry_queued" - StateHandedOff WorkItemState = "handed_off" - StateReleased WorkItemState = "released" -) - -// RunAttemptPhase tracks where a run attempt is in its lifecycle. -type RunAttemptPhase string - -const ( - PhasePreparingWorkspace RunAttemptPhase = "preparing_workspace" - PhaseSyncingRepository RunAttemptPhase = "syncing_repository" - PhaseBuildingPrompt RunAttemptPhase = "building_prompt" - PhaseLaunchingAgent RunAttemptPhase = "launching_agent" - PhaseInitializingSession RunAttemptPhase = "initializing_session" - PhaseStreamingTurn RunAttemptPhase = "streaming_turn" - PhaseValidatingOutputs RunAttemptPhase = "validating_outputs" - PhaseWritingBackGitHub RunAttemptPhase = "writing_back_github" - PhaseFinishing RunAttemptPhase = "finishing" - PhaseSucceeded RunAttemptPhase = "succeeded" - PhaseHandedOff RunAttemptPhase = "handed_off_for_review" - PhaseFailed RunAttemptPhase = "failed" - PhaseTimedOut RunAttemptPhase = "timed_out" - PhaseStalled RunAttemptPhase = "stalled" - PhaseCanceled RunAttemptPhase = "canceled_by_reconciliation" -) - -// WorkItem is the normalized domain model for a project item. -type WorkItem struct { - WorkItemID string - ProjectItemID string - ContentType string // "issue", "draft_issue", "pull_request" - IssueID string - IssueNumber *int - IssueIdentifier string // "owner/repo#number" - Title string - Description string - State string // "open", "closed" - ProjectStatus string - Priority *int - Labels []string - Assignees []string - Milestone string - ProjectFields map[string]any - BlockedBy []BlockerRef - SubIssues []ChildRef - ParentIssue *ParentRef - LinkedPRs []PRRef - Repository *Repository - URL string - CreatedAt string - UpdatedAt string - Pass2Failed bool // true if dependency data is incomplete — do not dispatch -} - -type BlockerRef struct { - ID string - Identifier string - State string -} - -type ChildRef struct { - ID string - Identifier string - State string -} - -type ParentRef struct { - ID string - Identifier string -} - -type PRRef struct { - ID string - Number int - State string - IsDraft bool - URL string -} - -type Repository struct { - Owner string - Name string - FullName string - DefaultBranch string - CloneURLHTTPS string -} - -// RunningEntry tracks one active worker. -type RunningEntry struct { - WorkItem WorkItem - CancelFunc context.CancelFunc - IssueIdentifier string - Repository string - SessionID string - AdapterPID int - Phase RunAttemptPhase - LastAgentEvent string - LastAgentTimestamp *time.Time - LastAgentMessage string - InputTokens int - OutputTokens int - TotalTokens int - RetryAttempt *int - StartedAt time.Time -} - -// WorkerResult is what a worker goroutine sends back on completion. -type WorkerResult struct { - WorkItemID string - Outcome WorkerOutcome - Error error -} - -type WorkerOutcome string - -const ( - OutcomeNormal WorkerOutcome = "normal" - OutcomeHandoff WorkerOutcome = "handoff" - OutcomeFailure WorkerOutcome = "failure" -) - -// RetryEntry is a scheduled retry. -type RetryEntry struct { - WorkItemID string - ProjectItemID string - IssueIdentifier string - Attempt int - DueAt time.Time - Error string -} - -// State is the orchestrator's authoritative runtime state. -type State struct { - PollIntervalMs int - MaxConcurrentAgents int - Running map[string]*RunningEntry - Claimed map[string]bool - RetryAttempts map[string]*RetryEntry - Completed map[string]bool - HandedOff map[string]bool - AgentTotals AgentTotals - PendingRefresh bool - LastPollAt *time.Time - RecentWebhookEvents []WebhookEvent - DispatchTotal int64 - ErrorTotal int64 - HandoffTotal int64 -} - -// WebhookEvent records a recent webhook delivery for observability. -type WebhookEvent struct { - EventType string - DeliveryID string - ReceivedAt time.Time -} - -// AgentTotals accumulates agent metrics. -type AgentTotals struct { - InputTokens int64 - OutputTokens int64 - TotalTokens int64 - SecondsRunning float64 - GitHubWritebacks int64 - SessionsStarted int64 -} diff --git a/internal/orchestrator/worker.go b/internal/orchestrator/worker.go deleted file mode 100644 index 11bcfb3..0000000 --- a/internal/orchestrator/worker.go +++ /dev/null @@ -1,450 +0,0 @@ -package orchestrator - -import ( - "context" - "fmt" - "log/slog" - "os" - "strings" - "time" - - "github.com/shivamstaq/github-symphony/internal/adapter" - ghub "github.com/shivamstaq/github-symphony/internal/github" - "github.com/shivamstaq/github-symphony/internal/prompt" - "github.com/shivamstaq/github-symphony/internal/state" - "github.com/shivamstaq/github-symphony/internal/workspace" -) - -// WorkerDeps holds dependencies injected into the worker runner. -type WorkerDeps struct { - WorkspaceManager *workspace.Manager - AdapterFactory func(cwd string) (adapter.AdapterClient, error) - Source WorkItemSource - WriteBack *ghub.WriteBack - StateStore *state.Store - PromptTemplate string - MaxTurns int - HooksBefore string - HooksAfter string - HooksTimeoutMs int - PullRequestCfg PullRequestConfig - GitToken string // GitHub token for authenticated git operations (push) - EventBus *EventBus -} - -// PullRequestConfig for write-back decisions. -type PullRequestConfig struct { - OpenPROnSuccess bool - DraftByDefault bool - HandoffProjectStatus string - CommentOnIssue bool - ProjectID string - StatusFieldID string - HandoffOptionID string -} - -// Runner implements WorkerRunner with the full multi-turn loop. -type Runner struct { - deps WorkerDeps -} - -// NewRunner creates a worker runner with all dependencies. -func NewRunner(deps WorkerDeps) *Runner { - if deps.MaxTurns <= 0 { - deps.MaxTurns = 20 - } - return &Runner{deps: deps} -} - -// Run executes the full worker lifecycle for one work item. -func (r *Runner) Run(ctx context.Context, item WorkItem, attempt *int) WorkerResult { - logger := slog.With( - "work_item_id", item.WorkItemID, - "issue", item.IssueIdentifier, - "repo", item.Repository.FullName, - ) - - // 1. Create/reuse workspace - // Inject auth token into clone URL for push access - cloneURL := item.Repository.CloneURLHTTPS - if r.deps.GitToken != "" && strings.HasPrefix(cloneURL, "https://") { - cloneURL = strings.Replace(cloneURL, "https://", "https://x-access-token:"+r.deps.GitToken+"@", 1) - } - - ws, err := r.deps.WorkspaceManager.CreateForWorkItem(ctx, workspace.WorkItemRef{ - Owner: item.Repository.Owner, - Repo: item.Repository.Name, - IssueNumber: ptrVal(item.IssueNumber), - CloneURL: cloneURL, - BaseBranch: item.Repository.DefaultBranch, - }) - if err != nil { - logger.Error("workspace creation failed", "error", err) - r.emitEvent(item, EventError, fmt.Sprintf("Workspace failed: %v", err)) - return WorkerResult{WorkItemID: item.WorkItemID, Outcome: OutcomeFailure, Error: err} - } - r.emitEvent(item, EventWorkspaceCreated, fmt.Sprintf("Workspace ready: %s", ws.BranchName)) - - hookTimeout := time.Duration(r.deps.HooksTimeoutMs) * time.Millisecond - if hookTimeout == 0 { - hookTimeout = 60 * time.Second - } - - // 1b. Refresh workspace on continuation retries (fetch latest from origin) - if attempt != nil && *attempt > 0 && !ws.CreatedNow { - logger.Info("continuation retry, fetching latest from origin") - r.deps.WorkspaceManager.FetchOrigin(ws.Path) - } - - // 2. Run before_run hook - if r.deps.HooksBefore != "" { - if err := workspace.RunHook(ctx, "before_run", r.deps.HooksBefore, ws.Path, hookTimeout); err != nil { - logger.Error("before_run hook failed", "error", err) - return WorkerResult{WorkItemID: item.WorkItemID, Outcome: OutcomeFailure, Error: err} - } - } - - // 3. Start adapter session - adapterClient, err := r.deps.AdapterFactory(ws.Path) - if err != nil { - logger.Error("adapter creation failed", "error", err) - r.runAfterHook(ctx, ws.Path, hookTimeout) - return WorkerResult{WorkItemID: item.WorkItemID, Outcome: OutcomeFailure, Error: err} - } - defer func() { _ = adapterClient.Close() }() - - if _, err := adapterClient.Initialize(ctx); err != nil { - logger.Error("adapter initialize failed", "error", err) - r.runAfterHook(ctx, ws.Path, hookTimeout) - return WorkerResult{WorkItemID: item.WorkItemID, Outcome: OutcomeFailure, Error: err} - } - - // Load previous session ID for resumption (continuation turns keep Claude's memory) - var resumeID string - if r.deps.StateStore != nil && attempt != nil && *attempt > 0 { - sessions, _ := r.deps.StateStore.LoadSessions() - for _, s := range sessions { - if s.WorkItemID == item.WorkItemID && s.SessionID != "" { - resumeID = s.SessionID - logger.Info("resuming previous Claude session", "resume_id", resumeID) - break - } - } - } - - // Generate CLAUDE.md in workspace with issue context - r.generateClaudeMD(item, ws) - - sessionID, err := adapterClient.NewSession(ctx, adapter.SessionParams{ - Cwd: ws.Path, - Title: fmt.Sprintf("%s: %s", item.IssueIdentifier, item.Title), - ResumeSessionID: resumeID, - }) - if err != nil { - logger.Error("session creation failed", "error", err) - r.runAfterHook(ctx, ws.Path, hookTimeout) - return WorkerResult{WorkItemID: item.WorkItemID, Outcome: OutcomeFailure, Error: err} - } - - // 4. Multi-turn loop - var lastResult *adapter.PromptResult - handoffReached := false - - for turn := 1; turn <= r.deps.MaxTurns; turn++ { - // Check for context cancellation (e.g., SIGTERM shutdown) - if ctx.Err() != nil { - logger.Info("context cancelled, stopping worker") - break - } - - logger.Info("starting turn", "turn", turn, "max_turns", r.deps.MaxTurns) - r.emitEvent(item, EventTurnStarted, fmt.Sprintf("Turn %d/%d — Claude executing", turn, r.deps.MaxTurns)) - - // Build prompt - promptText, err := prompt.Render(r.deps.PromptTemplate, prompt.RenderInput{ - WorkItem: workItemToMap(item), - Repository: repoToMap(item.Repository), - Attempt: attempt, - BranchName: ws.BranchName, - BaseBranch: ws.BaseBranch, - }) - if err != nil { - logger.Error("prompt render failed", "error", err) - _ = adapterClient.CloseSession(ctx, sessionID) - r.runAfterHook(ctx, ws.Path, hookTimeout) - return WorkerResult{WorkItemID: item.WorkItemID, Outcome: OutcomeFailure, Error: err} - } - - // Send prompt - lastResult, err = adapterClient.Prompt(ctx, sessionID, promptText) - if err != nil { - logger.Error("prompt turn failed", "turn", turn, "error", err) - _ = adapterClient.CloseSession(ctx, sessionID) - r.runAfterHook(ctx, ws.Path, hookTimeout) - return WorkerResult{WorkItemID: item.WorkItemID, Outcome: OutcomeFailure, Error: err} - } - - logger.Info("turn completed", "turn", turn, "stop_reason", lastResult.StopReason, - "cost_usd", lastResult.CostUSD, "claude_session", lastResult.SessionID) - r.emitEvent(item, EventTurnCompleted, fmt.Sprintf("Turn %d done — %s ($%.4f)", turn, lastResult.StopReason, lastResult.CostUSD)) - - // Persist Claude session ID for future resumption - if lastResult.SessionID != "" && r.deps.StateStore != nil { - _ = r.deps.StateStore.SaveSession(state.SessionRecord{ - WorkItemID: item.WorkItemID, - SessionID: lastResult.SessionID, - AdapterKind: "claude_code", - LastStatus: string(lastResult.StopReason), - }) - } - - // If prompt failed, don't continue to next turn - if lastResult.StopReason == adapter.StopFailed { - logger.Warn("prompt failed, stopping turns", "turn", turn) - break - } - - // Re-check work item state between turns - if r.deps.Source != nil && turn < r.deps.MaxTurns { - refreshed, err := r.deps.Source.FetchStates(ctx, []string{item.WorkItemID}) - if err != nil { - logger.Warn("state refresh failed between turns", "error", err) - } else if len(refreshed) > 0 { - item = refreshed[0] - - // Check if item is no longer active - if item.State != "open" { - logger.Info("work item no longer active, stopping", "state", item.State) - break - } - - // Check handoff condition - handoff := EvaluateHandoff(HandoffInput{ - HasPR: len(item.LinkedPRs) > 0, - CurrentProjectStatus: item.ProjectStatus, - HandoffProjectStatus: r.deps.PullRequestCfg.HandoffProjectStatus, - }) - if handoff.IsHandoff { - logger.Info("handoff condition met between turns") - handoffReached = true - break - } - } - } - } - - // 5. Close adapter session - _ = adapterClient.CloseSession(ctx, sessionID) - - // 6. Write-back (if configured, agent completed, AND there are actual commits) - hasCommits := r.deps.WorkspaceManager.HasNewCommits(ws.Path, ws.BaseBranch) - if r.deps.PullRequestCfg.OpenPROnSuccess && r.deps.WriteBack != nil && lastResult != nil && lastResult.StopReason == adapter.StopCompleted && hasCommits { - logger.Info("new commits detected, performing write-back") - if err := r.performWriteBack(ctx, item, ws, logger); err != nil { - logger.Error("write-back failed", "error", err) - r.runAfterHook(ctx, ws.Path, hookTimeout) - return WorkerResult{WorkItemID: item.WorkItemID, Outcome: OutcomeFailure, Error: err} - } - - // PR creation IS the handoff. - handoffReached = true - logger.Info("PR created/updated, marking as handed off") - r.emitEvent(item, EventHandoff, "PR created, handed off for review") - - // Persist handoff to bbolt so it survives restarts - if r.deps.StateStore != nil { - _ = r.deps.StateStore.SaveHandoff(item.WorkItemID) - } - } - - // 7. Run after_run hook (best-effort) - r.runAfterHook(ctx, ws.Path, hookTimeout) - - // 8. Return outcome - if handoffReached { - logger.Info("worker completed with handoff") - return WorkerResult{WorkItemID: item.WorkItemID, Outcome: OutcomeHandoff} - } - - logger.Info("worker completed normally") - return WorkerResult{WorkItemID: item.WorkItemID, Outcome: OutcomeNormal} -} - -func (r *Runner) performWriteBack(ctx context.Context, item WorkItem, ws *workspace.Workspace, logger *slog.Logger) error { - // Push branch - if err := r.deps.WorkspaceManager.PushBranch(ws.Path, "origin", ws.BranchName); err != nil { - return fmt.Errorf("push branch: %w", err) - } - - // Upsert PR - baseBranch := ws.BaseBranch - if baseBranch == "" { - baseBranch = "main" - } - - prBody := fmt.Sprintf("Automated by Symphony for %s\n\nCloses %s\n\n---\n🤖 *Automated by [Symphony](https://github.com/shivamstaq/github-symphony)*", - item.IssueIdentifier, item.IssueIdentifier) - - prResult, err := r.deps.WriteBack.UpsertPR(ctx, ghub.PRParams{ - Owner: item.Repository.Owner, - Repo: item.Repository.Name, - Title: item.Title, - Body: prBody, - HeadBranch: ws.BranchName, - BaseBranch: baseBranch, - Draft: r.deps.PullRequestCfg.DraftByDefault, - }) - if err != nil { - return fmt.Errorf("upsert PR: %w", err) - } - - action := "created" - if !prResult.Created { - action = "updated" - } - logger.Info("PR write-back", "action", action, "number", prResult.Number, "url", prResult.URL) - r.emitEvent(item, EventPRCreated, fmt.Sprintf("PR #%d %s → %s", prResult.Number, action, prResult.URL)) - - // Comment on issue (only on first PR creation, not updates) - if r.deps.PullRequestCfg.CommentOnIssue && item.IssueNumber != nil && prResult.Created { - body := fmt.Sprintf("Symphony created PR: %s\n\n---\n🤖 *Automated by [Symphony](https://github.com/shivamstaq/github-symphony)*", prResult.URL) - _, err := r.deps.WriteBack.CommentOnIssue(ctx, item.Repository.Owner, item.Repository.Name, *item.IssueNumber, body) - if err != nil { - logger.Warn("issue comment failed", "error", err) - } - } - - // Move project status to handoff value (best-effort) - cfg := r.deps.PullRequestCfg - if cfg.ProjectID != "" && cfg.StatusFieldID != "" && cfg.HandoffOptionID != "" { - logger.Info("updating project status", - "project_id", cfg.ProjectID, - "item_id", item.ProjectItemID, - "field_id", cfg.StatusFieldID, - "option_id", cfg.HandoffOptionID, - ) - if err := r.deps.WriteBack.UpdateProjectField(ctx, cfg.ProjectID, item.ProjectItemID, cfg.StatusFieldID, cfg.HandoffOptionID); err != nil { - logger.Warn("project status update failed (non-fatal)", "error", err) - } else { - logger.Info("project status updated to handoff", "status", cfg.HandoffProjectStatus) - } - } else { - logger.Warn("project status update skipped (missing metadata)", - "project_id", cfg.ProjectID, - "field_id", cfg.StatusFieldID, - "option_id", cfg.HandoffOptionID, - ) - } - - return nil -} - -// generateClaudeMD creates a CLAUDE.md file in the workspace with issue context. -// Claude Code reads CLAUDE.md automatically on every invocation, providing -// persistent context across sessions without needing --resume. -func (r *Runner) generateClaudeMD(item WorkItem, ws *workspace.Workspace) { - content := fmt.Sprintf(`# Symphony Agent Context - -## Work Item -- **Issue**: %s — %s -- **Repository**: %s -- **Branch**: %s (based on %s) -- **Status**: %s - -## Instructions -- You are working on the issue described above -- Make changes, commit them with descriptive messages -- Do NOT push directly — Symphony handles git push and PR creation -- Use gh CLI (GITHUB_TOKEN is in your environment) to post workpad comments on the issue -- Before creating a workpad comment, check if one already exists -`, - item.IssueIdentifier, item.Title, - item.Repository.FullName, - ws.BranchName, ws.BaseBranch, - item.ProjectStatus, - ) - - if item.Description != "" { - content += fmt.Sprintf("\n## Issue Description\n\n%s\n", item.Description) - } - - claudePath := fmt.Sprintf("%s/CLAUDE.md", ws.Path) - if err := os.WriteFile(claudePath, []byte(content), 0644); err != nil { - slog.Warn("failed to write CLAUDE.md", "path", claudePath, "error", err) - } -} - -func (r *Runner) emitEvent(item WorkItem, kind EventKind, message string) { - if r.deps.EventBus != nil { - r.deps.EventBus.Emit(Event{ - WorkItemID: item.WorkItemID, - Issue: item.IssueIdentifier, - Kind: kind, - Message: message, - }) - } -} - -func (r *Runner) runAfterHook(ctx context.Context, wsPath string, timeout time.Duration) { - if r.deps.HooksAfter != "" { - if err := workspace.RunHook(ctx, "after_run", r.deps.HooksAfter, wsPath, timeout); err != nil { - slog.Warn("after_run hook failed (ignored)", "error", err) - } - } -} - -func ptrVal(p *int) int { - if p == nil { - return 0 - } - return *p -} - -func workItemToMap(item WorkItem) map[string]any { - // Convert sub-issues to template-friendly maps - var subIssues []map[string]any - for _, s := range item.SubIssues { - subIssues = append(subIssues, map[string]any{ - "id": s.ID, "identifier": s.Identifier, "state": s.State, - }) - } - - // Convert blockers to template-friendly maps - var blockedBy []map[string]any - for _, b := range item.BlockedBy { - blockedBy = append(blockedBy, map[string]any{ - "id": b.ID, "identifier": b.Identifier, "state": b.State, - }) - } - - return map[string]any{ - "work_item_id": item.WorkItemID, - "project_item_id": item.ProjectItemID, - "content_type": item.ContentType, - "issue_id": item.IssueID, - "issue_number": ptrVal(item.IssueNumber), - "issue_identifier": item.IssueIdentifier, - "title": item.Title, - "description": item.Description, - "state": item.State, - "project_status": item.ProjectStatus, - "labels": item.Labels, - "assignees": item.Assignees, - "milestone": item.Milestone, - "url": item.URL, - "sub_issues": subIssues, - "blocked_by": blockedBy, - } -} - -func repoToMap(repo *Repository) map[string]any { - if repo == nil { - return nil - } - return map[string]any{ - "owner": repo.Owner, - "name": repo.Name, - "full_name": repo.FullName, - "default_branch": repo.DefaultBranch, - } -} diff --git a/internal/orchestrator/worker_test.go b/internal/orchestrator/worker_test.go deleted file mode 100644 index f63474d..0000000 --- a/internal/orchestrator/worker_test.go +++ /dev/null @@ -1,235 +0,0 @@ -package orchestrator_test - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/shivamstaq/github-symphony/internal/adapter" - "github.com/shivamstaq/github-symphony/internal/orchestrator" - "github.com/shivamstaq/github-symphony/internal/workspace" -) - -const workerMockAgent = ` -while IFS= read -r line; do - id=$(echo "$line" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('id',0))" 2>/dev/null || echo "0") - method=$(echo "$line" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('method',''))" 2>/dev/null || echo "") - case "$method" in - initialize) echo "{\"id\":${id},\"result\":{\"protocolVersion\":1,\"provider\":\"test\",\"capabilities\":{}}}" ;; - session/new) echo "{\"id\":${id},\"result\":{\"sessionId\":\"s1\"}}" ;; - session/prompt) echo "{\"id\":${id},\"result\":{\"stopReason\":\"completed\",\"summary\":\"done\"}}" ;; - session/cancel) echo "{\"id\":${id},\"result\":{\"cancelled\":true}}" ;; - session/close) echo "{\"id\":${id},\"result\":{\"closed\":true}}" ;; - *) echo "{\"id\":${id},\"error\":{\"code\":-1,\"message\":\"unknown\"}}" ;; - esac -done -` - -func setupTestRepo(t *testing.T) (string, string, string) { - t.Helper() - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not found") - } - root := t.TempDir() - bareRepo := filepath.Join(root, "bare.git") - runGitCmd(t, root, "init", "--bare", bareRepo) - srcRepo := filepath.Join(root, "src") - runGitCmd(t, root, "clone", bareRepo, srcRepo) - os.WriteFile(filepath.Join(srcRepo, "README.md"), []byte("init"), 0644) - runGitCmd(t, srcRepo, "add", ".") - runGitCmd(t, srcRepo, "-c", "user.email=t@t", "-c", "user.name=T", "commit", "-m", "init") - branch := strings.TrimSpace(runGitOutputCmd(t, srcRepo, "branch", "--show-current")) - runGitCmd(t, srcRepo, "push", "origin", branch) - return root, bareRepo, branch -} - -func runGitCmd(t *testing.T, dir string, args ...string) { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v: %v\n%s", args, err, out) - } -} - -func runGitOutputCmd(t *testing.T, dir string, args ...string) string { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v: %v\n%s", args, err, out) - } - return string(out) -} - -func TestWorker_BasicRun_CompletesNormally(t *testing.T) { - root, bareRepo, branch := setupTestRepo(t) - - mgr := workspace.NewManager(workspace.ManagerConfig{ - WorktreeDir: filepath.Join(root, "worktrees"), - RepoCacheDir: filepath.Join(root, "cache"), - BranchPrefix: "symphony/", - UseWorktrees: false, - }) - - runner := orchestrator.NewRunner(orchestrator.WorkerDeps{ - WorkspaceManager: mgr, - AdapterFactory: func(cwd string) (adapter.AdapterClient, error) { - return adapter.NewAdapter(adapter.AdapterConfig{ - Kind: "opencode", - Command: "bash", - Args: []string{"-c", workerMockAgent}, - Cwd: cwd, - }) - }, - PromptTemplate: "Fix the issue: {{.work_item.title}}", - MaxTurns: 2, - }) - - num := 1 - item := orchestrator.WorkItem{ - WorkItemID: "github:p1:i1", - ProjectItemID: "p1", - IssueID: "i1", - IssueNumber: &num, - IssueIdentifier: "org/repo#1", - ContentType: "issue", - Title: "Fix flaky test", - State: "open", - ProjectStatus: "Todo", - Repository: &orchestrator.Repository{ - Owner: "org", - Name: "repo", - FullName: "org/repo", - DefaultBranch: branch, - CloneURLHTTPS: bareRepo, - }, - } - - result := runner.Run(context.Background(), item, nil) - - if result.Outcome != orchestrator.OutcomeNormal { - t.Errorf("expected OutcomeNormal, got %q (error: %v)", result.Outcome, result.Error) - } -} - -func TestWorker_MaxTurns_Enforced(t *testing.T) { - root, bareRepo, branch := setupTestRepo(t) - - mgr := workspace.NewManager(workspace.ManagerConfig{ - WorktreeDir: filepath.Join(root, "worktrees"), - RepoCacheDir: filepath.Join(root, "cache"), - BranchPrefix: "symphony/", - UseWorktrees: false, - }) - - turnCount := 0 - runner := orchestrator.NewRunner(orchestrator.WorkerDeps{ - WorkspaceManager: mgr, - AdapterFactory: func(cwd string) (adapter.AdapterClient, error) { - return adapter.NewAdapter(adapter.AdapterConfig{ - Kind: "opencode", // use generic subprocess adapter for testing - Command: "bash", - Args: []string{"-c", workerMockAgent}, - Cwd: cwd, - }) - }, - Source: &countingSourceForWorker{onFetch: func() { turnCount++ }}, - PromptTemplate: "Work on: {{.work_item.title}}", - MaxTurns: 3, - }) - - num := 2 - item := orchestrator.WorkItem{ - WorkItemID: "github:p2:i2", ProjectItemID: "p2", IssueID: "i2", - IssueNumber: &num, IssueIdentifier: "org/repo#2", - ContentType: "issue", Title: "Multi-turn test", State: "open", - ProjectStatus: "Todo", - Repository: &orchestrator.Repository{ - Owner: "org", Name: "repo", FullName: "org/repo", - DefaultBranch: branch, CloneURLHTTPS: bareRepo, - }, - } - - result := runner.Run(context.Background(), item, nil) - - if result.Outcome != orchestrator.OutcomeNormal { - t.Errorf("expected OutcomeNormal, got %q (error: %v)", result.Outcome, result.Error) - } - // Should have done 3 turns (max), with FetchStates called between turns 1-2 and 2-3 - if turnCount < 2 { - t.Errorf("expected at least 2 state refreshes between turns, got %d", turnCount) - } -} - -func TestWorker_BeforeRunHook_Failure_Aborts(t *testing.T) { - root, bareRepo, branch := setupTestRepo(t) - - mgr := workspace.NewManager(workspace.ManagerConfig{ - WorktreeDir: filepath.Join(root, "worktrees"), - RepoCacheDir: filepath.Join(root, "cache"), - BranchPrefix: "symphony/", - UseWorktrees: false, - }) - - runner := orchestrator.NewRunner(orchestrator.WorkerDeps{ - WorkspaceManager: mgr, - AdapterFactory: func(cwd string) (adapter.AdapterClient, error) { - t.Error("adapter should not be created when before_run fails") - return nil, nil - }, - PromptTemplate: "should not render", - MaxTurns: 1, - HooksBefore: "exit 1", // fail - HooksTimeoutMs: 5000, - }) - - num := 3 - item := orchestrator.WorkItem{ - WorkItemID: "github:p3:i3", ProjectItemID: "p3", - IssueNumber: &num, IssueIdentifier: "org/repo#3", - ContentType: "issue", Title: "Hook fail test", State: "open", - ProjectStatus: "Todo", - Repository: &orchestrator.Repository{ - Owner: "org", Name: "repo", FullName: "org/repo", - DefaultBranch: branch, CloneURLHTTPS: bareRepo, - }, - } - - result := runner.Run(context.Background(), item, nil) - - if result.Outcome != orchestrator.OutcomeFailure { - t.Errorf("expected OutcomeFailure, got %q", result.Outcome) - } -} - -// countingSourceForWorker counts FetchStates calls and always returns the item as active. -type countingSourceForWorker struct { - onFetch func() -} - -func (s *countingSourceForWorker) FetchCandidates(_ context.Context) ([]orchestrator.WorkItem, error) { - return nil, nil -} - -func (s *countingSourceForWorker) FetchStates(_ context.Context, ids []string) ([]orchestrator.WorkItem, error) { - if s.onFetch != nil { - s.onFetch() - } - // Return items as still active - var items []orchestrator.WorkItem - for _, id := range ids { - items = append(items, orchestrator.WorkItem{ - WorkItemID: id, - State: "open", - ProjectStatus: "Todo", - }) - } - return items, nil -} diff --git a/internal/orchestrator/writeback_failure_test.go b/internal/orchestrator/writeback_failure_test.go deleted file mode 100644 index 0053efc..0000000 --- a/internal/orchestrator/writeback_failure_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package orchestrator_test - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/shivamstaq/github-symphony/internal/orchestrator" -) - -// mockFailingRunner simulates a worker that encounters a write-back failure. -type mockFailingRunner struct{} - -func (m *mockFailingRunner) Run(_ context.Context, item orchestrator.WorkItem, _ *int) orchestrator.WorkerResult { - return orchestrator.WorkerResult{ - WorkItemID: item.WorkItemID, - Outcome: orchestrator.OutcomeFailure, - Error: fmt.Errorf("github_api_status: 422: validation failed"), - } -} - -func (m *mockFailingRunner) FetchCandidates(_ context.Context) ([]orchestrator.WorkItem, error) { - return nil, nil -} - -func (m *mockFailingRunner) FetchStates(_ context.Context, _ []string) ([]orchestrator.WorkItem, error) { - return nil, nil -} - -func TestOrchestrator_WritebackFailureSchedulesRetry(t *testing.T) { - source := &mockSource{ - items: []orchestrator.WorkItem{ - { - WorkItemID: "github:item1:issue1", - ProjectItemID: "item1", - ContentType: "issue", - Title: "Writeback fail test", - State: "open", - ProjectStatus: "Todo", - IssueNumber: intPtr(1), - Repository: &orchestrator.Repository{Owner: "org", Name: "repo", FullName: "org/repo"}, - }, - }, - } - - runner := &mockFailingRunner{} - - orch := orchestrator.New(orchestrator.OrchestratorConfig{ - PollIntervalMs: 100, - MaxConcurrentAgents: 5, - Eligibility: orchestrator.EligibilityConfig{ - ActiveValues: []string{"Todo"}, - ExecutableItemTypes: []string{"issue"}, - }, - }, source, runner) - - ctx := context.Background() - orch.RunOnce(ctx) - - // Wait for the failing worker to complete - time.Sleep(100 * time.Millisecond) - orch.ProcessResults() - - // Verify the failure was recorded as a retry - state := orch.GetState() - - if len(state.Running) != 0 { - t.Errorf("expected 0 running after failure, got %d", len(state.Running)) - } - - retry, ok := state.RetryAttempts["github:item1:issue1"] - if !ok { - t.Fatal("expected retry entry for failed work item") - } - if retry.Attempt != 1 { - t.Errorf("expected retry attempt=1, got %d", retry.Attempt) - } - if retry.Error == "" { - t.Error("expected retry error to contain failure reason") - } -} diff --git a/internal/prompt/router.go b/internal/prompt/router.go new file mode 100644 index 0000000..b4edea9 --- /dev/null +++ b/internal/prompt/router.go @@ -0,0 +1,49 @@ +package prompt + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/shivamstaq/github-symphony/internal/config" + "github.com/shivamstaq/github-symphony/internal/domain" +) + +// Router selects a prompt template based on a work item's custom field value. +type Router struct { + cfg config.PromptRoutingConfig + promptDir string // directory containing prompt template files +} + +// NewRouter creates a prompt router. +// promptDir is the absolute path to .symphony/prompts/. +func NewRouter(cfg config.PromptRoutingConfig, promptDir string) *Router { + return &Router{cfg: cfg, promptDir: promptDir} +} + +// SelectTemplate returns the prompt template content for the given work item. +// It checks the work item's ProjectFields for the configured routing field, +// matches the value against routes, and falls back to the default template. +func (r *Router) SelectTemplate(item domain.WorkItem) (string, error) { + templateFile := r.cfg.Default + + if r.cfg.FieldName != "" && item.ProjectFields != nil { + if val, ok := item.ProjectFields[r.cfg.FieldName]; ok { + valStr := fmt.Sprintf("%v", val) + for routeVal, tmplFile := range r.cfg.Routes { + if strings.EqualFold(routeVal, valStr) { + templateFile = tmplFile + break + } + } + } + } + + path := filepath.Join(r.promptDir, templateFile) + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read prompt template %q: %w", path, err) + } + return string(data), nil +} diff --git a/internal/prompt/router_test.go b/internal/prompt/router_test.go new file mode 100644 index 0000000..749dbcc --- /dev/null +++ b/internal/prompt/router_test.go @@ -0,0 +1,121 @@ +package prompt + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/shivamstaq/github-symphony/internal/config" + "github.com/shivamstaq/github-symphony/internal/domain" +) + +func setupPromptDir(t *testing.T) string { + t.Helper() + dir := filepath.Join(t.TempDir(), "prompts") + os.MkdirAll(dir, 0755) + os.WriteFile(filepath.Join(dir, "default.md"), []byte("Default prompt for {{.work_item.title}}"), 0644) + os.WriteFile(filepath.Join(dir, "bug_fix.md"), []byte("Fix the bug: {{.work_item.title}}"), 0644) + os.WriteFile(filepath.Join(dir, "feature.md"), []byte("Implement feature: {{.work_item.title}}"), 0644) + return dir +} + +func TestRouter_DefaultTemplate(t *testing.T) { + dir := setupPromptDir(t) + r := NewRouter(config.PromptRoutingConfig{ + Default: "default.md", + }, dir) + + item := domain.WorkItem{Title: "Test"} + tmpl, err := r.SelectTemplate(item) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(tmpl, "Default prompt") { + t.Errorf("expected default template, got %q", tmpl) + } +} + +func TestRouter_RouteByField(t *testing.T) { + dir := setupPromptDir(t) + r := NewRouter(config.PromptRoutingConfig{ + FieldName: "Type", + Routes: map[string]string{ + "Bug": "bug_fix.md", + "Feature": "feature.md", + }, + Default: "default.md", + }, dir) + + // Bug route + item := domain.WorkItem{ + Title: "Fix login", + ProjectFields: map[string]any{"Type": "Bug"}, + } + tmpl, err := r.SelectTemplate(item) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(tmpl, "Fix the bug") { + t.Errorf("expected bug template, got %q", tmpl) + } + + // Feature route + item.ProjectFields["Type"] = "Feature" + tmpl, err = r.SelectTemplate(item) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(tmpl, "Implement feature") { + t.Errorf("expected feature template, got %q", tmpl) + } + + // Unknown value -> default + item.ProjectFields["Type"] = "Unknown" + tmpl, err = r.SelectTemplate(item) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(tmpl, "Default prompt") { + t.Errorf("expected default for unknown type, got %q", tmpl) + } +} + +func TestRouter_CaseInsensitive(t *testing.T) { + dir := setupPromptDir(t) + r := NewRouter(config.PromptRoutingConfig{ + FieldName: "Type", + Routes: map[string]string{"bug": "bug_fix.md"}, + Default: "default.md", + }, dir) + + item := domain.WorkItem{ + Title: "Test", + ProjectFields: map[string]any{"Type": "BUG"}, + } + tmpl, err := r.SelectTemplate(item) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(tmpl, "Fix the bug") { + t.Errorf("expected case-insensitive match for bug, got %q", tmpl) + } +} + +func TestRouter_NoProjectFields(t *testing.T) { + dir := setupPromptDir(t) + r := NewRouter(config.PromptRoutingConfig{ + FieldName: "Type", + Routes: map[string]string{"bug": "bug_fix.md"}, + Default: "default.md", + }, dir) + + item := domain.WorkItem{Title: "Test"} // no ProjectFields + tmpl, err := r.SelectTemplate(item) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(tmpl, "Default prompt") { + t.Errorf("expected default when no fields, got %q", tmpl) + } +} diff --git a/internal/server/api.go b/internal/server/api.go new file mode 100644 index 0000000..34417dd --- /dev/null +++ b/internal/server/api.go @@ -0,0 +1,209 @@ +package server + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/shivamstaq/github-symphony/internal/engine" +) + +// EngineAPI provides the engine interface needed by the API server. +type EngineAPI interface { + GetState() *engine.State + Emit(engine.EngineEvent) +} + +// APIServer serves the Symphony HTTP API. +type APIServer struct { + engine EngineAPI + startedAt time.Time + router *chi.Mux + webhookSecret string // HMAC secret for GitHub webhook verification +} + +// APIServerConfig configures the API server. +type APIServerConfig struct { + WebhookSecret string // GitHub webhook secret for HMAC verification +} + +// NewAPIServer creates the HTTP API server. +func NewAPIServer(eng EngineAPI, cfgs ...APIServerConfig) *APIServer { + s := &APIServer{ + engine: eng, + startedAt: time.Now(), + router: chi.NewRouter(), + } + if len(cfgs) > 0 { + s.webhookSecret = cfgs[0].WebhookSecret + } + s.routes() + return s +} + +func (s *APIServer) routes() { + s.router.Get("/healthz", s.handleHealthz) + s.router.Get("/metrics", s.handleMetrics) + s.router.Get("/api/v1/state", s.handleGetState) + s.router.Post("/api/v1/pause/{id}", s.handlePause) + s.router.Post("/api/v1/resume/{id}", s.handleResume) + s.router.Post("/api/v1/kill/{id}", s.handleKill) + s.router.Post("/api/v1/refresh", s.handleRefresh) + s.router.Post("/api/v1/webhooks/github", s.handleWebhook) +} + +// Handler returns the HTTP handler. +func (s *APIServer) Handler() http.Handler { + return s.router +} + +func (s *APIServer) handleHealthz(w http.ResponseWriter, r *http.Request) { + state := s.engine.GetState() + uptime := time.Since(s.startedAt).Truncate(time.Second) + writeJSON(w, map[string]any{ + "status": "healthy", + "uptime": uptime.String(), + "running": state.RunningCount(), + "last_poll": state.LastPollAt, + }) +} + +func (s *APIServer) handleMetrics(w http.ResponseWriter, r *http.Request) { + state := s.engine.GetState() + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + + lines := []string{ + fmt.Sprintf("symphony_active_runs %d", state.RunningCount()), + fmt.Sprintf("symphony_retry_queue_depth %d", len(state.RetryQueue)), + fmt.Sprintf("symphony_dispatch_total %d", state.DispatchTotal), + fmt.Sprintf("symphony_error_total %d", state.ErrorTotal), + fmt.Sprintf("symphony_handoff_total %d", state.HandoffTotal), + fmt.Sprintf("symphony_tokens_total{direction=\"input\"} %d", state.Totals.InputTokens), + fmt.Sprintf("symphony_tokens_total{direction=\"output\"} %d", state.Totals.OutputTokens), + fmt.Sprintf("symphony_tokens_total{direction=\"total\"} %d", state.Totals.TotalTokens), + fmt.Sprintf("symphony_sessions_started_total %d", state.Totals.SessionsStarted), + fmt.Sprintf("symphony_cost_usd_total %f", state.Totals.CostUSD), + } + _, _ = fmt.Fprintln(w, strings.Join(lines, "\n")) +} + +func (s *APIServer) handleGetState(w http.ResponseWriter, r *http.Request) { + state := s.engine.GetState() + + running := make([]map[string]any, 0) + for id, entry := range state.Running { + running = append(running, map[string]any{ + "id": id, + "issue": entry.WorkItem.IssueIdentifier, + "phase": entry.Phase, + "paused": entry.Paused, + "tokens": entry.TotalTokens, + "cost_usd": entry.CostUSD, + "turns": entry.TurnsCompleted, + "started_at": entry.StartedAt, + "last_activity": entry.LastActivityAt, + "retry_attempt": entry.RetryAttempt, + }) + } + + retries := make([]map[string]any, 0) + for _, re := range state.RetryQueue { + retries = append(retries, map[string]any{ + "id": re.WorkItemID, + "issue": re.IssueIdentifier, + "attempt": re.Attempt, + "due_at": re.DueAt, + "error": re.Error, + }) + } + + writeJSON(w, map[string]any{ + "running": running, + "retries": retries, + "handed_off": len(state.HandedOff), + "dispatch_total": state.DispatchTotal, + "error_total": state.ErrorTotal, + "handoff_total": state.HandoffTotal, + "totals": state.Totals, + }) +} + +func (s *APIServer) handlePause(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + s.engine.Emit(engine.NewEvent(engine.EvtPauseRequested, id, nil)) + writeJSON(w, map[string]any{"status": "pause requested", "item": id}) +} + +func (s *APIServer) handleResume(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + s.engine.Emit(engine.NewEvent(engine.EvtResumeRequested, id, nil)) + writeJSON(w, map[string]any{"status": "resume requested", "item": id}) +} + +func (s *APIServer) handleKill(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + s.engine.Emit(engine.NewEvent(engine.EvtCancelRequested, id, nil)) + writeJSON(w, map[string]any{"status": "kill requested", "item": id}) +} + +func (s *APIServer) handleRefresh(w http.ResponseWriter, r *http.Request) { + s.engine.Emit(engine.NewEvent(engine.EvtPollTick, "", engine.PollTickPayload{})) + writeJSON(w, map[string]any{"status": "refresh triggered"}) +} + +func (s *APIServer) handleWebhook(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB max + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + // Verify signature if webhook secret is configured + if s.webhookSecret != "" { + sig := r.Header.Get("X-Hub-Signature-256") + if sig == "" { + http.Error(w, "missing signature", http.StatusUnauthorized) + return + } + if !verifyHMAC(body, sig, s.webhookSecret) { + http.Error(w, "invalid signature", http.StatusUnauthorized) + return + } + } + + // Trigger a poll tick — the engine's buffered channel provides natural coalescing: + // if the channel is full, the event is dropped (duplicate poll suppressed). + s.engine.Emit(engine.NewEvent(engine.EvtPollTick, "", engine.PollTickPayload{})) + writeJSON(w, map[string]any{"status": "webhook received"}) +} + +// verifyHMAC checks the GitHub webhook HMAC-SHA256 signature. +func verifyHMAC(body []byte, signature, secret string) bool { + // GitHub sends "sha256=" + prefix := "sha256=" + if !strings.HasPrefix(signature, prefix) { + return false + } + sigHex := signature[len(prefix):] + sigBytes, err := hex.DecodeString(sigHex) + if err != nil { + return false + } + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + expected := mac.Sum(nil) + return hmac.Equal(sigBytes, expected) +} + +func writeJSON(w http.ResponseWriter, data any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(data) +} diff --git a/internal/server/api_test.go b/internal/server/api_test.go new file mode 100644 index 0000000..89f7902 --- /dev/null +++ b/internal/server/api_test.go @@ -0,0 +1,137 @@ +package server + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "testing" + + "github.com/shivamstaq/github-symphony/internal/engine" +) + +// mockEngine implements EngineAPI for testing. +type mockEngine struct { + events []engine.EngineEvent +} + +func (m *mockEngine) GetState() *engine.State { + return engine.NewState() +} + +func (m *mockEngine) Emit(evt engine.EngineEvent) { + m.events = append(m.events, evt) +} + +func TestWebhook_ValidSignature(t *testing.T) { + secret := "test-secret" + eng := &mockEngine{} + srv := NewAPIServer(eng, APIServerConfig{WebhookSecret: secret}) + + body := []byte(`{"action":"opened"}`) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + sig := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + + req := httptest.NewRequest("POST", "/api/v1/webhooks/github", bytes.NewReader(body)) + req.Header.Set("X-Hub-Signature-256", sig) + w := httptest.NewRecorder() + + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if len(eng.events) != 1 { + t.Errorf("expected 1 event emitted, got %d", len(eng.events)) + } + if eng.events[0].Type != engine.EvtPollTick { + t.Errorf("expected poll tick event, got %s", eng.events[0].Type) + } +} + +func TestWebhook_InvalidSignature(t *testing.T) { + eng := &mockEngine{} + srv := NewAPIServer(eng, APIServerConfig{WebhookSecret: "real-secret"}) + + body := []byte(`{"action":"opened"}`) + req := httptest.NewRequest("POST", "/api/v1/webhooks/github", bytes.NewReader(body)) + req.Header.Set("X-Hub-Signature-256", "sha256=badhex") + w := httptest.NewRecorder() + + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } + if len(eng.events) != 0 { + t.Errorf("expected no events emitted, got %d", len(eng.events)) + } +} + +func TestWebhook_MissingSignature(t *testing.T) { + eng := &mockEngine{} + srv := NewAPIServer(eng, APIServerConfig{WebhookSecret: "secret"}) + + body := []byte(`{"action":"opened"}`) + req := httptest.NewRequest("POST", "/api/v1/webhooks/github", bytes.NewReader(body)) + w := httptest.NewRecorder() + + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +func TestWebhook_NoSecretConfigured(t *testing.T) { + eng := &mockEngine{} + srv := NewAPIServer(eng) // no webhook secret + + body := []byte(`{"action":"opened"}`) + req := httptest.NewRequest("POST", "/api/v1/webhooks/github", bytes.NewReader(body)) + w := httptest.NewRecorder() + + srv.Handler().ServeHTTP(w, req) + + // Without secret configured, webhook should be accepted without signature + if w.Code != http.StatusOK { + t.Errorf("expected 200 (no secret = no verification), got %d", w.Code) + } + if len(eng.events) != 1 { + t.Errorf("expected 1 event emitted, got %d", len(eng.events)) + } +} + +func TestHealthz(t *testing.T) { + eng := &mockEngine{} + srv := NewAPIServer(eng) + + req := httptest.NewRequest("GET", "/healthz", nil) + w := httptest.NewRecorder() + + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestRefresh(t *testing.T) { + eng := &mockEngine{} + srv := NewAPIServer(eng) + + req := httptest.NewRequest("POST", "/api/v1/refresh", nil) + w := httptest.NewRecorder() + + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + if len(eng.events) != 1 || eng.events[0].Type != engine.EvtPollTick { + t.Errorf("expected poll tick event") + } +} diff --git a/internal/server/metrics_resilience_test.go b/internal/server/metrics_resilience_test.go deleted file mode 100644 index 97912e2..0000000 --- a/internal/server/metrics_resilience_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package server_test - -import ( - "net/http/httptest" - "testing" - "time" - - "github.com/shivamstaq/github-symphony/internal/orchestrator" - "github.com/shivamstaq/github-symphony/internal/server" -) - -// panicStateProvider panics when GetState is called, simulating a metrics subsystem failure. -type panicStateProvider struct{} - -func (p *panicStateProvider) GetState() orchestrator.State { panic("simulated metrics failure") } -func (p *panicStateProvider) IsHealthy() bool { return true } -func (p *panicStateProvider) AuthMode() string { return "pat" } -func (p *panicStateProvider) TriggerRefresh() {} -func (p *panicStateProvider) StartedAt() time.Time { return time.Now() } - -func TestMetrics_PanicDoesNotCrashServer(t *testing.T) { - srv := server.New(server.Config{}, &panicStateProvider{}) - - // The chi Recoverer middleware should catch the panic - req := httptest.NewRequest("GET", "/metrics", nil) - w := httptest.NewRecorder() - - // This should NOT panic the test process - srv.Handler().ServeHTTP(w, req) - - // Recoverer returns 500 on panic - if w.Code != 500 { - t.Errorf("expected 500 after panic recovery, got %d", w.Code) - } -} - -func TestHealthz_StillWorksAfterMetricsPanic(t *testing.T) { - srv := server.New(server.Config{}, &panicStateProvider{}) - - // First: metrics panics - req1 := httptest.NewRequest("GET", "/metrics", nil) - w1 := httptest.NewRecorder() - srv.Handler().ServeHTTP(w1, req1) - - // Second: healthz should still work (server didn't crash) - // Note: healthz also calls GetState which will panic — but the point is - // the server process survives. Let's test with a provider that only panics on metrics. - srv2 := server.New(server.Config{}, &mockStateProvider{healthy: true}) - req2 := httptest.NewRequest("GET", "/healthz", nil) - w2 := httptest.NewRecorder() - srv2.Handler().ServeHTTP(w2, req2) - - if w2.Code != 200 { - t.Errorf("healthz should still work, got %d", w2.Code) - } -} diff --git a/internal/server/server.go b/internal/server/server.go deleted file mode 100644 index 9f4d7d0..0000000 --- a/internal/server/server.go +++ /dev/null @@ -1,241 +0,0 @@ -package server - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" - "time" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/shivamstaq/github-symphony/internal/orchestrator" -) - -// StateProvider is the interface the server needs from the orchestrator. -type StateProvider interface { - GetState() orchestrator.State - IsHealthy() bool - AuthMode() string - TriggerRefresh() - StartedAt() time.Time -} - -// Config for the HTTP server. -type Config struct { - Port int - Host string - ReadTimeoutMs int - WriteTimeoutMs int -} - -// Server is the HTTP API server. -type Server struct { - cfg Config - provider StateProvider - router chi.Router -} - -// New creates a new HTTP server. -func New(cfg Config, provider StateProvider) *Server { - s := &Server{ - cfg: cfg, - provider: provider, - } - s.buildRouter() - return s -} - -// Handler returns the HTTP handler for testing. -func (s *Server) Handler() http.Handler { - return s.router -} - -// ListenAndServe starts the HTTP server. -func (s *Server) ListenAndServe() error { - addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port) - srv := &http.Server{ - Addr: addr, - Handler: s.router, - ReadTimeout: time.Duration(s.cfg.ReadTimeoutMs) * time.Millisecond, - WriteTimeout: time.Duration(s.cfg.WriteTimeoutMs) * time.Millisecond, - } - return srv.ListenAndServe() -} - -func (s *Server) buildRouter() { - r := chi.NewRouter() - r.Use(middleware.Recoverer) - r.Use(middleware.RealIP) - - r.Get("/healthz", s.handleHealthz) - r.Get("/metrics", s.handleMetrics) - - r.Route("/api/v1", func(r chi.Router) { - r.Get("/state", s.handleState) - r.Get("/work-items/{id}", s.handleWorkItem) - r.Post("/refresh", s.handleRefresh) - }) - - s.router = r -} - -// MountWebhook adds a webhook handler at /api/v1/webhooks/github. -func (s *Server) MountWebhook(handler http.Handler) { - s.router.Post("/api/v1/webhooks/github", handler.ServeHTTP) -} - -func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) { - if !s.provider.IsHealthy() { - w.WriteHeader(503) - _ = json.NewEncoder(w).Encode(map[string]any{ - "status": "unhealthy", - }) - return - } - - state := s.provider.GetState() - uptime := time.Since(s.provider.StartedAt()).Seconds() - - resp := map[string]any{ - "status": "ok", - "uptime_seconds": int(uptime), - "auth_mode": s.provider.AuthMode(), - "running_count": len(state.Running), - } - if state.LastPollAt != nil { - resp["last_poll_at"] = state.LastPollAt.Format(time.RFC3339) - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) -} - -func (s *Server) handleState(w http.ResponseWriter, _ *http.Request) { - state := s.provider.GetState() - - running := make(map[string]any, len(state.Running)) - for id, entry := range state.Running { - running[id] = map[string]any{ - "work_item_id": entry.WorkItem.WorkItemID, - "title": entry.WorkItem.Title, - "repository": entry.Repository, - "started_at": entry.StartedAt.Format(time.RFC3339), - "input_tokens": entry.InputTokens, - "output_tokens": entry.OutputTokens, - } - } - - retrying := make(map[string]any, len(state.RetryAttempts)) - for id, entry := range state.RetryAttempts { - retrying[id] = map[string]any{ - "attempt": entry.Attempt, - "due_at": entry.DueAt.Format(time.RFC3339), - "error": entry.Error, - } - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "running": running, - "retrying": retrying, - "max_concurrent_agents": state.MaxConcurrentAgents, - "agent_totals": map[string]any{ - "input_tokens": state.AgentTotals.InputTokens, - "output_tokens": state.AgentTotals.OutputTokens, - "total_tokens": state.AgentTotals.TotalTokens, - "seconds_running": state.AgentTotals.SecondsRunning, - "github_writebacks": state.AgentTotals.GitHubWritebacks, - "sessions_started": state.AgentTotals.SessionsStarted, - }, - "pending_refresh": state.PendingRefresh, - }) -} - -func (s *Server) handleWorkItem(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - state := s.provider.GetState() - - if entry, ok := state.Running[id]; ok { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "work_item_id": entry.WorkItem.WorkItemID, - "title": entry.WorkItem.Title, - "state": "running", - "started_at": entry.StartedAt.Format(time.RFC3339), - }) - return - } - - w.WriteHeader(404) - _ = json.NewEncoder(w).Encode(map[string]any{"error": "work item not found"}) -} - -func (s *Server) handleRefresh(w http.ResponseWriter, _ *http.Request) { - s.provider.TriggerRefresh() - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) -} - -func (s *Server) handleMetrics(w http.ResponseWriter, _ *http.Request) { - state := s.provider.GetState() - w.Header().Set("Content-Type", "text/plain; version=0.0.4") - - var sb strings.Builder - - // Gauges - fmt.Fprintf(&sb, "# HELP symphony_active_runs Current running work items\n") - fmt.Fprintf(&sb, "# TYPE symphony_active_runs gauge\n") - fmt.Fprintf(&sb, "symphony_active_runs %d\n", len(state.Running)) - - fmt.Fprintf(&sb, "# HELP symphony_max_concurrent_agents Configured concurrency limit\n") - fmt.Fprintf(&sb, "# TYPE symphony_max_concurrent_agents gauge\n") - fmt.Fprintf(&sb, "symphony_max_concurrent_agents %d\n", state.MaxConcurrentAgents) - - fmt.Fprintf(&sb, "# HELP symphony_retry_queue_depth Current retry queue size\n") - fmt.Fprintf(&sb, "# TYPE symphony_retry_queue_depth gauge\n") - fmt.Fprintf(&sb, "symphony_retry_queue_depth %d\n", len(state.RetryAttempts)) - - // Counters - fmt.Fprintf(&sb, "# HELP symphony_tokens_total Cumulative token usage\n") - fmt.Fprintf(&sb, "# TYPE symphony_tokens_total counter\n") - fmt.Fprintf(&sb, "symphony_tokens_total{direction=\"input\"} %d\n", state.AgentTotals.InputTokens) - fmt.Fprintf(&sb, "symphony_tokens_total{direction=\"output\"} %d\n", state.AgentTotals.OutputTokens) - fmt.Fprintf(&sb, "symphony_tokens_total{direction=\"total\"} %d\n", state.AgentTotals.TotalTokens) - - fmt.Fprintf(&sb, "# HELP symphony_sessions_started_total Total sessions started\n") - fmt.Fprintf(&sb, "# TYPE symphony_sessions_started_total counter\n") - fmt.Fprintf(&sb, "symphony_sessions_started_total %d\n", state.AgentTotals.SessionsStarted) - - fmt.Fprintf(&sb, "# HELP symphony_github_writebacks_total Total write-back operations\n") - fmt.Fprintf(&sb, "# TYPE symphony_github_writebacks_total counter\n") - fmt.Fprintf(&sb, "symphony_github_writebacks_total %d\n", state.AgentTotals.GitHubWritebacks) - - // Dispatches total - fmt.Fprintf(&sb, "# HELP symphony_dispatches_total Total dispatches\n") - fmt.Fprintf(&sb, "# TYPE symphony_dispatches_total counter\n") - fmt.Fprintf(&sb, "symphony_dispatches_total %d\n", state.DispatchTotal) - - // Work item state distribution - fmt.Fprintf(&sb, "# HELP symphony_work_item_state Count of work items by orchestration state\n") - fmt.Fprintf(&sb, "# TYPE symphony_work_item_state gauge\n") - fmt.Fprintf(&sb, "symphony_work_item_state{state=\"running\"} %d\n", len(state.Running)) - fmt.Fprintf(&sb, "symphony_work_item_state{state=\"retry_queued\"} %d\n", len(state.RetryAttempts)) - handedOff := 0 - if state.HandedOff != nil { - handedOff = len(state.HandedOff) - } - fmt.Fprintf(&sb, "symphony_work_item_state{state=\"handed_off\"} %d\n", handedOff) - - // Errors total - fmt.Fprintf(&sb, "# HELP symphony_errors_total Total errors by category\n") - fmt.Fprintf(&sb, "# TYPE symphony_errors_total counter\n") - fmt.Fprintf(&sb, "symphony_errors_total %d\n", state.ErrorTotal) - - // PR handoffs total - fmt.Fprintf(&sb, "# HELP symphony_pr_handoffs_total Total PR handoffs\n") - fmt.Fprintf(&sb, "# TYPE symphony_pr_handoffs_total counter\n") - fmt.Fprintf(&sb, "symphony_pr_handoffs_total %d\n", state.HandoffTotal) - - _, _ = w.Write([]byte(sb.String())) -} diff --git a/internal/server/server_test.go b/internal/server/server_test.go deleted file mode 100644 index 4626ef6..0000000 --- a/internal/server/server_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package server_test - -import ( - "encoding/json" - "net/http/httptest" - "testing" - "time" - - "github.com/shivamstaq/github-symphony/internal/orchestrator" - "github.com/shivamstaq/github-symphony/internal/server" -) - -func TestHealthz_Healthy(t *testing.T) { - srv := server.New(server.Config{}, &mockStateProvider{healthy: true}) - req := httptest.NewRequest("GET", "/healthz", nil) - w := httptest.NewRecorder() - srv.Handler().ServeHTTP(w, req) - - if w.Code != 200 { - t.Errorf("expected 200, got %d", w.Code) - } - - var body map[string]any - json.NewDecoder(w.Body).Decode(&body) - if body["status"] != "ok" { - t.Errorf("expected status=ok, got %v", body["status"]) - } -} - -func TestHealthz_Unhealthy(t *testing.T) { - srv := server.New(server.Config{}, &mockStateProvider{healthy: false}) - req := httptest.NewRequest("GET", "/healthz", nil) - w := httptest.NewRecorder() - srv.Handler().ServeHTTP(w, req) - - if w.Code != 503 { - t.Errorf("expected 503, got %d", w.Code) - } -} - -func TestAPIState(t *testing.T) { - srv := server.New(server.Config{}, &mockStateProvider{ - healthy: true, - state: orchestrator.State{ - MaxConcurrentAgents: 10, - Running: map[string]*orchestrator.RunningEntry{ - "item1": { - WorkItem: orchestrator.WorkItem{WorkItemID: "item1", Title: "Fix bug"}, - StartedAt: time.Now(), - }, - }, - }, - }) - - req := httptest.NewRequest("GET", "/api/v1/state", nil) - w := httptest.NewRecorder() - srv.Handler().ServeHTTP(w, req) - - if w.Code != 200 { - t.Errorf("expected 200, got %d", w.Code) - } - - var body map[string]any - json.NewDecoder(w.Body).Decode(&body) - running, ok := body["running"].(map[string]any) - if !ok { - t.Fatalf("expected running map, got %T", body["running"]) - } - if len(running) != 1 { - t.Errorf("expected 1 running, got %d", len(running)) - } -} - -func TestMetrics(t *testing.T) { - srv := server.New(server.Config{}, &mockStateProvider{healthy: true}) - req := httptest.NewRequest("GET", "/metrics", nil) - w := httptest.NewRecorder() - srv.Handler().ServeHTTP(w, req) - - if w.Code != 200 { - t.Errorf("expected 200, got %d", w.Code) - } - - body := w.Body.String() - if len(body) == 0 { - t.Error("expected non-empty metrics body") - } - // Should contain at least one metric - if !containsStr(body, "symphony_") { - t.Error("expected metrics with symphony_ prefix") - } -} - -func TestRefresh(t *testing.T) { - provider := &mockStateProvider{healthy: true} - srv := server.New(server.Config{}, provider) - req := httptest.NewRequest("POST", "/api/v1/refresh", nil) - w := httptest.NewRecorder() - srv.Handler().ServeHTTP(w, req) - - if w.Code != 200 { - t.Errorf("expected 200, got %d", w.Code) - } - if !provider.refreshCalled { - t.Error("expected refresh to be triggered") - } -} - -type mockStateProvider struct { - healthy bool - state orchestrator.State - refreshCalled bool -} - -func (m *mockStateProvider) GetState() orchestrator.State { return m.state } -func (m *mockStateProvider) IsHealthy() bool { return m.healthy } -func (m *mockStateProvider) AuthMode() string { return "pat" } -func (m *mockStateProvider) TriggerRefresh() { m.refreshCalled = true } -func (m *mockStateProvider) StartedAt() time.Time { return time.Now().Add(-1 * time.Hour) } - -func containsStr(s, substr string) bool { - return len(s) > 0 && len(substr) > 0 && (s == substr || len(s) > len(substr) && findSubstr(s, substr)) -} - -func findSubstr(s, sub string) bool { - for i := 0; i <= len(s)-len(sub); i++ { - if s[i:i+len(sub)] == sub { - return true - } - } - return false -} diff --git a/internal/ssh/ssh.go b/internal/ssh/ssh.go deleted file mode 100644 index a7b16f3..0000000 --- a/internal/ssh/ssh.go +++ /dev/null @@ -1,37 +0,0 @@ -// Package ssh provides the optional SSH worker extension per spec Appendix A. -// -// This package is a stub. The SSH extension allows Symphony to dispatch -// worker runs to remote hosts over SSH while keeping the orchestrator -// as the single source of truth for polling, claims, and reconciliation. -// -// When fully implemented: -// - worker.ssh_hosts provides candidate SSH destinations -// - Each worker run is assigned to one host at a time -// - workspace.root is interpreted on the remote host -// - The coding-agent adapter is launched over SSH stdio -// - Continuation turns stay on the same host and workspace -package ssh - -// HostConfig describes an SSH host for remote worker dispatch. -type HostConfig struct { - Host string - Port int - User string - PrivateKey string - MaxWorkers int -} - -// Pool manages a set of SSH hosts for worker dispatch. -type Pool struct { - hosts []HostConfig -} - -// NewPool creates an SSH host pool. -func NewPool(hosts []HostConfig) *Pool { - return &Pool{hosts: hosts} -} - -// Available returns true if any host has capacity. -func (p *Pool) Available() bool { - return len(p.hosts) > 0 -} diff --git a/internal/tracker/contract_test.go b/internal/tracker/contract_test.go new file mode 100644 index 0000000..d878a99 --- /dev/null +++ b/internal/tracker/contract_test.go @@ -0,0 +1,129 @@ +package tracker_test + +import ( + "context" + "testing" + + "github.com/shivamstaq/github-symphony/internal/domain" + "github.com/shivamstaq/github-symphony/internal/tracker" + trackermock "github.com/shivamstaq/github-symphony/internal/tracker/mock" +) + +// contractSuite runs shared behavioral tests against any Tracker implementation. +// These tests verify the interface contract, not implementation details. +func contractSuite(t *testing.T, name string, tr tracker.Tracker) { + t.Run(name+"/FetchCandidates_returns_items", func(t *testing.T) { + items, err := tr.FetchCandidates(context.Background()) + if err != nil { + t.Fatalf("FetchCandidates error: %v", err) + } + if len(items) == 0 { + t.Skip("no items to test — tracker may not be configured") + } + // Every item must have a WorkItemID + for _, item := range items { + if item.WorkItemID == "" { + t.Error("FetchCandidates returned item with empty WorkItemID") + } + } + }) + + t.Run(name+"/FetchCandidates_normalized_fields", func(t *testing.T) { + items, err := tr.FetchCandidates(context.Background()) + if err != nil { + t.Fatalf("FetchCandidates error: %v", err) + } + for _, item := range items { + // Every item must have a title + if item.Title == "" { + t.Errorf("item %s has empty title", item.WorkItemID) + } + // State must be open or closed + if item.State != "" && item.State != "open" && item.State != "closed" { + t.Errorf("item %s has unexpected state %q (want open/closed)", item.WorkItemID, item.State) + } + // ProjectStatus must not be empty + if item.ProjectStatus == "" { + t.Errorf("item %s has empty ProjectStatus", item.WorkItemID) + } + } + }) + + t.Run(name+"/FetchStates_returns_matching_items", func(t *testing.T) { + items, err := tr.FetchCandidates(context.Background()) + if err != nil || len(items) == 0 { + t.Skip("no items available") + } + ids := []string{items[0].WorkItemID} + states, err := tr.FetchStates(context.Background(), ids) + if err != nil { + t.Fatalf("FetchStates error: %v", err) + } + if len(states) == 0 { + t.Error("FetchStates returned no items for known ID") + } + if len(states) > 0 && states[0].WorkItemID != ids[0] { + t.Errorf("FetchStates returned wrong item: got %q, want %q", states[0].WorkItemID, ids[0]) + } + }) + + t.Run(name+"/FetchStates_unknown_id_returns_empty", func(t *testing.T) { + states, err := tr.FetchStates(context.Background(), []string{"nonexistent-id-xyz"}) + if err != nil { + t.Fatalf("FetchStates error: %v", err) + } + if len(states) != 0 { + t.Errorf("FetchStates for unknown ID should return empty, got %d items", len(states)) + } + }) + + t.Run(name+"/ValidateConfig_returns_no_error", func(t *testing.T) { + _, err := tr.ValidateConfig(context.Background(), tracker.ValidationInput{ + ActiveValues: []string{"Todo"}, + TerminalValues: []string{"Done"}, + }) + if err != nil { + t.Fatalf("ValidateConfig error: %v", err) + } + }) +} + +// TestContract_MockTracker runs the contract suite against the mock tracker. +func TestContract_MockTracker(t *testing.T) { + issueNum := 1 + items := []domain.WorkItem{ + { + WorkItemID: "test-1", + ProjectItemID: "proj-1", + ContentType: "issue", + IssueNumber: &issueNum, + Title: "Test issue", + State: "open", + ProjectStatus: "Todo", + }, + { + WorkItemID: "test-2", + ProjectItemID: "proj-2", + ContentType: "issue", + IssueNumber: &issueNum, + Title: "Another issue", + State: "closed", + ProjectStatus: "Done", + }, + } + + tr := trackermock.New(items) + contractSuite(t, "mock", tr) +} + +// TestContract_LinearTracker would run against a real Linear instance. +// Skipped by default — requires LINEAR_API_KEY and LINEAR_TEAM_ID env vars. +// func TestContract_LinearTracker(t *testing.T) { +// apiKey := os.Getenv("LINEAR_API_KEY") +// teamID := os.Getenv("LINEAR_TEAM_ID") +// if apiKey == "" || teamID == "" { +// t.Skip("set LINEAR_API_KEY and LINEAR_TEAM_ID for Linear contract tests") +// } +// tr := linear.NewSource(apiKey, teamID) +// contractSuite(t, "linear", tr) +// } diff --git a/internal/tracker/factory.go b/internal/tracker/factory.go new file mode 100644 index 0000000..ea02780 --- /dev/null +++ b/internal/tracker/factory.go @@ -0,0 +1,28 @@ +package tracker + +import ( + "fmt" + + "github.com/shivamstaq/github-symphony/internal/config" +) + +// TrackerFactory creates a Tracker from config. +// Each tracker kind registers its constructor here. +type TrackerFactory func(cfg *config.SymphonyConfig) (Tracker, error) + +var factories = map[string]TrackerFactory{} + +// Register adds a tracker factory for a given kind. +func Register(kind string, factory TrackerFactory) { + factories[kind] = factory +} + +// NewTracker creates a Tracker based on the config's tracker.kind. +func NewTracker(cfg *config.SymphonyConfig) (Tracker, error) { + kind := cfg.Tracker.Kind + factory, ok := factories[kind] + if !ok { + return nil, fmt.Errorf("unknown tracker kind %q — supported: github, linear", kind) + } + return factory(cfg) +} diff --git a/internal/orchestrator/convert.go b/internal/tracker/github/normalize.go similarity index 50% rename from internal/orchestrator/convert.go rename to internal/tracker/github/normalize.go index a7ce8df..d8df4b1 100644 --- a/internal/orchestrator/convert.go +++ b/internal/tracker/github/normalize.go @@ -1,10 +1,14 @@ -package orchestrator +package github -import ghub "github.com/shivamstaq/github-symphony/internal/github" +import ( + "github.com/shivamstaq/github-symphony/internal/domain" + gh "github.com/shivamstaq/github-symphony/internal/github" +) -// ConvertNormalizedItem converts a github.NormalizedItem to an orchestrator.WorkItem. -func ConvertNormalizedItem(n ghub.NormalizedItem) WorkItem { - item := WorkItem{ +// ToDomainWorkItem converts a github.NormalizedItem to the canonical domain.WorkItem. +// The types are field-aligned by design, so this is a direct struct copy. +func ToDomainWorkItem(n gh.NormalizedItem) domain.WorkItem { + item := domain.WorkItem{ WorkItemID: n.WorkItemID, ProjectItemID: n.ProjectItemID, ContentType: n.ContentType, @@ -26,7 +30,7 @@ func ConvertNormalizedItem(n ghub.NormalizedItem) WorkItem { } if n.Repository != nil { - item.Repository = &Repository{ + item.Repository = &domain.Repository{ Owner: n.Repository.Owner, Name: n.Repository.Name, FullName: n.Repository.FullName, @@ -36,23 +40,30 @@ func ConvertNormalizedItem(n ghub.NormalizedItem) WorkItem { } for _, b := range n.BlockedBy { - item.BlockedBy = append(item.BlockedBy, BlockerRef{ID: b.ID, Identifier: b.Identifier, State: b.State}) + item.BlockedBy = append(item.BlockedBy, domain.BlockerRef{ + ID: b.ID, + Identifier: b.Identifier, + State: b.State, + }) } - for _, s := range n.SubIssues { - item.SubIssues = append(item.SubIssues, ChildRef{ID: s.ID, Identifier: s.Identifier, State: s.State}) + + for _, c := range n.SubIssues { + item.SubIssues = append(item.SubIssues, domain.ChildRef{ + ID: c.ID, + Identifier: c.Identifier, + State: c.State, + }) } + for _, p := range n.LinkedPRs { - item.LinkedPRs = append(item.LinkedPRs, PRRef{ID: p.ID, Number: p.Number, State: p.State, IsDraft: p.IsDraft, URL: p.URL}) + item.LinkedPRs = append(item.LinkedPRs, domain.PRRef{ + ID: p.ID, + Number: p.Number, + State: p.State, + IsDraft: p.IsDraft, + URL: p.URL, + }) } return item } - -// ConvertNormalizedItems converts a slice. -func ConvertNormalizedItems(items []ghub.NormalizedItem) []WorkItem { - result := make([]WorkItem, len(items)) - for i, n := range items { - result[i] = ConvertNormalizedItem(n) - } - return result -} diff --git a/internal/tracker/github/register.go b/internal/tracker/github/register.go new file mode 100644 index 0000000..cdbb678 --- /dev/null +++ b/internal/tracker/github/register.go @@ -0,0 +1,43 @@ +package github + +import ( + "fmt" + + "github.com/shivamstaq/github-symphony/internal/config" + gh "github.com/shivamstaq/github-symphony/internal/github" + "github.com/shivamstaq/github-symphony/internal/tracker" +) + +func init() { + tracker.Register("github", func(cfg *config.SymphonyConfig) (tracker.Tracker, error) { + token := cfg.Auth.GitHub.Token + if token == "" { + return nil, fmt.Errorf("github token required: set auth.github.token or $GITHUB_TOKEN") + } + + apiURL := cfg.Auth.GitHub.APIURL + if apiURL == "" { + apiURL = "https://api.github.com" + } + graphqlEndpoint := apiURL + "/graphql" + + client := gh.NewGraphQLClient(graphqlEndpoint, token) + + pageSize := 100 + ghSource := gh.NewSource(client, gh.SourceConfig{ + Owner: cfg.Tracker.Owner, + ProjectNumber: cfg.Tracker.ProjectNumber, + ProjectScope: cfg.Tracker.ProjectScope, + StatusFieldName: cfg.Tracker.StatusFieldName, + PageSize: pageSize, + PriorityValueMap: cfg.Tracker.PriorityValueMap, + }) + + return NewSource(client, ghSource, cfg.Tracker.PriorityValueMap, SourceConfig{ + Owner: cfg.Tracker.Owner, + ProjectNumber: cfg.Tracker.ProjectNumber, + ProjectScope: cfg.Tracker.ProjectScope, + StatusFieldName: cfg.Tracker.StatusFieldName, + }), nil + }) +} diff --git a/internal/tracker/github/source.go b/internal/tracker/github/source.go new file mode 100644 index 0000000..659def5 --- /dev/null +++ b/internal/tracker/github/source.go @@ -0,0 +1,123 @@ +// Package github implements tracker.Tracker for GitHub Projects V2. +package github + +import ( + "context" + "fmt" + "log/slog" + + "github.com/shivamstaq/github-symphony/internal/domain" + gh "github.com/shivamstaq/github-symphony/internal/github" + "github.com/shivamstaq/github-symphony/internal/tracker" +) + +// Source implements tracker.Tracker by wrapping github.Source. +type Source struct { + ghSource *gh.Source + ghClient *gh.GraphQLClient + priorityMap map[string]int + cfg SourceConfig +} + +// SourceConfig holds config needed by the GitHub tracker adapter. +type SourceConfig struct { + Owner string + ProjectNumber int + ProjectScope string + StatusFieldName string +} + +// NewSource creates a GitHub tracker adapter. +func NewSource(client *gh.GraphQLClient, ghSource *gh.Source, priorityMap map[string]int, cfg SourceConfig) *Source { + return &Source{ + ghSource: ghSource, + ghClient: client, + priorityMap: priorityMap, + cfg: cfg, + } +} + +func (s *Source) FetchCandidates(ctx context.Context) ([]domain.WorkItem, error) { + rawItems, err := s.ghSource.FetchCandidateRaw(ctx) + if err != nil { + return nil, fmt.Errorf("github tracker: fetch candidates: %w", err) + } + + items := make([]domain.WorkItem, 0, len(rawItems)) + for _, raw := range rawItems { + normalized := gh.NormalizeWorkItem(raw, s.priorityMap) + items = append(items, ToDomainWorkItem(normalized)) + } + + slog.Debug("github tracker: fetched candidates", "count", len(items)) + return items, nil +} + +func (s *Source) FetchStates(ctx context.Context, ids []string) ([]domain.WorkItem, error) { + // Fetch all items and filter to matching IDs. + // GitHub API doesn't support fetching by arbitrary IDs directly — + // we refetch the project and filter. + rawItems, err := s.ghSource.FetchStateRaw(ctx) + if err != nil { + return nil, fmt.Errorf("github tracker: fetch states: %w", err) + } + + idSet := make(map[string]bool, len(ids)) + for _, id := range ids { + idSet[id] = true + } + + var items []domain.WorkItem + for _, raw := range rawItems { + normalized := gh.NormalizeWorkItem(raw, s.priorityMap) + domainItem := ToDomainWorkItem(normalized) + if idSet[domainItem.WorkItemID] { + items = append(items, domainItem) + } + } + + return items, nil +} + +func (s *Source) ValidateConfig(ctx context.Context, input tracker.ValidationInput) ([]tracker.ValidationProblem, error) { + // Fetch project field metadata to verify the status field exists + fieldName := input.StatusFieldName + if fieldName == "" { + fieldName = "Status" + } + meta, err := s.ghClient.FetchProjectFieldMeta(ctx, s.cfg.Owner, s.cfg.ProjectNumber, s.cfg.ProjectScope, fieldName) + if err != nil { + return nil, fmt.Errorf("github tracker: validate config: %w", err) + } + + var problems []tracker.ValidationProblem + + // Check that configured status values exist as options + for _, v := range input.ActiveValues { + if _, ok := meta.Options[v]; !ok { + problems = append(problems, tracker.ValidationProblem{ + Kind: tracker.ProblemMissingStatus, + Name: v, + CanFix: false, + }) + } + } + for _, v := range input.TerminalValues { + if _, ok := meta.Options[v]; !ok { + problems = append(problems, tracker.ValidationProblem{ + Kind: tracker.ProblemMissingStatus, + Name: v, + CanFix: false, + }) + } + } + + return problems, nil +} + +func (s *Source) CreateMissingFields(_ context.Context, _ []tracker.ValidationProblem) error { + return fmt.Errorf("github does not support auto-creating project field options — configure them in the GitHub Project settings UI") +} + +// Compile-time interface check. +var _ tracker.Tracker = (*Source)(nil) diff --git a/internal/tracker/linear/client.go b/internal/tracker/linear/client.go new file mode 100644 index 0000000..ffedbea --- /dev/null +++ b/internal/tracker/linear/client.go @@ -0,0 +1,91 @@ +// Package linear implements the tracker.Tracker interface for Linear. +package linear + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const apiURL = "https://api.linear.app/graphql" + +// Client is a Linear GraphQL API client. +type Client struct { + apiKey string + httpClient *http.Client +} + +// NewClient creates a Linear API client. +func NewClient(apiKey string) *Client { + return &Client{ + apiKey: apiKey, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// graphqlRequest is the JSON body for a GraphQL request. +type graphqlRequest struct { + Query string `json:"query"` + Variables map[string]any `json:"variables,omitempty"` +} + +// graphqlResponse is the JSON response from a GraphQL request. +type graphqlResponse struct { + Data json.RawMessage `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +// Query executes a GraphQL query and unmarshals the data field into target. +func (c *Client) Query(ctx context.Context, query string, vars map[string]any, target any) error { + body, err := json.Marshal(graphqlRequest{Query: query, Variables: vars}) + if err != nil { + return fmt.Errorf("marshal query: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", c.apiKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("linear API request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != 200 { + return fmt.Errorf("linear API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var gqlResp graphqlResponse + if err := json.Unmarshal(respBody, &gqlResp); err != nil { + return fmt.Errorf("unmarshal response: %w", err) + } + + if len(gqlResp.Errors) > 0 { + return fmt.Errorf("linear GraphQL error: %s", gqlResp.Errors[0].Message) + } + + if target != nil { + if err := json.Unmarshal(gqlResp.Data, target); err != nil { + return fmt.Errorf("unmarshal data: %w", err) + } + } + + return nil +} diff --git a/internal/tracker/linear/models.go b/internal/tracker/linear/models.go new file mode 100644 index 0000000..10e3419 --- /dev/null +++ b/internal/tracker/linear/models.go @@ -0,0 +1,97 @@ +package linear + +// Linear API response models. + +// IssueNode is a single issue from the Linear API. +type IssueNode struct { + ID string `json:"id"` + Identifier string `json:"identifier"` // e.g., "ENG-123" + Title string `json:"title"` + Description string `json:"description"` + Priority int `json:"priority"` // 0=none, 1=urgent, 2=high, 3=medium, 4=low + State StateNode `json:"state"` + Labels LabelsConn `json:"labels"` + Assignee *AssigneeNode `json:"assignee"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + URL string `json:"url"` + Team TeamNode `json:"team"` + Project *ProjectNode `json:"project"` + Relations RelationsConn `json:"relations"` + BranchName string `json:"branchName"` +} + +type StateNode struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` // "backlog", "unstarted", "started", "completed", "cancelled" +} + +type LabelsConn struct { + Nodes []LabelNode `json:"nodes"` +} + +type LabelNode struct { + Name string `json:"name"` +} + +type AssigneeNode struct { + Name string `json:"name"` +} + +type TeamNode struct { + ID string `json:"id"` + Key string `json:"key"` // e.g., "ENG" + Name string `json:"name"` +} + +type ProjectNode struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type RelationsConn struct { + Nodes []RelationNode `json:"nodes"` +} + +type RelationNode struct { + Type string `json:"type"` // "blocks", "blocked_by", "related", "duplicate" + RelatedIssue IssueRef `json:"relatedIssue"` +} + +type IssueRef struct { + ID string `json:"id"` + Identifier string `json:"identifier"` + State StateNode `json:"state"` +} + +// IssuesResponse is the top-level response for team issues queries. +type IssuesResponse struct { + Team struct { + Issues struct { + Nodes []IssueNode `json:"nodes"` + PageInfo PageInfo `json:"pageInfo"` + } `json:"issues"` + } `json:"team"` +} + +// IssuesByIDsResponse is the response for fetching specific issues by ID. +type IssuesByIDsResponse struct { + Issues struct { + Nodes []IssueNode `json:"nodes"` + } `json:"issues"` +} + +type PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` +} + +// WorkflowStatesResponse is the response for fetching team workflow states. +type WorkflowStatesResponse struct { + Team struct { + States struct { + Nodes []StateNode `json:"nodes"` + } `json:"states"` + } `json:"team"` +} diff --git a/internal/tracker/linear/normalize.go b/internal/tracker/linear/normalize.go new file mode 100644 index 0000000..aad2cbd --- /dev/null +++ b/internal/tracker/linear/normalize.go @@ -0,0 +1,72 @@ +package linear + +import ( + "github.com/shivamstaq/github-symphony/internal/domain" +) + +// Normalize converts a Linear IssueNode to the canonical domain.WorkItem. +func Normalize(issue IssueNode) domain.WorkItem { + labels := make([]string, len(issue.Labels.Nodes)) + for i, l := range issue.Labels.Nodes { + labels[i] = l.Name + } + + var assignees []string + if issue.Assignee != nil { + assignees = []string{issue.Assignee.Name} + } + + // Map Linear priority (1=urgent..4=low) to our model + var priority *int + if issue.Priority > 0 { + p := issue.Priority + priority = &p + } + + // Map Linear state type to open/closed + issueState := "open" + if issue.State.Type == "completed" || issue.State.Type == "cancelled" { + issueState = "closed" + } + + // Build blockers from relations + var blockedBy []domain.BlockerRef + for _, rel := range issue.Relations.Nodes { + if rel.Type == "blocked_by" { + blockedBy = append(blockedBy, domain.BlockerRef{ + ID: rel.RelatedIssue.ID, + Identifier: rel.RelatedIssue.Identifier, + State: rel.RelatedIssue.State.Name, + }) + } + } + + item := domain.WorkItem{ + WorkItemID: "linear:" + issue.ID, + ProjectItemID: issue.ID, + ContentType: "issue", + IssueID: issue.ID, + IssueIdentifier: issue.Identifier, + Title: issue.Title, + Description: issue.Description, + State: issueState, + ProjectStatus: issue.State.Name, + Priority: priority, + Labels: labels, + Assignees: assignees, + BlockedBy: blockedBy, + URL: issue.URL, + CreatedAt: issue.CreatedAt, + UpdatedAt: issue.UpdatedAt, + ProjectFields: map[string]any{ + "team": issue.Team.Key, + "state_type": issue.State.Type, + }, + } + + if issue.Project != nil { + item.ProjectFields["project"] = issue.Project.Name + } + + return item +} diff --git a/internal/tracker/linear/normalize_test.go b/internal/tracker/linear/normalize_test.go new file mode 100644 index 0000000..c9dfe47 --- /dev/null +++ b/internal/tracker/linear/normalize_test.go @@ -0,0 +1,124 @@ +package linear + +import ( + "testing" +) + +func TestNormalize_BasicIssue(t *testing.T) { + issue := IssueNode{ + ID: "abc-123", + Identifier: "ENG-42", + Title: "Fix login bug", + Description: "Login fails on mobile", + Priority: 2, + State: StateNode{Name: "In Progress", Type: "started"}, + Labels: LabelsConn{Nodes: []LabelNode{{Name: "bug"}, {Name: "urgent"}}}, + Assignee: &AssigneeNode{Name: "Alice"}, + CreatedAt: "2026-03-15T10:00:00Z", + URL: "https://linear.app/team/ENG-42", + Team: TeamNode{Key: "ENG", Name: "Engineering"}, + } + + item := Normalize(issue) + + if item.WorkItemID != "linear:abc-123" { + t.Errorf("WorkItemID = %q, want 'linear:abc-123'", item.WorkItemID) + } + if item.IssueIdentifier != "ENG-42" { + t.Errorf("IssueIdentifier = %q, want 'ENG-42'", item.IssueIdentifier) + } + if item.Title != "Fix login bug" { + t.Errorf("Title = %q", item.Title) + } + if item.State != "open" { + t.Errorf("State = %q, want 'open' for started type", item.State) + } + if item.ProjectStatus != "In Progress" { + t.Errorf("ProjectStatus = %q", item.ProjectStatus) + } + if item.Priority == nil || *item.Priority != 2 { + t.Errorf("Priority = %v, want 2", item.Priority) + } + if len(item.Labels) != 2 || item.Labels[0] != "bug" { + t.Errorf("Labels = %v", item.Labels) + } + if len(item.Assignees) != 1 || item.Assignees[0] != "Alice" { + t.Errorf("Assignees = %v", item.Assignees) + } +} + +func TestNormalize_CompletedIssueIsClosed(t *testing.T) { + issue := IssueNode{ + ID: "def-456", + Identifier: "ENG-99", + Title: "Done issue", + State: StateNode{Name: "Done", Type: "completed"}, + Team: TeamNode{Key: "ENG"}, + } + + item := Normalize(issue) + if item.State != "closed" { + t.Errorf("completed issue State = %q, want 'closed'", item.State) + } +} + +func TestNormalize_CancelledIssueIsClosed(t *testing.T) { + issue := IssueNode{ + ID: "ghi-789", + Identifier: "ENG-100", + Title: "Cancelled", + State: StateNode{Name: "Cancelled", Type: "cancelled"}, + Team: TeamNode{Key: "ENG"}, + } + + item := Normalize(issue) + if item.State != "closed" { + t.Errorf("cancelled issue State = %q, want 'closed'", item.State) + } +} + +func TestNormalize_BlockedByRelation(t *testing.T) { + issue := IssueNode{ + ID: "xyz-1", + Identifier: "ENG-10", + Title: "Blocked task", + State: StateNode{Name: "Todo", Type: "unstarted"}, + Team: TeamNode{Key: "ENG"}, + Relations: RelationsConn{ + Nodes: []RelationNode{ + { + Type: "blocked_by", + RelatedIssue: IssueRef{ + ID: "xyz-2", + Identifier: "ENG-5", + State: StateNode{Name: "In Progress"}, + }, + }, + }, + }, + } + + item := Normalize(issue) + if len(item.BlockedBy) != 1 { + t.Fatalf("expected 1 blocker, got %d", len(item.BlockedBy)) + } + if item.BlockedBy[0].Identifier != "ENG-5" { + t.Errorf("blocker identifier = %q", item.BlockedBy[0].Identifier) + } +} + +func TestNormalize_NoPriorityIsNil(t *testing.T) { + issue := IssueNode{ + ID: "nop-1", + Identifier: "ENG-200", + Title: "No priority", + Priority: 0, + State: StateNode{Name: "Todo", Type: "unstarted"}, + Team: TeamNode{Key: "ENG"}, + } + + item := Normalize(issue) + if item.Priority != nil { + t.Errorf("zero priority should normalize to nil, got %v", *item.Priority) + } +} diff --git a/internal/tracker/linear/register.go b/internal/tracker/linear/register.go new file mode 100644 index 0000000..7a124b2 --- /dev/null +++ b/internal/tracker/linear/register.go @@ -0,0 +1,25 @@ +package linear + +import ( + "fmt" + + "github.com/shivamstaq/github-symphony/internal/config" + "github.com/shivamstaq/github-symphony/internal/tracker" +) + +func init() { + tracker.Register("linear", func(cfg *config.SymphonyConfig) (tracker.Tracker, error) { + apiKey := cfg.Auth.Linear.APIKey + if apiKey == "" { + apiKey = cfg.Tracker.LinearAPIKey + } + if apiKey == "" { + return nil, fmt.Errorf("linear API key required: set auth.linear.api_key or tracker.linear_api_key") + } + teamID := cfg.Tracker.LinearTeamID + if teamID == "" { + return nil, fmt.Errorf("linear team ID required: set tracker.linear_team_id") + } + return NewSource(apiKey, teamID), nil + }) +} diff --git a/internal/tracker/linear/source.go b/internal/tracker/linear/source.go new file mode 100644 index 0000000..326516b --- /dev/null +++ b/internal/tracker/linear/source.go @@ -0,0 +1,197 @@ +package linear + +import ( + "context" + "fmt" + + "github.com/shivamstaq/github-symphony/internal/domain" + "github.com/shivamstaq/github-symphony/internal/tracker" +) + +// Source implements tracker.Tracker for Linear. +type Source struct { + client *Client + teamID string +} + +// NewSource creates a Linear tracker source. +func NewSource(apiKey, teamID string) *Source { + return &Source{ + client: NewClient(apiKey), + teamID: teamID, + } +} + +const issuesQuery = ` +query TeamIssues($teamId: String!, $after: String) { + team(id: $teamId) { + issues(first: 50, after: $after, orderBy: updatedAt) { + nodes { + id + identifier + title + description + priority + url + createdAt + updatedAt + branchName + state { id name type } + labels { nodes { name } } + assignee { name } + team { id key name } + project { id name } + relations { + nodes { + type + relatedIssue { + id + identifier + state { id name type } + } + } + } + } + pageInfo { hasNextPage endCursor } + } + } +} +` + +func (s *Source) FetchCandidates(ctx context.Context) ([]domain.WorkItem, error) { + var allItems []domain.WorkItem + var cursor string + + for { + vars := map[string]any{"teamId": s.teamID} + if cursor != "" { + vars["after"] = cursor + } + + var resp IssuesResponse + if err := s.client.Query(ctx, issuesQuery, vars, &resp); err != nil { + return nil, fmt.Errorf("fetch linear issues: %w", err) + } + + for _, node := range resp.Team.Issues.Nodes { + allItems = append(allItems, Normalize(node)) + } + + if !resp.Team.Issues.PageInfo.HasNextPage { + break + } + cursor = resp.Team.Issues.PageInfo.EndCursor + } + + return allItems, nil +} + +const issuesByIDsQuery = ` +query IssuesByIds($ids: [String!]!) { + issues(filter: { id: { in: $ids } }) { + nodes { + id + identifier + title + description + priority + url + createdAt + updatedAt + branchName + state { id name type } + labels { nodes { name } } + assignee { name } + team { id key name } + project { id name } + relations { + nodes { + type + relatedIssue { + id + identifier + state { id name type } + } + } + } + } + } +} +` + +func (s *Source) FetchStates(ctx context.Context, ids []string) ([]domain.WorkItem, error) { + // Extract Linear IDs from composite IDs (strip "linear:" prefix) + linearIDs := make([]string, 0, len(ids)) + for _, id := range ids { + if len(id) > 7 && id[:7] == "linear:" { + linearIDs = append(linearIDs, id[7:]) + } else { + linearIDs = append(linearIDs, id) + } + } + + vars := map[string]any{"ids": linearIDs} + var resp IssuesByIDsResponse + if err := s.client.Query(ctx, issuesByIDsQuery, vars, &resp); err != nil { + return nil, fmt.Errorf("fetch linear issue states: %w", err) + } + + items := make([]domain.WorkItem, len(resp.Issues.Nodes)) + for i, node := range resp.Issues.Nodes { + items[i] = Normalize(node) + } + return items, nil +} + +func (s *Source) ValidateConfig(ctx context.Context, input tracker.ValidationInput) ([]tracker.ValidationProblem, error) { + // Fetch team workflow states + var resp WorkflowStatesResponse + statesQuery := `query TeamStates($teamId: String!) { + team(id: $teamId) { + states { nodes { id name type } } + } + }` + + if err := s.client.Query(ctx, statesQuery, map[string]any{"teamId": s.teamID}, &resp); err != nil { + return nil, fmt.Errorf("fetch workflow states: %w", err) + } + + // Build set of existing state names + existing := make(map[string]bool) + for _, state := range resp.Team.States.Nodes { + existing[state.Name] = true + } + + var problems []tracker.ValidationProblem + + // Check active values + for _, v := range input.ActiveValues { + if !existing[v] { + problems = append(problems, tracker.ValidationProblem{ + Kind: tracker.ProblemMissingStatus, + Name: v, + CanFix: false, // Linear states are workflow-defined, can't auto-create + }) + } + } + + // Check terminal values + for _, v := range input.TerminalValues { + if !existing[v] { + problems = append(problems, tracker.ValidationProblem{ + Kind: tracker.ProblemMissingStatus, + Name: v, + CanFix: false, + }) + } + } + + return problems, nil +} + +func (s *Source) CreateMissingFields(_ context.Context, _ []tracker.ValidationProblem) error { + return fmt.Errorf("linear does not support auto-creating workflow states — configure them in Linear settings") +} + +// Compile-time interface check. +var _ tracker.Tracker = (*Source)(nil) diff --git a/internal/tracker/mock/mock.go b/internal/tracker/mock/mock.go new file mode 100644 index 0000000..e4e38b7 --- /dev/null +++ b/internal/tracker/mock/mock.go @@ -0,0 +1,89 @@ +// Package mock provides a configurable mock tracker for testing. +package mock + +import ( + "context" + "sync" + + "github.com/shivamstaq/github-symphony/internal/domain" + "github.com/shivamstaq/github-symphony/internal/tracker" +) + +// Tracker is a configurable mock that stores items in memory. +type Tracker struct { + mu sync.Mutex + items []domain.WorkItem + + // Tracking calls for assertions + FetchCandidatesCalls int + FetchStatesCalls int + ValidateCalls int + StatusUpdates []StatusUpdate +} + +// StatusUpdate records a status change made via the tracker. +type StatusUpdate struct { + ItemID string + NewStatus string +} + +// New creates a mock tracker with the given initial items. +func New(items []domain.WorkItem) *Tracker { + return &Tracker{items: items} +} + +func (t *Tracker) FetchCandidates(_ context.Context) ([]domain.WorkItem, error) { + t.mu.Lock() + defer t.mu.Unlock() + t.FetchCandidatesCalls++ + cp := make([]domain.WorkItem, len(t.items)) + copy(cp, t.items) + return cp, nil +} + +func (t *Tracker) FetchStates(_ context.Context, ids []string) ([]domain.WorkItem, error) { + t.mu.Lock() + defer t.mu.Unlock() + t.FetchStatesCalls++ + + idSet := make(map[string]bool, len(ids)) + for _, id := range ids { + idSet[id] = true + } + + var result []domain.WorkItem + for _, item := range t.items { + if idSet[item.WorkItemID] { + result = append(result, item) + } + } + return result, nil +} + +func (t *Tracker) ValidateConfig(_ context.Context, _ tracker.ValidationInput) ([]tracker.ValidationProblem, error) { + t.mu.Lock() + defer t.mu.Unlock() + t.ValidateCalls++ + return nil, nil +} + +func (t *Tracker) CreateMissingFields(_ context.Context, _ []tracker.ValidationProblem) error { + return nil +} + +// SetItems replaces the item list (for simulating state changes). +func (t *Tracker) SetItems(items []domain.WorkItem) { + t.mu.Lock() + defer t.mu.Unlock() + t.items = items +} + +// AddItem adds a single item. +func (t *Tracker) AddItem(item domain.WorkItem) { + t.mu.Lock() + defer t.mu.Unlock() + t.items = append(t.items, item) +} + +// Compile-time check. +var _ tracker.Tracker = (*Tracker)(nil) diff --git a/internal/tracker/tracker.go b/internal/tracker/tracker.go index 54feb54..00891c4 100644 --- a/internal/tracker/tracker.go +++ b/internal/tracker/tracker.go @@ -1,94 +1,57 @@ -// Package tracker defines the abstract interfaces for work item tracking systems. +// Package tracker defines the abstract interface for work item tracking systems. // -// Symphony supports multiple tracker backends (GitHub Projects, Linear, Jira, GitLab) -// through these interfaces. Each backend implements the three core contracts: +// Symphony supports multiple tracker backends (GitHub Projects, Linear) through +// this interface. Each backend implements Tracker and converts its native format +// to the shared domain.WorkItem model. // -// - WorkItemSource: fetch eligible work items and refresh their states -// - WriteBackService: create PRs, post comments, update status fields -// - TrackerAuth: provide authentication tokens for the tracker -// -// The orchestrator only interacts with these interfaces, never with -// tracker-specific types. Each backend converts its native format to the -// shared WorkItem domain model. -// -// Currently implemented: GitHub Projects (internal/github/) -// Planned: Linear, Jira, GitLab +// Currently implemented: GitHub Projects (tracker/github/) +// Planned: Linear (tracker/linear/) package tracker -import "context" +import ( + "context" -// WorkItemSource fetches work items from a project tracker. -// This interface is the primary input to the orchestrator's poll loop. -// -// The WorkItem type used by implementations is orchestrator.WorkItem. -// Each tracker backend normalizes its native types into that struct. -// This interface is defined identically in orchestrator.WorkItemSource — -// tracker backends implement orchestrator.WorkItemSource directly. -// -// type WorkItemSource interface { -// FetchCandidates(ctx context.Context) ([]orchestrator.WorkItem, error) -// FetchStates(ctx context.Context, workItemIDs []string) ([]orchestrator.WorkItem, error) -// } + "github.com/shivamstaq/github-symphony/internal/domain" +) -// WriteBackService performs write-back operations after agent work completes. -// Each tracker backend maps these operations to its native API. -type WriteBackService interface { - // CreateReviewArtifact pushes a branch and creates/updates a review artifact (PR, MR, etc.). - // For trackers without code review (Jira), this may just update the ticket status. - CreateReviewArtifact(ctx context.Context, params ReviewArtifactParams) (*ReviewArtifactResult, error) +// Tracker is the unified interface for issue/project tracking systems. +// Implementations: tracker/github, tracker/linear +type Tracker interface { + // FetchCandidates returns all work items eligible for dispatch. + FetchCandidates(ctx context.Context) ([]domain.WorkItem, error) - // CommentOnItem posts a comment or update on the work item. - CommentOnItem(ctx context.Context, owner, repo string, itemNumber int, body string) (string, error) + // FetchStates returns current state for specific work item IDs. + FetchStates(ctx context.Context, ids []string) ([]domain.WorkItem, error) - // MoveToStatus transitions the work item to a new status in the project. - // This is used for handoff (e.g., "Todo" → "Human Review"). - MoveToStatus(ctx context.Context, projectID, itemID, fieldID, optionID string) error -} + // ValidateConfig checks that configured fields, statuses, and labels + // exist on the remote tracker. Returns a list of problems found. + ValidateConfig(ctx context.Context, input ValidationInput) ([]ValidationProblem, error) -// ReviewArtifactParams for creating a review artifact (PR/MR). -type ReviewArtifactParams struct { - Owner string - Repo string - Title string - Body string - HeadBranch string - BaseBranch string - Draft bool + // CreateMissingFields creates fields/statuses/labels that don't exist. + CreateMissingFields(ctx context.Context, problems []ValidationProblem) error } -// ReviewArtifactResult from a review artifact creation. -type ReviewArtifactResult struct { - Number int - URL string - State string - IsDraft bool - Created bool // true if newly created, false if existing was updated +// ValidationInput contains the fields to validate against the remote tracker. +type ValidationInput struct { + StatusFieldName string + ActiveValues []string + TerminalValues []string + RequiredLabels []string + CustomFields []string } -// TrackerAuth provides authentication for tracker API operations. -type TrackerAuth interface { - // Token returns a valid authentication token. - Token(ctx context.Context) (string, error) - - // Mode returns the authentication mode identifier (e.g., "pat", "app", "oauth"). - Mode() string +// ValidationProblem describes a single config/tracker mismatch. +type ValidationProblem struct { + Kind ProblemKind + Name string + CanFix bool } -// WorkItem is the normalized domain model shared across all tracker backends. -// Each backend converts its native types into this struct. -// This is the same as orchestrator.WorkItem — aliased here to define -// the canonical location for the domain model. -// -// Note: Currently orchestrator.WorkItem is the authoritative type. -// This package documents the contract that all tracker backends must produce. -// A future refactoring may move WorkItem here and have orchestrator import it. - -// TrackerKind identifies which tracker backend to use. -type TrackerKind string +// ProblemKind classifies a validation problem. +type ProblemKind string const ( - TrackerGitHub TrackerKind = "github" - TrackerLinear TrackerKind = "linear" - TrackerJira TrackerKind = "jira" - TrackerGitLab TrackerKind = "gitlab" + ProblemMissingStatus ProblemKind = "missing_status" + ProblemMissingLabel ProblemKind = "missing_label" + ProblemMissingField ProblemKind = "missing_field" ) diff --git a/internal/tui/tui.go b/internal/tui/tui.go deleted file mode 100644 index bc17238..0000000 --- a/internal/tui/tui.go +++ /dev/null @@ -1,519 +0,0 @@ -package tui - -import ( - "fmt" - "strings" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/shivamstaq/github-symphony/internal/orchestrator" -) - -// ViewMode tracks which screen the TUI is showing. -type ViewMode int - -const ( - ViewOverview ViewMode = iota - ViewDetail -) - -// FocusPanel tracks which panel has keyboard focus in overview. -type FocusPanel int - -const ( - FocusAgents FocusPanel = iota - FocusEvents -) - -// StateProvider gives the TUI access to orchestrator state. -type StateProvider interface { - GetState() orchestrator.State -} - -// Config for the TUI. -type Config struct { - StateProvider StateProvider - EventBus *orchestrator.EventBus - StartedAt time.Time -} - -type tickMsg time.Time -type eventMsg orchestrator.Event - -// Model is the Bubble Tea model for the Symphony TUI. -type Model struct { - cfg Config - state orchestrator.State - events []orchestrator.Event - eventSub chan orchestrator.Event - width int - height int - quitting bool - view ViewMode - focus FocusPanel - selectedAgent int // index into sorted agent list - agentIDs []string // sorted keys of Running map - selectedDetail string // work item ID of the detailed agent - detailEvents []orchestrator.Event - eventsScroll int // scroll offset for events panel -} - -// New creates a new TUI model. -func New(cfg Config) Model { - return Model{ - cfg: cfg, - eventSub: cfg.EventBus.Subscribe(), - } -} - -func (m Model) Init() tea.Cmd { - return tea.Batch(tickCmd(), waitForEvent(m.eventSub)) -} - -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - return m.handleKey(msg) - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - case tickMsg: - m.state = m.cfg.StateProvider.GetState() - m.updateAgentIDs() - return m, tickCmd() - case eventMsg: - e := orchestrator.Event(msg) - m.events = append(m.events, e) - // Auto-scroll to bottom if user hasn't scrolled up - maxScroll := max(0, len(m.events)-m.eventsViewHeight()) - if m.eventsScroll >= maxScroll-1 { - m.eventsScroll = max(0, len(m.events)-m.eventsViewHeight()) - } - return m, waitForEvent(m.eventSub) - } - return m, nil -} - -func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "ctrl+c": - m.quitting = true - return m, tea.Quit - - case "tab": - if m.view == ViewOverview { - if m.focus == FocusAgents { - m.focus = FocusEvents - } else { - m.focus = FocusAgents - } - } - - case "up", "k": - if m.view == ViewDetail { - // Scroll detail events up - if m.eventsScroll > 0 { - m.eventsScroll-- - } - } else if m.focus == FocusAgents { - if m.selectedAgent > 0 { - m.selectedAgent-- - } - } else { - if m.eventsScroll > 0 { - m.eventsScroll-- - } - } - - case "down", "j": - if m.view == ViewDetail { - m.eventsScroll++ - } else if m.focus == FocusAgents { - if m.selectedAgent < len(m.agentIDs)-1 { - m.selectedAgent++ - } - } else { - maxScroll := max(0, len(m.events)-m.eventsViewHeight()) - if m.eventsScroll < maxScroll { - m.eventsScroll++ - } - } - - case "enter": - if m.view == ViewOverview && m.focus == FocusAgents && len(m.agentIDs) > 0 { - m.view = ViewDetail - m.selectedDetail = m.agentIDs[m.selectedAgent] - m.detailEvents = m.filterEventsForAgent(m.selectedDetail) - m.eventsScroll = max(0, len(m.detailEvents)-m.detailEventsHeight()) - } - - case "esc": - if m.view == ViewDetail { - m.view = ViewOverview - m.eventsScroll = max(0, len(m.events)-m.eventsViewHeight()) - } - - case "pgup": - if m.focus == FocusEvents || m.view == ViewDetail { - m.eventsScroll = max(0, m.eventsScroll-10) - } - - case "pgdown": - if m.focus == FocusEvents || m.view == ViewDetail { - m.eventsScroll += 10 - } - } - return m, nil -} - -func (m Model) View() string { - if m.quitting { - return "Shutting down...\n" - } - if m.view == ViewDetail { - return m.viewDetail() - } - return m.viewOverview() -} - -// --- STYLES --- - -var ( - titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205")) - headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) - dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) - goodStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("82")) - errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) - selectedStyle = lipgloss.NewStyle().Background(lipgloss.Color("236")).Bold(true) - focusStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("117")) -) - -// --- OVERVIEW --- - -func (m Model) viewOverview() string { - w := max(80, m.width) - var b strings.Builder - - // Header - b.WriteString(m.renderHeader(w)) - b.WriteByte('\n') - - // Running agents - b.WriteString(m.renderAgents(w)) - b.WriteByte('\n') - - // Retry queue - b.WriteString(m.renderRetries(w)) - b.WriteByte('\n') - - // Events (scrollable, takes remaining vertical space) - b.WriteString(m.renderEventsPanel(w)) - b.WriteByte('\n') - - // Footer - focusHint := "" - if m.focus == FocusAgents { - focusHint = " (agents)" - } else { - focusHint = " (events)" - } - b.WriteString(strings.Repeat("─", w) + "\n") - b.WriteString(dimStyle.Render(fmt.Sprintf("[q] Quit [Tab] Switch focus%s [↑↓] Navigate [Enter] Detail [PgUp/PgDn] Scroll", focusHint))) - - return b.String() -} - -func (m Model) renderHeader(w int) string { - uptime := time.Since(m.cfg.StartedAt).Truncate(time.Second) - running := len(m.state.Running) - maxAgents := m.state.MaxConcurrentAgents - retrying := len(m.state.RetryAttempts) - handedOff := 0 - if m.state.HandedOff != nil { - handedOff = len(m.state.HandedOff) - } - - line1 := titleStyle.Render("🎵 Symphony") + dimStyle.Render(fmt.Sprintf(" Uptime: %s", uptime)) - line2 := fmt.Sprintf("Agents: %s │ Dispatched: %d │ Handed Off: %s │ Retrying: %s │ Errors: %d", - colorCount(running, maxAgents), m.state.DispatchTotal, - goodStyle.Render(fmt.Sprintf("%d", handedOff)), - retryColor(retrying), m.state.ErrorTotal) - - return line1 + "\n" + line2 + "\n" + strings.Repeat("─", w) -} - -func (m Model) renderAgents(w int) string { - var b strings.Builder - label := "RUNNING AGENTS" - if m.focus == FocusAgents { - label = focusStyle.Render("▶ RUNNING AGENTS") - } else { - label = headerStyle.Render(label) - } - b.WriteString(label + "\n") - - if len(m.state.Running) == 0 { - b.WriteString(dimStyle.Render(" (none)") + "\n") - return b.String() - } - - // Dynamic column widths - issueW := max(20, w*35/100) - phaseW := max(12, w*20/100) - timeW := 10 - tokW := 10 - sessW := max(8, w-issueW-phaseW-timeW-tokW-6) - - hdr := fmt.Sprintf(" %-*s %-*s %-*s %-*s %s", - issueW, "Issue", phaseW, "Phase", timeW, "Time", tokW, "Tokens", "Session") - b.WriteString(hdr + "\n") - b.WriteString(" " + strings.Repeat("─", w-4) + "\n") - - for i, id := range m.agentIDs { - entry, ok := m.state.Running[id] - if !ok { - continue - } - elapsed := time.Since(entry.StartedAt).Truncate(time.Second) - issue := entry.IssueIdentifier - if issue == "" { - issue = id - } - phase := string(entry.Phase) - if phase == "" { - phase = "running" - } - tokens := entry.InputTokens + entry.OutputTokens - - row := fmt.Sprintf(" %-*s %-*s %-*s %-*s %s", - issueW, trunc(issue, issueW), - phaseW, trunc(phase, phaseW), - timeW, elapsed, - tokW, fmtTokens(tokens), - trunc(entry.SessionID, sessW)) - - if m.focus == FocusAgents && i == m.selectedAgent { - row = selectedStyle.Render(row) - } - b.WriteString(row + "\n") - } - return b.String() -} - -func (m Model) renderRetries(w int) string { - var b strings.Builder - b.WriteString(headerStyle.Render("RETRY QUEUE") + "\n") - - if len(m.state.RetryAttempts) == 0 { - b.WriteString(dimStyle.Render(" (empty)") + "\n") - return b.String() - } - - for _, entry := range m.state.RetryAttempts { - dueIn := time.Until(entry.DueAt).Truncate(time.Second) - status := fmt.Sprintf("due in %s", dueIn) - if dueIn <= 0 { - status = warnStyle.Render("firing") - } - issue := entry.IssueIdentifier - if issue == "" { - issue = trunc(entry.WorkItemID, 30) - } - b.WriteString(fmt.Sprintf(" %s → %s (attempt %d)\n", issue, status, entry.Attempt)) - } - return b.String() -} - -func (m Model) renderEventsPanel(w int) string { - var b strings.Builder - label := "RECENT EVENTS" - if m.focus == FocusEvents { - label = focusStyle.Render("▶ RECENT EVENTS") + dimStyle.Render(fmt.Sprintf(" (%d total, scroll: ↑↓ PgUp/PgDn)", len(m.events))) - } else { - label = headerStyle.Render(label) + dimStyle.Render(fmt.Sprintf(" (%d)", len(m.events))) - } - b.WriteString(label + "\n") - - if len(m.events) == 0 { - b.WriteString(dimStyle.Render(" (no events yet)") + "\n") - return b.String() - } - - viewH := m.eventsViewHeight() - start := m.eventsScroll - end := min(start+viewH, len(m.events)) - if start >= len(m.events) { - start = max(0, len(m.events)-viewH) - end = len(m.events) - } - - for _, e := range m.events[start:end] { - b.WriteString(m.formatEvent(e, w)) - } - - // Scroll indicator - if start > 0 { - b.WriteString(dimStyle.Render(" ↑ more above") + "\n") - } - if end < len(m.events) { - b.WriteString(dimStyle.Render(" ↓ more below") + "\n") - } - - return b.String() -} - -// --- DETAIL VIEW --- - -func (m Model) viewDetail() string { - w := max(80, m.width) - var b strings.Builder - - entry, ok := m.state.Running[m.selectedDetail] - if !ok { - b.WriteString(headerStyle.Render("AGENT DETAIL") + " " + dimStyle.Render("(agent no longer running)") + "\n\n") - b.WriteString(dimStyle.Render("Press [Esc] to return") + "\n") - return b.String() - } - - // Header - b.WriteString(strings.Repeat("─", w) + "\n") - b.WriteString(headerStyle.Render("AGENT DETAIL") + "\n") - b.WriteString(fmt.Sprintf(" Issue: %s\n", entry.IssueIdentifier)) - b.WriteString(fmt.Sprintf(" Phase: %s\n", entry.Phase)) - b.WriteString(fmt.Sprintf(" Branch: %s\n", entry.WorkItem.IssueIdentifier)) - b.WriteString(fmt.Sprintf(" Session: %s\n", entry.SessionID)) - b.WriteString(fmt.Sprintf(" Running: %s\n", time.Since(entry.StartedAt).Truncate(time.Second))) - b.WriteString(fmt.Sprintf(" Tokens: %s\n", fmtTokens(entry.InputTokens+entry.OutputTokens))) - b.WriteString(strings.Repeat("─", w) + "\n") - - // Events for this agent - b.WriteString(headerStyle.Render("AGENT EVENTS") + dimStyle.Render(fmt.Sprintf(" (%d)", len(m.detailEvents))) + "\n") - - viewH := m.detailEventsHeight() - start := m.eventsScroll - end := min(start+viewH, len(m.detailEvents)) - if start >= len(m.detailEvents) { - start = max(0, len(m.detailEvents)-viewH) - end = len(m.detailEvents) - } - - if len(m.detailEvents) == 0 { - b.WriteString(dimStyle.Render(" (no events for this agent)") + "\n") - } else { - for _, e := range m.detailEvents[start:end] { - b.WriteString(m.formatEvent(e, w)) - } - } - - b.WriteString(strings.Repeat("─", w) + "\n") - b.WriteString(dimStyle.Render("[Esc] Back [↑↓] Scroll [PgUp/PgDn] Page")) - - return b.String() -} - -// --- HELPERS --- - -func (m Model) formatEvent(e orchestrator.Event, w int) string { - ts := e.Time.Format("15:04:05") - issue := e.Issue - if issue == "" { - issue = e.WorkItemID - } - - style := dimStyle - switch e.Kind { - case orchestrator.EventError: - style = errorStyle - case orchestrator.EventHandoff, orchestrator.EventPRCreated: - style = goodStyle - case orchestrator.EventBlocked: - style = warnStyle - case orchestrator.EventDispatched, orchestrator.EventTurnStarted: - style = lipgloss.NewStyle().Foreground(lipgloss.Color("117")) - } - - issueW := max(15, w*25/100) - msgW := max(20, w-issueW-12) - - return fmt.Sprintf(" %s %s %s\n", - dimStyle.Render(ts), - style.Render(trunc(issue, issueW)), - trunc(e.Message, msgW)) -} - -func (m Model) eventsViewHeight() int { - // Reserve: header(3) + agents(~6) + retries(~3) + events_header(1) + footer(2) + scroll_indicators(2) - used := 17 + len(m.state.Running) + len(m.state.RetryAttempts) - return max(5, m.height-used) -} - -func (m Model) detailEventsHeight() int { - return max(5, m.height-14) // header(8) + footer(2) + padding -} - -func (m *Model) updateAgentIDs() { - m.agentIDs = m.agentIDs[:0] - for id := range m.state.Running { - m.agentIDs = append(m.agentIDs, id) - } - if m.selectedAgent >= len(m.agentIDs) { - m.selectedAgent = max(0, len(m.agentIDs)-1) - } -} - -func (m Model) filterEventsForAgent(workItemID string) []orchestrator.Event { - var filtered []orchestrator.Event - for _, e := range m.events { - if e.WorkItemID == workItemID { - filtered = append(filtered, e) - } - } - return filtered -} - -func tickCmd() tea.Cmd { - return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) -} - -func waitForEvent(ch chan orchestrator.Event) tea.Cmd { - return func() tea.Msg { return eventMsg(<-ch) } -} - -func colorCount(n, max int) string { - if n > 0 { - return goodStyle.Render(fmt.Sprintf("%d/%d", n, max)) - } - return dimStyle.Render(fmt.Sprintf("%d/%d", n, max)) -} - -func retryColor(n int) string { - if n > 0 { - return warnStyle.Render(fmt.Sprintf("%d", n)) - } - return dimStyle.Render("0") -} - -func fmtTokens(n int) string { - if n >= 1000000 { - return fmt.Sprintf("%.1fM", float64(n)/1000000) - } - if n >= 1000 { - return fmt.Sprintf("%.1fk", float64(n)/1000) - } - return fmt.Sprintf("%d", n) -} - -func trunc(s string, n int) string { - if n <= 0 { - return "" - } - if len(s) <= n { - return s - } - if n <= 1 { - return "…" - } - return s[:n-1] + "…" -} diff --git a/internal/tui/views/keys.go b/internal/tui/views/keys.go new file mode 100644 index 0000000..89aa0ee --- /dev/null +++ b/internal/tui/views/keys.go @@ -0,0 +1 @@ +package tui diff --git a/internal/tui/views/styles.go b/internal/tui/views/styles.go new file mode 100644 index 0000000..dd478d2 --- /dev/null +++ b/internal/tui/views/styles.go @@ -0,0 +1,33 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("212")) + + headerStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("99")). + BorderBottom(true). + BorderStyle(lipgloss.NormalBorder()) + + selectedStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("236")). + Foreground(lipgloss.Color("15")) + + dimStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("242")) + + warnStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("214")) + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")) + + statusBarStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + BorderTop(true). + BorderStyle(lipgloss.NormalBorder()) +) diff --git a/internal/tui/views/tui.go b/internal/tui/views/tui.go new file mode 100644 index 0000000..3f8dd8f --- /dev/null +++ b/internal/tui/views/tui.go @@ -0,0 +1,431 @@ +package tui + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/shivamstaq/github-symphony/internal/engine" +) + +// ViewMode tracks which screen the TUI is showing. +type ViewMode int + +const ( + ViewOverview ViewMode = iota + ViewDetail + ViewLogs +) + +// EngineProvider gives the TUI access to engine state. +type EngineProvider interface { + GetState() *engine.State + Emit(engine.EngineEvent) +} + +// Config for the TUI. +type Config struct { + Engine EngineProvider + StartedAt time.Time + LogDir string // .symphony/logs/ path for log viewer +} + +type tickMsg time.Time + +// Model is the Bubble Tea model. +type Model struct { + cfg Config + state *engine.State + width int + height int + quitting bool + view ViewMode + selectedAgent int + agentIDs []string + selectedDetail string + + // Log viewer state + logLines []string + logFilter string + logScroll int + logOffset int64 // file offset for tailing +} + +// New creates the TUI model. +func New(cfg Config) Model { + return Model{ + cfg: cfg, + state: cfg.Engine.GetState(), + view: ViewOverview, + } +} + +func (m Model) Init() tea.Cmd { + return tea.Tick(200*time.Millisecond, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.handleKey(msg) + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case tickMsg: + m.state = m.cfg.Engine.GetState() + m.updateAgentIDs() + readLogLines(&m) + return m, tea.Tick(200*time.Millisecond, func(t time.Time) tea.Msg { + return tickMsg(t) + }) + } + return m, nil +} + +func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Global keys + switch msg.Type { + case tea.KeyCtrlC: + m.quitting = true + return m, tea.Quit + } + + switch m.view { + case ViewOverview: + return m.handleOverviewKey(msg) + case ViewDetail: + return m.handleDetailKey(msg) + case ViewLogs: + return m.handleLogsKey(msg) + } + return m, nil +} + +func (m Model) handleOverviewKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q": + m.quitting = true + return m, tea.Quit + case "up", "k": + if m.selectedAgent > 0 { + m.selectedAgent-- + } + case "down", "j": + if m.selectedAgent < len(m.agentIDs)-1 { + m.selectedAgent++ + } + case "enter": + if m.selectedAgent < len(m.agentIDs) { + m.selectedDetail = m.agentIDs[m.selectedAgent] + m.view = ViewDetail + } + case "l": + m.view = ViewLogs + case "p": + if m.selectedAgent < len(m.agentIDs) { + id := m.agentIDs[m.selectedAgent] + m.cfg.Engine.Emit(engine.NewEvent(engine.EvtPauseRequested, id, nil)) + } + case "R": + if m.selectedAgent < len(m.agentIDs) { + id := m.agentIDs[m.selectedAgent] + m.cfg.Engine.Emit(engine.NewEvent(engine.EvtResumeRequested, id, nil)) + } + case "K": + if m.selectedAgent < len(m.agentIDs) { + id := m.agentIDs[m.selectedAgent] + m.cfg.Engine.Emit(engine.NewEvent(engine.EvtCancelRequested, id, nil)) + } + case "r": + // Force refresh handled by engine + } + return m, nil +} + +func (m Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "escape": + m.view = ViewOverview + case "p": + m.cfg.Engine.Emit(engine.NewEvent(engine.EvtPauseRequested, m.selectedDetail, nil)) + case "R": + m.cfg.Engine.Emit(engine.NewEvent(engine.EvtResumeRequested, m.selectedDetail, nil)) + case "K": + m.cfg.Engine.Emit(engine.NewEvent(engine.EvtCancelRequested, m.selectedDetail, nil)) + } + return m, nil +} + +func (m Model) handleLogsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "escape": + m.view = ViewOverview + case "up", "k": + if m.logScroll > 0 { + m.logScroll-- + } + case "down", "j": + m.logScroll++ + } + return m, nil +} + +func (m *Model) updateAgentIDs() { + if m.state == nil { + m.agentIDs = nil + return + } + ids := make([]string, 0, len(m.state.Running)) + for id := range m.state.Running { + ids = append(ids, id) + } + sort.Strings(ids) + m.agentIDs = ids + if m.selectedAgent >= len(ids) { + m.selectedAgent = max(0, len(ids)-1) + } +} + +// readLogLines reads new lines from the orchestrator log file since last offset. +func readLogLines(m *Model) { + if m.cfg.LogDir == "" { + return + } + logPath := filepath.Join(m.cfg.LogDir, "orchestrator.jsonl") + f, err := os.Open(logPath) + if err != nil { + return + } + defer func() { _ = f.Close() }() + + if m.logOffset > 0 { + _, _ = f.Seek(m.logOffset, 0) + } + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + m.logLines = append(m.logLines, line) + } + + // Get current position for next read + info, err := f.Stat() + if err == nil { + m.logOffset = info.Size() + } + + // Cap to prevent unbounded growth + const maxLogLines = 1000 + if len(m.logLines) > maxLogLines { + m.logLines = m.logLines[len(m.logLines)-maxLogLines:] + } +} + +func (m Model) View() string { + if m.quitting { + return "" + } + switch m.view { + case ViewOverview: + return m.viewOverview() + case ViewDetail: + return m.viewDetail() + case ViewLogs: + return m.viewLogs() + } + return "" +} + +// viewOverview renders the main dashboard. +func (m Model) viewOverview() string { + var b strings.Builder + + // Header + uptime := time.Since(m.cfg.StartedAt).Truncate(time.Second) + running := 0 + if m.state != nil { + running = m.state.RunningCount() + } + header := titleStyle.Render(fmt.Sprintf( + "Symphony Agents: %d Uptime: %s", + running, uptime, + )) + b.WriteString(header + "\n") + b.WriteString(headerStyle.Render(strings.Repeat("─", min(m.width, 80))) + "\n") + + // Running agents + if m.state != nil && len(m.agentIDs) > 0 { + b.WriteString("\n RUNNING AGENTS\n") + for i, id := range m.agentIDs { + entry := m.state.Running[id] + if entry == nil { + continue + } + elapsed := time.Since(entry.StartedAt).Truncate(time.Second) + phase := string(entry.Phase) + if entry.Paused { + phase = "PAUSED" + } + tokens := fmt.Sprintf("%dk", entry.TotalTokens/1000) + line := fmt.Sprintf(" %-30s %-15s %8s %8s", + truncate(entry.WorkItem.IssueIdentifier, 30), + phase, + elapsed, + tokens, + ) + if i == m.selectedAgent { + b.WriteString(selectedStyle.Render(line) + "\n") + } else { + b.WriteString(line + "\n") + } + } + } else { + b.WriteString("\n " + dimStyle.Render("No agents running") + "\n") + } + + // Retry queue + if m.state != nil && len(m.state.RetryQueue) > 0 { + b.WriteString("\n RETRY QUEUE\n") + for _, re := range m.state.RetryQueue { + dueIn := time.Until(re.DueAt).Truncate(time.Second) + fmt.Fprintf(&b, " %-30s due in %s (attempt %d)\n", + re.IssueIdentifier, dueIn, re.Attempt) + } + } + + // Metrics + if m.state != nil { + fmt.Fprintf(&b, "\n Dispatched: %d Handed off: %d Errors: %d Tokens: %dk\n", + m.state.DispatchTotal, + m.state.HandoffTotal, + m.state.ErrorTotal, + m.state.Totals.TotalTokens/1000, + ) + } + + // Status bar + b.WriteString("\n" + statusBarStyle.Render( + " [q]uit [l]ogs [p]ause [R]esume [K]ill [Enter]detail [r]efresh", + )) + + return b.String() +} + +// viewDetail renders the detail view for a specific agent. +func (m Model) viewDetail() string { + var b strings.Builder + + entry, ok := m.state.Running[m.selectedDetail] + if !ok { + b.WriteString(titleStyle.Render("Agent Detail") + "\n\n") + b.WriteString(dimStyle.Render("Agent no longer running") + "\n") + b.WriteString("\n" + statusBarStyle.Render(" [q]back")) + return b.String() + } + + b.WriteString(titleStyle.Render("Agent Detail: "+entry.WorkItem.IssueIdentifier) + "\n") + b.WriteString(headerStyle.Render(strings.Repeat("─", min(m.width, 80))) + "\n\n") + + elapsed := time.Since(entry.StartedAt).Truncate(time.Second) + phase := string(entry.Phase) + if entry.Paused { + phase = warnStyle.Render("PAUSED") + } + + fmt.Fprintf(&b, " Title: %s\n", entry.WorkItem.Title) + fmt.Fprintf(&b, " Phase: %s\n", phase) + fmt.Fprintf(&b, " Elapsed: %s\n", elapsed) + fmt.Fprintf(&b, " Turns: %d\n", entry.TurnsCompleted) + fmt.Fprintf(&b, " Tokens: %d (in: %d, out: %d)\n", + entry.TotalTokens, entry.InputTokens, entry.OutputTokens) + fmt.Fprintf(&b, " Cost: $%.4f\n", entry.CostUSD) + fmt.Fprintf(&b, " Attempt: %d\n", entry.RetryAttempt) + + if entry.WorkItem.Repository != nil { + fmt.Fprintf(&b, " Repo: %s\n", entry.WorkItem.Repository.FullName) + } + + if entry.Session != nil && entry.Session.SocketPath != "" { + fmt.Fprintf(&b, " Socket: %s\n", entry.Session.SocketPath) + } + + fmt.Fprintf(&b, "\n Last activity: %s ago\n", + time.Since(entry.LastActivityAt).Truncate(time.Second)) + + b.WriteString("\n" + statusBarStyle.Render(" [q]back [p]ause [R]esume [K]ill")) + + return b.String() +} + +// viewLogs renders the log viewer. +func (m Model) viewLogs() string { + var b strings.Builder + + filterInfo := "all" + if m.logFilter != "" { + filterInfo = m.logFilter + } + b.WriteString(titleStyle.Render("Logs") + " " + + dimStyle.Render(fmt.Sprintf("[filter: %s]", filterInfo)) + "\n") + b.WriteString(headerStyle.Render(strings.Repeat("─", min(m.width, 80))) + "\n\n") + + if len(m.logLines) == 0 { + b.WriteString(dimStyle.Render(" No log entries. Logs are written to .symphony/logs/") + "\n") + } else { + maxLines := m.height - 6 + if maxLines < 5 { + maxLines = 5 + } + start := m.logScroll + if start >= len(m.logLines) { + start = max(0, len(m.logLines)-1) + } + end := min(start+maxLines, len(m.logLines)) + for _, line := range m.logLines[start:end] { + styled := line + if strings.Contains(line, "ERROR") { + styled = errorStyle.Render(line) + } else if strings.Contains(line, "WARN") { + styled = warnStyle.Render(line) + } + b.WriteString(" " + styled + "\n") + } + } + + b.WriteString("\n" + statusBarStyle.Render(" [q]back [j/k]scroll")) + + return b.String() +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-3] + "..." +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go deleted file mode 100644 index 1fa1227..0000000 --- a/internal/webhook/webhook.go +++ /dev/null @@ -1,73 +0,0 @@ -package webhook - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "io" - "log/slog" - "net/http" - "strings" -) - -// EventCallback is called when a valid webhook event is received. -type EventCallback func(eventType string, payload []byte) - -// Handler handles GitHub webhook deliveries. -type Handler struct { - secret string - callback EventCallback -} - -// NewHandler creates a webhook handler with signature verification. -func NewHandler(secret string, callback EventCallback) *Handler { - return &Handler{ - secret: secret, - callback: callback, - } -} - -func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "failed to read body", 400) - return - } - - // Verify signature - sigHeader := r.Header.Get("X-Hub-Signature-256") - if sigHeader == "" { - slog.Warn("webhook: missing signature header") - http.Error(w, "missing signature", http.StatusUnauthorized) - return - } - - if !h.verifySignature(body, sigHeader) { - slog.Warn("webhook: invalid signature") - http.Error(w, "invalid signature", http.StatusUnauthorized) - return - } - - eventType := r.Header.Get("X-GitHub-Event") - deliveryID := r.Header.Get("X-GitHub-Delivery") - - slog.Info("webhook received", - "event", eventType, - "delivery_id", deliveryID, - "size", len(body), - ) - - h.callback(eventType, body) - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) -} - -func (h *Handler) verifySignature(body []byte, sigHeader string) bool { - sig := strings.TrimPrefix(sigHeader, "sha256=") - mac := hmac.New(sha256.New, []byte(h.secret)) - mac.Write(body) - expected := hex.EncodeToString(mac.Sum(nil)) - return hmac.Equal([]byte(sig), []byte(expected)) -} diff --git a/internal/webhook/webhook_test.go b/internal/webhook/webhook_test.go deleted file mode 100644 index d1678e1..0000000 --- a/internal/webhook/webhook_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package webhook_test - -import ( - "bytes" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "net/http/httptest" - "testing" - - "github.com/shivamstaq/github-symphony/internal/webhook" -) - -func TestWebhookHandler_ValidSignature(t *testing.T) { - secret := "test_secret_123" - var refreshed bool - - handler := webhook.NewHandler(secret, func(eventType string, payload []byte) { - refreshed = true - if eventType != "issues" { - t.Errorf("expected event type 'issues', got %q", eventType) - } - }) - - body := []byte(`{"action":"opened","issue":{"number":1}}`) - sig := computeSignature(secret, body) - - req := httptest.NewRequest("POST", "/api/v1/webhooks/github", bytes.NewReader(body)) - req.Header.Set("X-GitHub-Event", "issues") - req.Header.Set("X-Hub-Signature-256", "sha256="+sig) - req.Header.Set("Content-Type", "application/json") - - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != 200 { - t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - if !refreshed { - t.Error("expected callback to be invoked") - } -} - -func TestWebhookHandler_InvalidSignature(t *testing.T) { - handler := webhook.NewHandler("real_secret", func(string, []byte) { - t.Error("callback should not be invoked for invalid signature") - }) - - body := []byte(`{"action":"opened"}`) - req := httptest.NewRequest("POST", "/", bytes.NewReader(body)) - req.Header.Set("X-GitHub-Event", "issues") - req.Header.Set("X-Hub-Signature-256", "sha256=invalid") - - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != 401 { - t.Errorf("expected 401, got %d", w.Code) - } -} - -func TestWebhookHandler_MissingSignature(t *testing.T) { - handler := webhook.NewHandler("secret", func(string, []byte) { - t.Error("should not be called") - }) - - req := httptest.NewRequest("POST", "/", bytes.NewReader([]byte(`{}`))) - req.Header.Set("X-GitHub-Event", "issues") - // No signature header - - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != 401 { - t.Errorf("expected 401, got %d", w.Code) - } -} - -func computeSignature(secret string, body []byte) string { - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write(body) - return hex.EncodeToString(mac.Sum(nil)) -} diff --git a/test/integration/WORKFLOW_test.md b/test/integration/WORKFLOW_test.md deleted file mode 100644 index bbdfc36..0000000 --- a/test/integration/WORKFLOW_test.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -tracker: - kind: github - owner: shivamstaq - project_number: 6 - project_scope: user -github: - token: $GITHUB_TOKEN -agent: - kind: claude_code - max_concurrent_agents: 2 - max_turns: 1 -polling: - interval_ms: 5000 -pull_request: - open_pr_on_success: false - draft_by_default: true ---- -You are working on {{.work_item.issue_identifier}}: {{.work_item.title}} - -Repository: {{.repository.full_name}} -Branch: {{.branch_name}} diff --git a/test/integration/integration_test.go b/test/integration/integration_test.go deleted file mode 100644 index 3cbe22d..0000000 --- a/test/integration/integration_test.go +++ /dev/null @@ -1,169 +0,0 @@ -//go:build integration - -package integration_test - -import ( - "context" - "os" - "testing" - - "github.com/joho/godotenv" - "github.com/shivamstaq/github-symphony/internal/config" - ghub "github.com/shivamstaq/github-symphony/internal/github" - "github.com/shivamstaq/github-symphony/internal/orchestrator" -) - -func init() { - // Load .env from project root (two levels up from test/integration/) - _ = godotenv.Load("../../.env") -} - -func TestIntegration_FetchRealProjectItems(t *testing.T) { - token := os.Getenv("GITHUB_TOKEN") - if token == "" { - t.Skip("GITHUB_TOKEN not set") - } - - // Load test workflow - wf, err := config.LoadWorkflow("WORKFLOW_test.md") - if err != nil { - t.Fatalf("load workflow: %v", err) - } - - cfg, err := config.NewServiceConfig(wf.Config) - if err != nil { - t.Fatalf("parse config: %v", err) - } - - // Create GitHub client - gqlClient := ghub.NewGraphQLClient("https://api.github.com/graphql", token) - source := ghub.NewSource(gqlClient, ghub.SourceConfig{ - Owner: cfg.Tracker.Owner, - ProjectNumber: cfg.Tracker.ProjectNumber, - ProjectScope: cfg.Tracker.ProjectScope, - StatusFieldName: cfg.Tracker.StatusFieldName, - PageSize: cfg.GitHub.GraphQLPageSize, - }) - - // Fetch candidates - rawItems, err := source.FetchCandidateRaw(context.Background()) - if err != nil { - t.Fatalf("fetch candidates: %v", err) - } - - t.Logf("fetched %d raw items from GitHub Project #%d", len(rawItems), cfg.Tracker.ProjectNumber) - - if len(rawItems) == 0 { - t.Fatal("expected at least 1 project item — verify project has items in active status") - } - - // Verify normalization - for _, raw := range rawItems { - item := ghub.NormalizeWorkItem(raw, nil) - t.Logf(" item: %s — %q (status=%q, state=%q, type=%q)", - item.WorkItemID, item.Title, raw.ProjectStatus, item.State, item.ContentType) - - if item.WorkItemID == "" { - t.Error("work_item_id should not be empty") - } - if item.Title == "" { - t.Error("title should not be empty") - } - if item.ContentType == "" { - t.Error("content_type should not be empty") - } - } -} - -func TestIntegration_SourceBridgeFetchCandidates(t *testing.T) { - token := os.Getenv("GITHUB_TOKEN") - if token == "" { - t.Skip("GITHUB_TOKEN not set") - } - - wf, err := config.LoadWorkflow("WORKFLOW_test.md") - if err != nil { - t.Fatal(err) - } - cfg, err := config.NewServiceConfig(wf.Config) - if err != nil { - t.Fatal(err) - } - - gqlClient := ghub.NewGraphQLClient("https://api.github.com/graphql", token) - source := ghub.NewSource(gqlClient, ghub.SourceConfig{ - Owner: cfg.Tracker.Owner, - ProjectNumber: cfg.Tracker.ProjectNumber, - ProjectScope: cfg.Tracker.ProjectScope, - StatusFieldName: cfg.Tracker.StatusFieldName, - PageSize: 50, - }) - - bridge := orchestrator.NewSourceBridge(source, nil) - items, err := bridge.FetchCandidates(context.Background()) - if err != nil { - t.Fatalf("bridge fetch: %v", err) - } - - t.Logf("bridge returned %d work items", len(items)) - - for _, item := range items { - t.Logf(" %s: %q (status=%q, repo=%s)", - item.WorkItemID, item.Title, item.ProjectStatus, - repoName(item.Repository)) - - // Verify all fields are populated - if item.WorkItemID == "" { - t.Error("work_item_id empty") - } - if item.ProjectItemID == "" { - t.Error("project_item_id empty") - } - if item.ContentType != "issue" && item.ContentType != "draft_issue" && item.ContentType != "pull_request" { - t.Errorf("unexpected content_type: %q", item.ContentType) - } - if item.Repository != nil && item.Repository.CloneURLHTTPS == "" { - t.Error("clone URL should be derived") - } - } -} - -func TestIntegration_DoctorConnectivity(t *testing.T) { - token := os.Getenv("GITHUB_TOKEN") - if token == "" { - t.Skip("GITHUB_TOKEN not set") - } - - // Verify PAT can authenticate - provider := ghub.NewPATProvider(token) - tok, err := provider.Token(context.Background(), ghub.RepoRef{}) - if err != nil { - t.Fatalf("token: %v", err) - } - if tok == "" { - t.Fatal("empty token") - } - - // Verify API connectivity - client, err := provider.HTTPClient(context.Background(), ghub.RepoRef{}) - if err != nil { - t.Fatal(err) - } - resp, err := client.Get("https://api.github.com/user") - if err != nil { - t.Fatalf("API call: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - t.Errorf("expected 200, got %d", resp.StatusCode) - } - t.Logf("GitHub API authenticated successfully (status %d)", resp.StatusCode) -} - -func repoName(r *orchestrator.Repository) string { - if r == nil { - return "" - } - return r.FullName -} diff --git a/test/property/fsm_test.go b/test/property/fsm_test.go new file mode 100644 index 0000000..9589005 --- /dev/null +++ b/test/property/fsm_test.go @@ -0,0 +1,225 @@ +// Package property contains property-based tests for the FSM. +// These tests generate random event sequences and verify that invariants +// always hold, regardless of the sequence. +package property + +import ( + "math/rand" + "testing" + + "github.com/shivamstaq/github-symphony/internal/domain" +) + +// guardForEvent returns a guard function appropriate for the given event +// when transitioning from the given state. +func guardForEvent(state domain.ItemState, event domain.Event) func(domain.TransitionGuard) bool { + // For error events from running, randomly pick one of the two guards + if state == domain.StateRunning && event == domain.EventError { + if rand.Intn(2) == 0 { + return func(g domain.TransitionGuard) bool { return g == domain.GuardHasRetriesLeft } + } + return func(g domain.TransitionGuard) bool { return g == domain.GuardMaxRetriesExhausted } + } + // For claim/dispatch, satisfy their guards + if event == domain.EventClaim { + return func(g domain.TransitionGuard) bool { + return g == domain.GuardSlotAvailable || g == domain.GuardNone + } + } + if event == domain.EventDispatch { + return func(g domain.TransitionGuard) bool { + return g == domain.GuardConcurrencyOK || g == domain.GuardNone + } + } + return nil +} + +// TestProperty_RandomSequencesNeverPanic verifies that applying random +// event sequences to the FSM never panics and always returns either +// a valid transition or ErrInvalidTransition. +func TestProperty_RandomSequencesNeverPanic(t *testing.T) { + const numSequences = 10000 + const maxSeqLen = 50 + + for i := 0; i < numSequences; i++ { + state := domain.StateOpen + seqLen := rand.Intn(maxSeqLen) + 1 + + for j := 0; j < seqLen; j++ { + event := domain.AllEvents[rand.Intn(len(domain.AllEvents))] + guard := guardForEvent(state, event) + result, err := domain.Transition(state, event, guard) + if err == nil { + state = result.To + } + // No panic = pass + } + } +} + +// TestProperty_SingleStateInvariant verifies that after any valid transition, +// the item is in exactly one state (the target state). +func TestProperty_SingleStateInvariant(t *testing.T) { + const numSequences = 5000 + const maxSeqLen = 30 + + for i := 0; i < numSequences; i++ { + state := domain.StateOpen + + for j := 0; j < maxSeqLen; j++ { + event := domain.AllEvents[rand.Intn(len(domain.AllEvents))] + guard := guardForEvent(state, event) + result, err := domain.Transition(state, event, guard) + if err == nil { + state = result.To + } + + // Verify state is one of AllStates + found := false + for _, s := range domain.AllStates { + if state == s { + found = true + break + } + } + if !found { + t.Fatalf("state %q is not in AllStates after sequence of length %d", state, j+1) + } + } + } +} + +// TestProperty_NeedsHumanOnlyFromExpectedEvents verifies that needs_human +// is only reachable via the expected events (no_commits, stall, budget, writeback error). +func TestProperty_NeedsHumanOnlyFromExpectedEvents(t *testing.T) { + allowedToNeedsHuman := map[domain.Event]bool{ + domain.EventAgentExitedEmpty: true, + domain.EventStallDetected: true, + domain.EventBudgetExceeded: true, + domain.EventError: true, // from completed (writeback error) + } + + const numSequences = 10000 + const maxSeqLen = 50 + + for i := 0; i < numSequences; i++ { + state := domain.StateOpen + + for j := 0; j < maxSeqLen; j++ { + event := domain.AllEvents[rand.Intn(len(domain.AllEvents))] + guard := guardForEvent(state, event) + result, err := domain.Transition(state, event, guard) + if err == nil { + if result.To == domain.StateNeedsHuman && !allowedToNeedsHuman[event] { + t.Fatalf("reached needs_human via unexpected event %q from %q", + event, result.From) + } + state = result.To + } + } + } +} + +// TestProperty_HandedOffRequiresPRCreated verifies that handed_off is only +// reachable via the pr_created event. +func TestProperty_HandedOffRequiresPRCreated(t *testing.T) { + const numSequences = 10000 + const maxSeqLen = 50 + + for i := 0; i < numSequences; i++ { + state := domain.StateOpen + + for j := 0; j < maxSeqLen; j++ { + event := domain.AllEvents[rand.Intn(len(domain.AllEvents))] + guard := guardForEvent(state, event) + result, err := domain.Transition(state, event, guard) + if err == nil { + if result.To == domain.StateHandedOff && event != domain.EventPRCreated { + t.Fatalf("reached handed_off via %q from %q — only pr_created should lead here", + event, result.From) + } + state = result.To + } + } + } +} + +// TestProperty_NoTerminalStateEscape verifies that failed and handed_off +// states can only be exited via specific events (retry_manual, pr_merged, pr_closed). +func TestProperty_NoTerminalStateEscape(t *testing.T) { + terminalExits := map[domain.ItemState]map[domain.Event]bool{ + domain.StateHandedOff: { + domain.EventPRMerged: true, + domain.EventPRClosed: true, + }, + domain.StateFailed: { + domain.EventRetryManual: true, + }, + } + + const numSequences = 10000 + const maxSeqLen = 50 + + for i := 0; i < numSequences; i++ { + state := domain.StateOpen + + for j := 0; j < maxSeqLen; j++ { + event := domain.AllEvents[rand.Intn(len(domain.AllEvents))] + guard := guardForEvent(state, event) + prevState := state + result, err := domain.Transition(state, event, guard) + if err == nil { + if exits, isTerminal := terminalExits[prevState]; isTerminal { + if !exits[event] { + t.Fatalf("escaped terminal state %q via %q — only %v should work", + prevState, event, exits) + } + } + state = result.To + } + } + } +} + +// TestProperty_EventLogIsValidSequence verifies that recording transitions +// and replaying them produces a consistent state. +func TestProperty_EventLogIsValidSequence(t *testing.T) { + const numSequences = 5000 + const maxSeqLen = 30 + + for i := 0; i < numSequences; i++ { + state := domain.StateOpen + var log []domain.TransitionResult + + for j := 0; j < maxSeqLen; j++ { + event := domain.AllEvents[rand.Intn(len(domain.AllEvents))] + guard := guardForEvent(state, event) + result, err := domain.Transition(state, event, guard) + if err == nil { + log = append(log, result) + state = result.To + } + } + + // Replay the log using the recorded guards and verify same final state + replayState := domain.StateOpen + for k, entry := range log { + // Use the exact guard that was recorded to ensure deterministic replay + recordedGuard := entry.Guard + result, err := domain.Transition(replayState, entry.Event, func(g domain.TransitionGuard) bool { + return g == recordedGuard + }) + if err != nil { + t.Fatalf("replay failed at step %d: %v (from=%q event=%q guard=%q)", k, err, replayState, entry.Event, recordedGuard) + } + if result.To != entry.To { + t.Fatalf("replay diverged at step %d: got %q, want %q", k, result.To, entry.To) + } + replayState = result.To + } + + if replayState != state { + t.Fatalf("replay final state %q != original %q", replayState, state) + } + } +} diff --git a/test/scenario/harness.go b/test/scenario/harness.go new file mode 100644 index 0000000..dc111aa --- /dev/null +++ b/test/scenario/harness.go @@ -0,0 +1,211 @@ +// Package scenario provides a test harness for driving the Symphony engine +// through scripted event sequences and asserting FSM state transitions. +package scenario + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "testing" + "time" + + "github.com/shivamstaq/github-symphony/internal/agent" + agentmock "github.com/shivamstaq/github-symphony/internal/agent/mock" + "github.com/shivamstaq/github-symphony/internal/config" + "github.com/shivamstaq/github-symphony/internal/domain" + "github.com/shivamstaq/github-symphony/internal/engine" + trackermock "github.com/shivamstaq/github-symphony/internal/tracker/mock" +) + +// Harness drives the engine through scenarios with mock adapters. +type Harness struct { + t *testing.T + eng *engine.Engine + tracker *trackermock.Tracker + ctx context.Context + cancel context.CancelFunc + tmpDir string +} + +// HarnessConfig configures a test harness. +type HarnessConfig struct { + Items []domain.WorkItem + Agent agent.Agent + MaxConcurrent int + MaxRetries int + StallTimeoutMs int + Budget config.BudgetConfig + ActiveValues []string + TerminalValues []string + HandoffStatus string +} + +// NewHarness creates a test harness with the given configuration. +func NewHarness(t *testing.T, hcfg HarnessConfig) *Harness { + t.Helper() + tmpDir := t.TempDir() + + if hcfg.MaxConcurrent == 0 { + hcfg.MaxConcurrent = 5 + } + if hcfg.MaxRetries == 0 { + hcfg.MaxRetries = 3 + } + if len(hcfg.ActiveValues) == 0 { + hcfg.ActiveValues = []string{"Todo", "In Progress"} + } + if len(hcfg.TerminalValues) == 0 { + hcfg.TerminalValues = []string{"Done", "Closed"} + } + + mockAgent := hcfg.Agent + if mockAgent == nil { + mockAgent = agentmock.NewSuccessAgent() + } + + tracker := trackermock.New(hcfg.Items) + + evtLogPath := filepath.Join(tmpDir, "events.jsonl") + evtLog, err := engine.NewEventLog(evtLogPath) + if err != nil { + t.Fatal(err) + } + + cfg := &config.SymphonyConfig{} + cfg.Tracker.ActiveValues = hcfg.ActiveValues + cfg.Tracker.TerminalValues = hcfg.TerminalValues + cfg.Tracker.ExecutableItemTypes = []string{"issue"} + cfg.Agent.MaxConcurrent = hcfg.MaxConcurrent + cfg.Agent.MaxTurns = 20 + cfg.Agent.MaxContinuationRetries = hcfg.MaxRetries + cfg.Agent.MaxRetryBackoffMs = 100 // fast retries for tests + cfg.Agent.StallTimeoutMs = hcfg.StallTimeoutMs + cfg.Agent.Budget = hcfg.Budget + cfg.Polling.IntervalMs = 100 + cfg.PullRequest.HandoffStatus = hcfg.HandoffStatus + + eng := engine.New(engine.Deps{ + Config: cfg, + Tracker: tracker, + Agent: mockAgent, + EventLog: evtLog, + Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), + }) + + ctx, cancel := context.WithCancel(context.Background()) + + return &Harness{ + t: t, + eng: eng, + tracker: tracker, + ctx: ctx, + cancel: cancel, + tmpDir: tmpDir, + } +} + +// PollOnce triggers a single poll cycle and processes all resulting events. +func (h *Harness) PollOnce() { + h.t.Helper() + h.eng.HandlePollTick(h.ctx) + h.DrainEvents(time.Second) +} + +// DrainEvents processes events from the engine channel until timeout. +func (h *Harness) DrainEvents(timeout time.Duration) { + h.t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if !h.eng.ProcessOneEvent(h.ctx) { + time.Sleep(5 * time.Millisecond) + } + } +} + +// WaitForState waits until the given item reaches the expected state, or fails. +func (h *Harness) WaitForState(itemID string, expected domain.ItemState, timeout time.Duration) { + h.t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + h.eng.ProcessOneEvent(h.ctx) + if h.State(itemID) == expected { + return + } + time.Sleep(5 * time.Millisecond) + } + h.t.Fatalf("timed out waiting for item %q to reach state %q (current: %q)", + itemID, expected, h.State(itemID)) +} + +// State returns the current FSM state of an item. +func (h *Harness) State(itemID string) domain.ItemState { + return h.eng.GetState().ItemState(itemID) +} + +// IsHandedOff checks if an item is marked as handed off. +func (h *Harness) IsHandedOff(itemID string) bool { + return h.eng.GetState().HandedOff[itemID] +} + +// IsRunning checks if an item has a running worker. +func (h *Harness) IsRunning(itemID string) bool { + _, ok := h.eng.GetState().Running[itemID] + return ok +} + +// HasRetry checks if an item is in the retry queue. +func (h *Harness) HasRetry(itemID string) bool { + _, ok := h.eng.GetState().RetryQueue[itemID] + return ok +} + +// RetryAttempt returns the retry attempt count for an item. +func (h *Harness) RetryAttempt(itemID string) int { + if re, ok := h.eng.GetState().RetryQueue[itemID]; ok { + return re.Attempt + } + return 0 +} + +// DispatchTotal returns the total dispatch count. +func (h *Harness) DispatchTotal() int64 { + return h.eng.GetState().DispatchTotal +} + +// SetTrackerItems updates the mock tracker's items (simulate external changes). +func (h *Harness) SetTrackerItems(items []domain.WorkItem) { + h.tracker.SetItems(items) +} + +// Emit sends an event to the engine. +func (h *Harness) Emit(evt engine.EngineEvent) { + h.eng.Emit(evt) +} + +// RunningEntry returns the running entry for an item, or nil. +func (h *Harness) RunningEntry(itemID string) *engine.RunningEntry { + return h.eng.GetState().Running[itemID] +} + +// AssertState fails if the item is not in the expected state. +func (h *Harness) AssertState(itemID string, expected domain.ItemState) { + h.t.Helper() + got := h.State(itemID) + if got != expected { + h.t.Errorf("item %q: expected state %q, got %q", itemID, expected, got) + } +} + +// AssertNotDispatched fails if the dispatch total increased. +func (h *Harness) AssertNotDispatched(prevTotal int64) { + h.t.Helper() + if h.DispatchTotal() != prevTotal { + h.t.Error("unexpected dispatch — item should not have been dispatched") + } +} + +// Cleanup cancels the context. +func (h *Harness) Cleanup() { + h.cancel() +} diff --git a/test/scenario/scenarios_test.go b/test/scenario/scenarios_test.go new file mode 100644 index 0000000..9d543f6 --- /dev/null +++ b/test/scenario/scenarios_test.go @@ -0,0 +1,208 @@ +package scenario + +import ( + "fmt" + "testing" + "time" + + agentmock "github.com/shivamstaq/github-symphony/internal/agent/mock" + "github.com/shivamstaq/github-symphony/internal/config" + "github.com/shivamstaq/github-symphony/internal/domain" + "github.com/shivamstaq/github-symphony/internal/engine" +) + +func makeItem(id string, num int) domain.WorkItem { + return domain.WorkItem{ + WorkItemID: id, + ProjectItemID: "proj-" + id, + ContentType: "issue", + IssueNumber: &num, + IssueIdentifier: "org/repo#" + id, + Title: "Issue " + id, + Description: "Fix something in " + id, + State: "open", + ProjectStatus: "Todo", + } +} + +// Scenario 1: Happy path — dispatch → agent completes with commits → handed off +func TestScenario_HappyPath(t *testing.T) { + item := makeItem("1", 1) + h := NewHarness(t, HarnessConfig{Items: []domain.WorkItem{item}}) + defer h.Cleanup() + + h.PollOnce() + h.WaitForState("1", domain.StateHandedOff, 3*time.Second) + + h.AssertState("1", domain.StateHandedOff) + if !h.IsHandedOff("1") { + t.Error("item should be in HandedOff map") + } + if h.DispatchTotal() != 1 { + t.Errorf("expected 1 dispatch, got %d", h.DispatchTotal()) + } +} + +// Scenario 2: Retry exhaustion — agent fails repeatedly → failed state +func TestScenario_RetryExhaustion(t *testing.T) { + item := makeItem("2", 2) + h := NewHarness(t, HarnessConfig{ + Items: []domain.WorkItem{item}, + Agent: agentmock.NewFailAgent(fmt.Errorf("build failed")), + MaxRetries: 2, + }) + defer h.Cleanup() + + // First attempt + h.PollOnce() + h.WaitForState("2", domain.StateQueued, 2*time.Second) + + // Should have retry scheduled + if !h.HasRetry("2") { + t.Fatal("expected retry after first failure") + } + if h.RetryAttempt("2") != 1 { + t.Errorf("expected attempt=1, got %d", h.RetryAttempt("2")) + } +} + +// Scenario 3: No commits → needs_human (NOT retry) +func TestScenario_NoCommitsEscalation(t *testing.T) { + item := makeItem("3", 3) + h := NewHarness(t, HarnessConfig{ + Items: []domain.WorkItem{item}, + Agent: agentmock.NewNoCommitsAgent(), + }) + defer h.Cleanup() + + h.PollOnce() + h.WaitForState("3", domain.StateNeedsHuman, 3*time.Second) + + h.AssertState("3", domain.StateNeedsHuman) + + // Must NOT be in retry queue + if h.HasRetry("3") { + t.Error("no-commits exit must NOT schedule a retry — this is the cost leak bug") + } +} + +// Scenario 4: Stall recovery — agent stalls → needs_human +func TestScenario_StallRecovery(t *testing.T) { + item := makeItem("4", 4) + h := NewHarness(t, HarnessConfig{ + Items: []domain.WorkItem{item}, + Agent: &agentmock.MockAgent{ + StopReason: "completed", + NumTurns: 1, + HasCommits: true, + Delay: 10 * time.Second, // very slow agent + }, + StallTimeoutMs: 50, // 50ms timeout + }) + defer h.Cleanup() + + h.PollOnce() + + // Wait for dispatch + time.Sleep(30 * time.Millisecond) + + // Backdate activity to trigger stall + if entry := h.RunningEntry("4"); entry != nil { + entry.LastActivityAt = time.Now().Add(-200 * time.Millisecond) + } + + // Trigger another poll (which runs stall detection) + h.PollOnce() + h.WaitForState("4", domain.StateNeedsHuman, 2*time.Second) + + h.AssertState("4", domain.StateNeedsHuman) + if h.IsRunning("4") { + t.Error("stalled worker should be removed from running") + } +} + +// Scenario 5: Reconcile closed — issue closed while agent running → cancelled +func TestScenario_ReconcileClosed(t *testing.T) { + item := makeItem("5", 5) + h := NewHarness(t, HarnessConfig{ + Items: []domain.WorkItem{item}, + Agent: &agentmock.MockAgent{ + StopReason: "completed", + NumTurns: 1, + HasCommits: true, + Delay: 5 * time.Second, // slow agent + }, + }) + defer h.Cleanup() + + h.PollOnce() + time.Sleep(30 * time.Millisecond) + + // Simulate issue being closed externally + closedItem := item + closedItem.State = "closed" + h.SetTrackerItems([]domain.WorkItem{closedItem}) + + // Next poll triggers reconciliation + h.PollOnce() + h.WaitForState("5", domain.StateOpen, 2*time.Second) + + // Item should be released (back to open = not tracked) + h.AssertState("5", domain.StateOpen) + if h.IsRunning("5") { + t.Error("cancelled worker should be removed from running") + } +} + +// Scenario 6: Budget exceeded — tokens over limit → needs_human +func TestScenario_BudgetExceeded(t *testing.T) { + item := makeItem("6", 6) + h := NewHarness(t, HarnessConfig{ + Items: []domain.WorkItem{item}, + Agent: &agentmock.MockAgent{ + StopReason: "completed", + NumTurns: 1, + HasCommits: true, + Delay: 5 * time.Second, + }, + Budget: config.BudgetConfig{ + MaxTokensPerItem: 100, + }, + }) + defer h.Cleanup() + + h.PollOnce() + time.Sleep(30 * time.Millisecond) + + // Simulate token usage exceeding budget + if entry := h.RunningEntry("6"); entry != nil { + entry.TotalTokens = 200 + } + + // Budget check happens on agent updates — simulate one + h.Emit(engine.NewEvent(engine.EvtAgentUpdate, "6", engine.AgentUpdatePayload{ + Update: agentmock.MakeUpdate("tokens", 200), + })) + h.DrainEvents(time.Second) + + h.AssertState("6", domain.StateNeedsHuman) +} + +// Scenario 7: Handed-off item is NOT re-dispatched +func TestScenario_HandedOffNotRedispatched(t *testing.T) { + item := makeItem("7", 7) + h := NewHarness(t, HarnessConfig{Items: []domain.WorkItem{item}}) + defer h.Cleanup() + + // First poll — dispatch and handoff + h.PollOnce() + h.WaitForState("7", domain.StateHandedOff, 3*time.Second) + + prevDispatches := h.DispatchTotal() + + // Second poll — item still in tracker but should NOT be re-dispatched + h.PollOnce() + + h.AssertNotDispatched(prevDispatches) + h.AssertState("7", domain.StateHandedOff) +}